Files
DevFlow/src-tauri/src/commands/project.rs

701 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 项目相关命令
use std::path::Path;
use serde::{Deserialize, Serialize};
use tauri::State;
use df_ai::provider::{ChatMessage, CompletionRequest};
use df_core::types::new_id;
use df_project::scan::{
collect_sample, detect_stack, discover_projects, extract_description, is_monorepo,
normalize_path, DiscoveredProject,
};
use df_storage::models::ProjectRecord;
use crate::state::AppState;
use super::{err_str, now_millis};
/// 创建项目入参
#[derive(Debug, Deserialize)]
pub struct CreateProjectInput {
pub name: String,
#[serde(default)]
pub description: String,
pub idea_id: Option<String>,
/// 绑定的本地代码目录(可选,空=不绑定)
#[serde(default)]
pub path: Option<String>,
/// 技术栈 JSON 数组字符串(可选,空则自动探测)
#[serde(default)]
pub stack: Option<String>,
}
/// 列出未删除项目(过滤回收站)
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<ProjectRecord>, String> {
state.projects.list_active().await.map_err(err_str)
}
/// 创建项目,返回完整记录
///
/// 绑定目录时(path 非空):校验目录存在 + 防重复绑定 + 自动探测技术栈(stack 为空时)。
#[tauri::command]
pub async fn create_project(
state: State<'_, AppState>,
input: CreateProjectInput,
) -> Result<ProjectRecord, String> {
create_with_binding(&state, input.name, input.description, input.idea_id, input.path, input.stack).await
}
/// 共用「校验 + 防重 + 探测 + insert」核心 — create_project 与 import_projects_batch 共用。
///
/// 对称收敛(决策记录:217 create/bind 去重):绑定逻辑单一实现,
/// 绑定目录时统一走「校验存在 + 防重复 + 自动探测 stack(stack 入参为空时)」。
/// relocate 不并入(走 update_field 非 insert)。
///
/// 返回 insert 后的完整记录。
async fn create_with_binding(
state: &AppState,
name: String,
description: String,
idea_id: Option<String>,
path: Option<String>,
stack: Option<String>,
) -> Result<ProjectRecord, String> {
// 绑定目录:校验存在 + 防重复 + 自动探测技术栈
let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) {
Some(p) => {
if !Path::new(p).is_dir() {
return Err(format!("目录不存在: {p}"));
}
if let Some(conflict) = find_binding_conflict(state, p, None).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name));
}
// stack 优先用入参,否则自动探测(spawn_blocking 防 IO 阻塞 tokio runtime)
let stack_json = match stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(),
None => {
let root = std::path::PathBuf::from(p);
let detected = tokio::task::spawn_blocking(move || detect_stack(&root))
.await
.map_err(err_str)?
.map_err(err_str)?;
serde_json::to_string(&detected).map_err(err_str)?
}
};
(Some(p.to_string()), Some(stack_json))
}
None => (None, None),
};
let now = now_millis();
let record = ProjectRecord {
id: new_id(),
name,
description,
status: "planning".to_string(),
idea_id,
path,
stack,
created_at: now.clone(),
updated_at: now,
};
state.projects.insert(record.clone()).await.map_err(err_str)?;
Ok(record)
}
/// 导入历史项目入参
#[derive(Debug, Deserialize)]
pub struct ImportProjectInput {
/// 待导入的本地目录绝对路径(已存在)
pub path: String,
/// 项目名(可选,空=用目录名)
#[serde(default)]
pub name: Option<String>,
/// 描述(可选,空=自动读 README 首段)
#[serde(default)]
pub description: Option<String>,
/// 技术栈 JSON 数组字符串(可选,空=自动探测)
#[serde(default)]
pub stack: Option<String>,
}
/// 导入历史项目 — 用户选已存在的本地目录,复用 scan 探测 + 绑定一步创建项目记录。
///
/// 与 `create_project` 的区别:import 直接给 path,创建实体 + 绑定目录 + 探测栈 +
/// (可选)读 README 首段填 description 一次性完成,无需先建空项目再绑定。
///
/// 流程:校验目录存在 → normalize_path 防重复绑定 → detect_stack + extract_description
/// (spawn_blocking 防 IO 阻塞 tokio runtime)→ 走 create_with_binding insert → 返回。
#[tauri::command]
pub async fn import_project(
state: State<'_, AppState>,
input: ImportProjectInput,
) -> Result<ProjectRecord, String> {
let path = input.path.trim().to_string();
if path.is_empty() {
return Err("导入路径不能为空".to_string());
}
if !Path::new(&path).is_dir() {
return Err(format!("目录不存在: {path}"));
}
// 解析 name/desc/stack(入参优先,缺省时从目录探测/读 README)。
// spawn_blocking 防 IO 阻塞 tokio runtime。stack 解析后透传给 create_with_binding
// (不再重复探测,与原行为一致)。
let root = std::path::PathBuf::from(&path);
let want_name = input.name.clone();
let want_desc = input.description.clone();
let want_stack = input.stack.clone();
let (name, description, stack_json) = tokio::task::spawn_blocking(move || -> Result<_, String> {
let name = match want_name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(n) => n.to_string(),
None => root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "无法从路径解析项目名".to_string())?,
};
let description = match want_desc.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(d) => d.to_string(),
None => extract_description(&root).unwrap_or_default(),
};
let stack_json = match want_stack.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(s) => s.to_string(),
None => {
let detected = detect_stack(&root).map_err(err_str)?;
serde_json::to_string(&detected).map_err(err_str)?
}
};
Ok((name, description, stack_json))
})
.await
.map_err(err_str)??;
create_with_binding(&state, name, description, None, Some(path), Some(stack_json)).await
}
/// 按 ID 查询项目
#[tauri::command]
pub async fn get_project(
state: State<'_, AppState>,
id: String,
) -> Result<Option<ProjectRecord>, String> {
state
.projects
.get_by_id(&id)
.await
.map_err(err_str)
}
/// 更新项目单个字段(字段名走 df-storage 白名单校验)
#[tauri::command]
pub async fn update_project(
state: State<'_, AppState>,
id: String,
field: String,
value: String,
) -> Result<bool, String> {
state
.projects
.update_field(&id, &field, &value)
.await
.map_err(err_str)
}
/// 删除项目(软删 → 回收站,可恢复)
#[tauri::command]
pub async fn delete_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.projects.soft_delete(&id).await.map_err(err_str)
}
/// 列出回收站项目(deleted_at IS NOT NULL)
#[tauri::command]
pub async fn list_deleted_projects(
state: State<'_, AppState>,
) -> Result<Vec<ProjectRecord>, String> {
state.projects.list_deleted().await.map_err(err_str)
}
/// 恢复项目(从回收站还原,清 deleted_at)
#[tauri::command]
pub async fn restore_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.projects.restore(&id).await.map_err(err_str)
}
/// 彻底删除项目(级联物理删 branches/releases/tasks,不可恢复)
#[tauri::command]
pub async fn purge_project(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state
.projects
.purge_with_descendants(&id)
.await
.map_err(err_str)
}
// ============================================================
// 项目目录绑定 — 探测 / 防重复 / 重定位 / 有效性检查
// ============================================================
/// 查找已绑定该目录的项目(排除 exclude_id 自身)。无冲突返回 None。
///
/// 委托 `ProjectRepo::find_path_conflict`(DRY R-PD-11:与 tool_registry::bind_dir_to_project
/// 共用同一防重复绑定实现)。normalize_path 内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过。
async fn find_binding_conflict(
state: &AppState,
path: &str,
exclude_id: Option<&str>,
) -> Result<Option<ProjectRecord>, String> {
let norm = normalize_path(path);
state
.projects
.find_path_conflict(&norm, exclude_id)
.await
.map_err(err_str)
}
/// 探测目录技术栈(前端选目录后实时预览)
#[tauri::command]
pub async fn scan_project_stack(path: String) -> Result<Vec<String>, String> {
let root = std::path::PathBuf::from(&path);
tokio::task::spawn_blocking(move || detect_stack(&root).map_err(err_str))
.await
.map_err(err_str)?
}
/// 检查目录是否已被其他项目绑定(防重复绑定)。返回占用项目(若有)。
/// exclude_id 用于编辑/重定位时排除自身。
#[tauri::command]
pub async fn check_path_binding(
state: State<'_, AppState>,
path: String,
exclude_id: Option<String>,
) -> Result<Option<ProjectRecord>, String> {
find_binding_conflict(&state, &path, exclude_id.as_deref()).await
}
/// 重定位项目目录(目录移动后重新指向)。校验存在 + 防重复 + 重探测 stack,返回最新记录。
#[tauri::command]
pub async fn relocate_project_path(
state: State<'_, AppState>,
id: String,
new_path: String,
) -> Result<ProjectRecord, String> {
if !Path::new(&new_path).is_dir() {
return Err(format!("目录不存在: {new_path}"));
}
if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name));
}
// 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime)
let root = std::path::PathBuf::from(&new_path);
let stack = tokio::task::spawn_blocking(move || detect_stack(&root))
.await
.map_err(err_str)?
.map_err(err_str)?;
let stack_json = serde_json::to_string(&stack).map_err(err_str)?;
state
.projects
.update_field(&id, "path", &new_path)
.await
.map_err(err_str)?;
state
.projects
.update_field(&id, "stack", &stack_json)
.await
.map_err(err_str)?;
state
.projects
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| "项目不存在".to_string())
}
/// 检查目录是否存在(详情页「目录是否还在」用)
#[tauri::command]
pub async fn check_path_exists(path: String) -> Result<bool, String> {
Ok(Path::new(&path).is_dir())
}
// ============================================================
// 批量扫描/导入历史项目 — F-260614-06(scan 第二步)
// ============================================================
/// 扫描发现的候选项目(规则发现,无 LLM)。前端预览表格只读展示。
#[derive(Debug, Serialize)]
pub struct ScannedProjectItem {
pub path: String,
pub name: String,
pub stack: Vec<String>,
pub is_monorepo: bool,
/// 该目录是否已被某个项目绑定(防重复,前端标记禁选)
pub already_bound: bool,
}
/// 扫描根目录发现候选项目(规则发现,快、不跑 LLM)。
///
/// 调 `discover_projects`(monorepo 一层展开 + detect_stack 非空过滤),
/// 标记每个候选是否已被项目绑定。前端用预览表格勾选后调 import_projects_batch。
#[tauri::command]
pub async fn scan_directory_for_projects(
state: State<'_, AppState>,
root_path: String,
) -> Result<Vec<ScannedProjectItem>, String> {
let root = Path::new(&root_path);
if !root.is_dir() {
return Err(format!("目录不存在: {root_path}"));
}
// 1. 规则发现(spawn_blocking 防 IO 阻塞 tokio runtime)
let scan_root = std::path::PathBuf::from(&root_path);
let discovered: Vec<DiscoveredProject> = tokio::task::spawn_blocking(move || {
discover_projects(&scan_root)
})
.await
.map_err(err_str)?
.map_err(err_str)?;
// 2. 标已绑定项(逐项 normalize_path 查重)
let mut out = Vec::with_capacity(discovered.len());
for d in discovered {
let already_bound = find_binding_conflict(&state, &d.path, None)
.await?
.is_some();
out.push(ScannedProjectItem {
path: d.path,
name: d.name,
stack: d.stack,
is_monorepo: d.is_monorepo,
already_bound,
});
}
Ok(out)
}
/// 批量导入历史项目单条结果
#[derive(Debug, Serialize)]
pub struct ImportBatchItemResult {
/// 入参 path(回显,前端按 path 对齐结果)
pub path: String,
/// 成功:导入的项目名;失败:None
pub name: Option<String>,
/// 失败原因(成功为 None)
pub error: Option<String>,
}
/// 批量导入历史项目结果(前端 toast 汇总)
#[derive(Debug, Serialize)]
pub struct ImportBatchResult {
pub imported: usize,
pub skipped: usize,
pub items: Vec<ImportBatchItemResult>,
}
/// 单条批量导入入参
#[derive(Debug, Deserialize)]
pub struct ImportBatchItemInput {
pub path: String,
#[serde(default)]
pub name: Option<String>,
}
/// 批量导入历史项目 — 对用户勾选项并发 LLM 抽 description + 入库绑定。
///
/// F-260614-06 决策⑤:扫描(scan_directory_for_projects)纯规则发现;此命令对勾选项
/// 并发跑 LLM(复用 scan_project_with_ai 的 complete 调用)抽 description。每项独立,
/// 非原子 —— 单项失败不影响其它项,逐项结果回传。LLM 全失败 description 留空(不喂噪音),
/// 用户可在详情页手填。
///
/// 限流:llm_concurrency 双层 permit(global + per_conv)防止批量扫描打满 provider。
/// 默认 planning 状态(对齐 create_project),不关联 idea。
#[tauri::command]
pub async fn import_projects_batch(
state: State<'_, AppState>,
items: Vec<ImportBatchItemInput>,
) -> Result<ImportBatchResult, String> {
if items.is_empty() {
return Ok(ImportBatchResult {
imported: 0,
skipped: 0,
items: Vec::new(),
});
}
// 取默认 provider(优先 is_default,否则首个)。无 provider 直接报错(批量无降级路径,
// 因为 description 是核心目的,无 LLM 与单 import_project 行为不同 —— 那走 import_project)
let providers = state.ai_providers.list_all().await.map_err(err_str)?;
let pc = providers
.iter()
.find(|p| p.is_default)
.cloned()
.or_else(|| providers.into_iter().next())
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?;
// build_provider_for 返回 Box<dyn LlmProvider>(非 Clone);多 future 共享需 Arc 包装。
// LlmProvider: Send + Sync + complete(&self) → Arc 共享安全。
let boxed = crate::commands::ai::secret::build_provider_for(&pc)
.map_err(|e| format!("provider 密钥不可用: {e}"))?;
let provider: std::sync::Arc<dyn df_ai::provider::LlmProvider> = std::sync::Arc::from(boxed);
// 每项独立 future,并发 join。失败逐项记录不影响其它。
// 注:provider 通过 Arc clone 在各 future 间共享(零拷贝,引用计数)。
let futures: Vec<_> = items
.into_iter()
.map(|item| {
let state_ref = state.inner();
let provider = provider.clone();
let pc = pc.clone();
async move {
let path = item.path.trim().to_string();
if path.is_empty() {
return ImportBatchItemResult {
path,
name: None,
error: Some("路径为空".to_string()),
};
}
// 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理)
let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await {
Ok(d) => d,
Err(e) => {
// LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。
tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}");
String::new()
}
};
let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await {
Ok(rec) => ImportBatchItemResult {
path,
name: Some(rec.name),
error: None,
},
Err(e) => ImportBatchItemResult {
path,
name: None,
error: Some(e),
},
}
}
})
.collect();
let results = futures::future::join_all(futures).await;
let imported = results.iter().filter(|r| r.name.is_some()).count();
let skipped = results.len() - imported;
Ok(ImportBatchResult {
imported,
skipped,
items: results,
})
}
/// 名字解析:入参优先,否则取目录名
fn resolve_name(path: &str, want: Option<String>) -> String {
if let Some(n) = want {
return n;
}
Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| path.to_string())
}
/// 复用 scan_project_with_ai 路径抽 description(轻量子代理)。
/// 双层 llm_concurrency permit 限流 + LLM 失败/解析失败返回空 description(不报错)。
async fn extract_description_via_llm(
state: &AppState,
provider: &std::sync::Arc<dyn df_ai::provider::LlmProvider>,
pc: &df_storage::models::AiProviderRecord,
path: &str,
) -> Result<String, String> {
let root = std::path::PathBuf::from(path);
let (rule_stack, sample) = tokio::task::spawn_blocking(move || {
let stack = detect_stack(&root)?;
let sample = collect_sample(&root)?;
Ok::<_, anyhow::Error>((stack, sample))
})
.await
.map_err(err_str)?
.map_err(err_str)?;
let request = CompletionRequest {
model: pc.default_model.clone(),
messages: build_scan_prompt(&sample, &rule_stack),
temperature: Some(0.2),
max_tokens: Some(400),
stream: false,
tools: None,
tool_choice: None,
};
let _g = state.llm_concurrency.acquire_global().await;
let _c = state.llm_concurrency.acquire_per_conv().await;
let resp = provider.complete(request).await.map_err(err_str)?;
// 只取 description,其它字段丢弃(批量场景不需要 project_type/stack 细化)
let desc = parse_scan_result(&resp.text)
.map(|p| p.description)
.unwrap_or_default();
Ok(desc)
}
// ============================================================
// AI 扫描项目 — LLM 分析采样自动填基础信息
// ============================================================
/// AI 扫描项目结果(预览用,用户确认后填入 ProjectRecord)
#[derive(Debug, Serialize)]
pub struct AiScanResult {
/// LLM 产出的项目摘要(空=LLM 未得出,前端提示手填)
pub description: String,
/// 技术栈(规则探测 LLM 推断,去重小写)
pub stack: Vec<String>,
/// 项目类型(web/api/cli/library/desktop/mobile/monorepo/other)
pub project_type: Option<String>,
/// LLM 原始返回(降级时含错误信息,前端可展示)
pub raw: Option<String>,
}
/// AI 扫描项目目录,自动分析基础信息(description/stack/project_type)
///
/// 规则探测(detect_stack)兜底 + LLM 分析采样产出摘要。LLM 失败/解析失败降级纯规则。
/// 需已配置默认 AI provider(无则报错提示去设置)。
#[tauri::command]
pub async fn scan_project_with_ai(
state: State<'_, AppState>,
path: String,
) -> Result<AiScanResult, String> {
let root = Path::new(&path);
if !root.is_dir() {
return Err(format!("目录不存在: {path}"));
}
// 1. 规则探测(兜底)+ 采样(纯 IO 轻量,spawn_blocking 防 IO 阻塞 tokio runtime)
let scan_root = std::path::PathBuf::from(&path);
let (rule_stack, sample) = tokio::task::spawn_blocking(move || {
let stack = detect_stack(&scan_root)?;
let sample = collect_sample(&scan_root)?;
Ok::<_, anyhow::Error>((stack, sample))
})
.await
.map_err(err_str)?
.map_err(err_str)?;
// 2. 取默认 provider(优先 is_default,否则首个)
let providers = state.ai_providers.list_all().await.map_err(err_str)?;
let pc = providers
.iter()
.find(|p| p.is_default)
.cloned()
.or_else(|| providers.into_iter().next())
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?;
// 3. 构造 provider + LLM 调用(非流式)
// build_provider_for 含空 key 早失败:Err → 走纯规则降级(与下方 LLM 失败降级行为一致)
let provider = match crate::commands::ai::secret::build_provider_for(&pc) {
Ok(p) => p,
Err(e) => {
return Ok(AiScanResult {
description: String::new(),
stack: rule_stack,
project_type: None,
raw: Some(format!("LLM 调用失败: provider 密钥不可用: {e}")),
});
}
};
let request = CompletionRequest {
model: pc.default_model.clone(),
messages: build_scan_prompt(&sample, &rule_stack),
temperature: Some(0.2),
max_tokens: Some(400),
stream: false,
tools: None,
tool_choice: None,
};
// 4. 双层限流 + complete
let _global_permit = state.llm_concurrency.acquire_global().await;
let _per_conv_permit = state.llm_concurrency.acquire_per_conv().await;
let llm_result = provider.complete(request).await;
// 5. 解析 + 合并 stack(LLM 失败降级纯规则)
match llm_result {
Ok(resp) => {
let raw = resp.text.clone();
match parse_scan_result(&resp.text) {
Some(p) => {
let mut stack = rule_stack;
for s in p.stack {
let s = s.trim().to_lowercase();
if !s.is_empty() && !stack.contains(&s) {
stack.push(s);
}
}
Ok(AiScanResult { description: p.description, stack, project_type: p.project_type, raw: Some(raw) })
}
None => Ok(AiScanResult { description: String::new(), stack: rule_stack, project_type: None, raw: Some(raw) }),
}
}
Err(e) => Ok(AiScanResult {
description: String::new(),
stack: rule_stack,
project_type: None,
raw: Some(format!("LLM 调用失败: {e}")),
}),
}
}
struct ParsedScan {
description: String,
stack: Vec<String>,
project_type: Option<String>,
}
/// 拼 LLM 扫描 prompt(system + 采样信息)
fn build_scan_prompt(sample: &df_project::scan::ProjectSample, rule_stack: &[String]) -> Vec<ChatMessage> {
let system = "你是项目分析助手。根据给定的项目采样信息,分析并输出项目基础信息。\n\
严格只输出一个 JSON 对象,不要任何解释、markdown 代码块或额外文字。格式:\n\
{\"description\":\"一句话中文项目摘要,描述项目做什么,30-60字\",\"stack\":[\"技术栈标签(小写英文,如 vue/rust/go)\"],\"project_type\":\"web|api|cli|library|desktop|mobile|monorepo|other\"}\n\
规则:stack 用小写英文标签且去重;description 中文;project_type 从给定枚举选最接近的。若无足够信息,description 填空字符串。";
let rule = if rule_stack.is_empty() { "(无)".to_string() } else { rule_stack.join(", ") };
let tree = if sample.tree.is_empty() { "(无)".to_string() } else { sample.tree.join("\n") };
let readme = sample.readme.clone().unwrap_or_else(|| "(无)".to_string());
let manifests = if sample.manifests.is_empty() {
"(无)".to_string()
} else {
sample.manifests.iter().map(|(n, c)| format!("### {n}\n{c}")).collect::<Vec<_>>().join("\n\n")
};
let user = format!(
"## 已探测技术栈(规则)\n{rule}\n\n## 目录结构(2层)\n{tree}\n\n## README\n{readme}\n\n## 清单文件\n{manifests}"
);
vec![ChatMessage::system(system), ChatMessage::user(user)]
}
/// 解析 LLM 返回的 JSON(容错:直接解析失败则提取首个 {...} 再解析)
fn parse_scan_result(text: &str) -> Option<ParsedScan> {
let extract = |v: &serde_json::Value| -> Option<ParsedScan> {
let description = v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string();
let stack = v
.get("stack")
.and_then(|x| x.as_array())
.map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())
.unwrap_or_default();
let project_type = v.get("project_type").and_then(|x| x.as_str()).map(String::from);
Some(ParsedScan { description, stack, project_type })
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
return extract(&v);
}
// 提取首个 {...}(LLM 可能裹 markdown 代码块或前后文字)
let start = text.find('{')?;
let end = text.rfind('}')?;
if end <= start {
return None;
}
let v = serde_json::from_str::<serde_json::Value>(&text[start..=end]).ok()?;
extract(&v)
}