Skip to main content

str_format/
cmd.rs

1//! CLI 子命令实现(规范第 9 章)。
2
3use std::path::{Path, PathBuf};
4
5use serde_json::Value as JValue;
6
7use crate::bundle::{Bundle, Scan};
8use crate::error::{Error, Result, code};
9use crate::meta::{Author, Entry, Kind, Meta, MetaLoad, RefItem};
10use crate::meta_edit::{meta_from_text, render_branch_meta, render_root_meta, toml_str};
11use crate::util::{self, SCHEMA_DIR};
12use crate::{SPEC_VERSION, STR_MAJOR};
13
14// ─────────────────────────── 公共辅助 ───────────────────────────
15
16/// 打开 bundle。
17pub fn open(dir: &Path) -> Result<Bundle> {
18    Bundle::new(dir.to_path_buf())
19}
20
21/// 定位某 uuid 对应的 visit 下标。
22pub fn locate(scan: &Scan, uuid: &str) -> Option<usize> {
23    scan.resolve(uuid)
24}
25
26/// 依据扩展名猜测媒体类型。
27pub fn media_type_for(name: &str) -> Option<String> {
28    let ext = Path::new(name)
29        .extension()
30        .map(|s| s.to_string_lossy().to_lowercase())
31        .unwrap_or_default();
32    let s = match ext.as_str() {
33        "json" => "application/json",
34        "toml" => "application/toml",
35        "md" => "text/markdown",
36        "txt" => "text/plain",
37        "csv" => "text/csv",
38        "yaml" | "yml" => "application/yaml",
39        "png" => "image/png",
40        "jpg" | "jpeg" => "image/jpeg",
41        "gif" => "image/gif",
42        "webp" => "image/webp",
43        "svg" => "image/svg+xml",
44        "pdf" => "application/pdf",
45        "zip" => "application/zip",
46        _ => return None,
47    };
48    Some(s.to_string())
49}
50
51/// 目录直接子项数(排除 `._meta`)。
52fn child_count(dir: &Path) -> Option<i64> {
53    crate::validate::dir_child_count(dir)
54}
55
56// ─────────────────────────── init ───────────────────────────
57
58/// 创建一个新的 `.str` bundle。
59pub fn init(
60    dir: &Path,
61    name: Option<String>,
62    title: Option<String>,
63    summary: Option<String>,
64    id_version: usize,
65) -> Result<()> {
66    if !matches!(id_version, 4 | 7) {
67        return Err(Error::BadArg(format!(
68            "`--id-version` = {id_version} 不受支持(`policies.id_version` 只允许 4 或 7)"
69        )));
70    }
71    let target = if dir.extension().map(|e| e == "str").unwrap_or(false) {
72        dir.to_path_buf()
73    } else {
74        PathBuf::from(format!("{}.str", dir.display()))
75    };
76    if target.exists() {
77        return Err(Error::BadArg(format!("{} 已存在", target.display())));
78    }
79    let bundle_name = name.clone().unwrap_or_else(|| {
80        target
81            .file_stem()
82            .map(|s| s.to_string_lossy().to_string())
83            .unwrap_or_else(|| "bundle".to_string())
84    });
85
86    std::fs::create_dir_all(target.join(SCHEMA_DIR)).map_err(|e| Error::io(&target, e))?;
87    for (file, body) in crate::EMBEDDED_SCHEMAS {
88        let p = target.join(SCHEMA_DIR).join(file);
89        std::fs::write(&p, body).map_err(|e| Error::io(&p, e))?;
90    }
91
92    let created = util::now_rfc3339();
93    let root_id = util::new_uuid(id_version);
94    let text = render_root_meta(
95        &bundle_name,
96        title.as_deref(),
97        summary.as_deref(),
98        &root_id,
99        &created,
100        crate::EMBEDDED_SCHEMAS.len(),
101        id_version,
102    );
103    let meta = meta_from_text(&text)?;
104    let meta_path = target.join(util::META_FILE);
105    meta.save(&meta_path)?;
106
107    println!("已创建 bundle:{}", target.display());
108    println!("  spec = {SPEC_VERSION}  str = {STR_MAJOR}");
109    println!("  policies.id_version = {id_version}");
110    println!(
111        "  {} 内已写入 {} 份校验 Schema",
112        SCHEMA_DIR,
113        crate::EMBEDDED_SCHEMAS.len()
114    );
115    Ok(())
116}
117
118/// ROOT `policies.id_version`(缺省 7)—— 生成端必须产出同版本的 UUID,否则 `E_ID_VERSION`。
119fn root_id_version(bundle: &Bundle) -> usize {
120    match bundle.read_meta(&bundle.root) {
121        Ok(MetaLoad::Ok(m, _)) => m.policies.id_version,
122        _ => 7,
123    }
124}
125
126/// 写回一份 `._meta` 并登记 `E_REVISION_STALE` 的历史基线(见 [`crate::baseline`])。
127fn save_meta(bundle: &Bundle, dir: &Path, meta: &Meta) -> Result<()> {
128    meta.save(&bundle.meta_path(dir))?;
129    crate::baseline::record(bundle, dir, meta);
130    Ok(())
131}
132
133/// 取目标分支下标:给了 `uuid` 就解析,缺省为 ROOT。
134///
135/// 规范 §9:命令签名中凡 `[uuid]` 形式的位置参数均**可选**,缺省目标一律为 ROOT。
136/// 本函数是这条规则的唯一实现点 —— 新增命令时复用它,不要各自内联缺省逻辑。
137fn target_branch(scan: &Scan, uuid: Option<&str>) -> Result<usize> {
138    match uuid {
139        Some(u) => {
140            locate(scan, u).ok_or_else(|| Error::BadArg(format!("找不到分支 id `{u}`")))
141        }
142        None => scan
143            .root_index
144            .ok_or_else(|| Error::BadArg("bundle 缺少 `._meta`".into())),
145    }
146}
147
148/// `--out` 的统一出口:缺省或 `-` 走 stdout,其余路径写文件。
149fn emit(text: &str, out: Option<&str>) -> Result<()> {
150    match out {
151        None | Some("-") => {
152            print!("{text}");
153            Ok(())
154        }
155        Some(p) => std::fs::write(p, text).map_err(|e| Error::io(p, e)),
156    }
157}
158
159/// `str export` 的产物是派生数据,**不得**写回 bundle 内部(规范 §9)。
160///
161/// 比较前把目标路径的**最深已存在祖先**也 canonicalize:否则 `Bundle::new` 归一化过的
162/// 根路径与未归一化的 `--out`(macOS `/var` → `/private/var` 这类符号链接)会对不上,
163/// 判定形同虚设。
164fn resolve_out_path(bundle: &Bundle, out: &str) -> Result<PathBuf> {
165    let p = abs(out);
166    let probe = match p.parent() {
167        Some(dir) => canonical_ancestor(dir),
168        None => p.clone(),
169    };
170    if probe.starts_with(&bundle.root) {
171        return Err(Error::BadArg(format!(
172            "`--out` 不得指向 bundle 内部({} 在 {} 内):export 的产物是派生数据",
173            p.display(),
174            bundle.root.display()
175        )));
176    }
177    Ok(p)
178}
179
180/// `dir` 的最深已存在祖先(canonicalize 后);一层都不存在时原样返回。
181fn canonical_ancestor(dir: &Path) -> PathBuf {
182    let mut cur = dir.to_path_buf();
183    loop {
184        if let Ok(resolved) = std::fs::canonicalize(&cur) {
185            return resolved;
186        }
187        match cur.parent() {
188            Some(parent) if parent != cur => cur = parent.to_path_buf(),
189            _ => return dir.to_path_buf(),
190        }
191    }
192}
193
194// ─────────────────────────── validate ───────────────────────────
195
196/// 校验整个 bundle。
197pub fn validate(dir: &Path, strict: bool, json: bool, fix_manifest: bool) -> Result<i32> {
198    let bundle = open(dir)?;
199    if fix_manifest {
200        // 规范 §9:`--fix-manifest` 是「校验前先修正清单」——必须**真正写盘**。
201        sync(dir, false)?;
202    }
203    let mut report = crate::validate::validate(&bundle)?;
204    if strict {
205        // `--strict`:把告警也视为失败(CI 用)
206        for i in &mut report.issues {
207            if i.level == crate::error::Level::Warn {
208                i.level = crate::error::Level::Error;
209            }
210        }
211    }
212    if json {
213        println!(
214            "{}",
215            serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()
216        );
217    } else {
218        println!("{}", report.to_text());
219    }
220    Ok(report.exit_code())
221}
222
223// ─────────────────────────── tree ───────────────────────────
224
225/// 渲染分支树(含 `refs` 关联线)。
226pub fn tree(dir: &Path, max_depth: Option<usize>, show_refs: bool, ascii: bool) -> Result<()> {
227    let bundle = open(dir)?;
228    let scan = bundle.scan()?;
229    println!("{}", bundle.name());
230    if scan.root_index.is_none() {
231        println!("  (缺少 `._meta`,无法渲染)");
232        return Ok(());
233    }
234    render_children(&scan, 0, "", max_depth, show_refs, ascii);
235    Ok(())
236}
237
238/// 树形符号:`(false)` 为 Unicode 制表符,`(true)` 为纯 ASCII(`--ascii`)。
239fn marks(ascii: bool) -> (&'static str, &'static str, &'static str, &'static str) {
240    if ascii {
241        ("|-- ", "`-- ", "|   ", "    ")
242    } else {
243        ("├─ ", "└─ ", "│  ", "   ")
244    }
245}
246
247/// 子分支下标,顺序取父级 `entries[]` 的 `(order, path)`(规范 §4.6:`order` 为同层排序键)。
248///
249/// 与 §4.9 的落盘顺序同源,因此 `str tree` 的次序与 `._meta` 中的条目次序一致。
250fn ordered_children(scan: &Scan, idx: usize) -> Vec<usize> {
251    let parent = scan.visits[idx].meta.as_ref();
252    let mut children: Vec<(i64, String, usize)> = Vec::new();
253    for (i, v) in scan.visits.iter().enumerate() {
254        if v.parent != Some(idx) {
255            continue;
256        }
257        let name = v
258            .dir
259            .file_name()
260            .map(|s| s.to_string_lossy().to_string())
261            .unwrap_or_default();
262        let order = parent
263            .and_then(|m| m.entries.iter().find(|e| e.path == name))
264            .and_then(|e| e.order)
265            .unwrap_or(i64::MAX);
266        children.push((order, name, i));
267    }
268    children.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
269    children.into_iter().map(|(_, _, i)| i).collect()
270}
271
272fn render_children(
273    scan: &Scan,
274    idx: usize,
275    prefix: &str,
276    max_depth: Option<usize>,
277    show_refs: bool,
278    ascii: bool,
279) {
280    let Some(meta) = scan.visits[idx].meta.as_ref() else {
281        return;
282    };
283    let children = ordered_children(scan, idx);
284    let (mid, last_mark, pipe, blank) = marks(ascii);
285    if show_refs {
286        let arrow = if ascii { "->" } else { "⇢" };
287        for r in &meta.refs {
288            let target = scan
289                .resolve(&r.target)
290                .map(|i| scan.visits[i].rel.clone())
291                .unwrap_or_else(|| format!("{}(未解析)", r.target));
292            println!("{prefix}{arrow} 关联: {target}  --{}--", r.rel);
293        }
294    }
295    for (n, ci) in children.iter().enumerate() {
296        let last = n + 1 == children.len();
297        let v = &scan.visits[*ci];
298        let (title, type_) = match v.meta.as_ref() {
299            Some(m) => (
300                m.title.clone().unwrap_or_default(),
301                m.r#type.clone().unwrap_or_default(),
302            ),
303            None => (String::new(), String::new()),
304        };
305        let mark = if last { last_mark } else { mid };
306        let mut line = format!("{prefix}{mark}[{}] {}", n + 1, title);
307        if !type_.is_empty() {
308            line.push_str(&format!("  ({type_})"));
309        }
310        if v.meta.is_none() {
311            line.push_str("  ! 解析失败");
312        }
313        println!("{line}");
314        if max_depth.map(|d| v.depth < d).unwrap_or(true) {
315            let next_prefix = format!("{prefix}{}", if last { blank } else { pipe });
316            render_children(scan, *ci, &next_prefix, max_depth, show_refs, ascii);
317        }
318    }
319}
320
321// ─────────────────────────── ls / show ───────────────────────────
322
323/// 列出某分支的清单(读 `._meta`)或磁盘原始内容。
324pub fn ls(dir: &Path, uuid: Option<String>, raw: bool) -> Result<()> {
325    let bundle = open(dir)?;
326    let scan = bundle.scan()?;
327    let idx = target_branch(&scan, uuid.as_deref())?;
328    let v = &scan.visits[idx];
329    println!("{}  ({})", v.rel, if raw { "磁盘原始" } else { "清单" });
330    if raw {
331        for (name, is_dir) in bundle.list_names(&v.dir)? {
332            if util::is_meta_file(&name) {
333                continue;
334            }
335            println!("  {}{}", if is_dir { "d " } else { "- " }, name);
336        }
337        return Ok(());
338    }
339    let Some(meta) = v.meta.as_ref() else {
340        println!("  (`._meta` 解析失败)");
341        return Ok(());
342    };
343    for e in &meta.entries {
344        let mut line = format!("  {:<28} {}", e.path, e.role);
345        if let Some(t) = &e.title {
346            line.push_str(&format!("  {t}"));
347        }
348        if let Some(s) = e.size {
349            line.push_str(&format!("  {s}B"));
350        }
351        if e.optional {
352            line.push_str("  (optional)");
353        }
354        println!("{line}");
355    }
356    Ok(())
357}
358
359/// 打印某分支的 `._meta`(归一化 JSON)。(规范 §9:`<UUID>` 缺省为 ROOT。)
360pub fn show(dir: &Path, uuid: Option<String>, full: bool) -> Result<()> {
361    let bundle = open(dir)?;
362    let scan = bundle.scan()?;
363    let idx = target_branch(&scan, uuid.as_deref())?;
364    let v = &scan.visits[idx];
365    let Some(meta) = v.meta.as_ref() else {
366        return Err(Error::BadArg(format!("{} 的 `._meta` 解析失败", v.rel)));
367    };
368    println!(
369        "{}",
370        serde_json::to_string_pretty(&meta.to_json()).unwrap_or_default()
371    );
372    if full {
373        for e in &meta.entries {
374            if (e.role == "payload" || e.role == "asset") && !e.path.is_empty() {
375                let p = v.dir.join(&e.path);
376                if let Ok(text) = std::fs::read_to_string(&p) {
377                    println!("\n── {} ──\n{}", e.path, text);
378                }
379            }
380        }
381    }
382    Ok(())
383}
384
385// ─────────────────────────── node / branch ───────────────────────────
386
387/// 新增独立节点(深度 1)。
388pub fn node_add(
389    dir: &Path,
390    type_: Option<String>,
391    title: Option<String>,
392    summary: Option<String>,
393) -> Result<()> {
394    let bundle = open(dir)?;
395    let root = match bundle.read_meta(&bundle.root)? {
396        MetaLoad::Ok(m, _) => m,
397        MetaLoad::Failed(_) => {
398            return Err(Error::BadArg("root `._meta` 解析失败,无法新增节点".into()));
399        }
400    };
401    if root.kind != Some(Kind::Root) {
402        return Err(Error::BadArg("root `._meta` 的 kind 不是 root".into()));
403    }
404    let id = util::new_uuid(root.policies.id_version);
405    let created = util::now_rfc3339();
406    let child_dir = bundle.root.join(&id);
407    std::fs::create_dir_all(&child_dir).map_err(|e| Error::io(&child_dir, e))?;
408    let text = render_branch_meta(
409        Kind::Node,
410        &id,
411        type_.as_deref(),
412        title.as_deref(),
413        summary.as_deref(),
414        &created,
415    );
416    let child = meta_from_text(&text)?;
417    save_meta(&bundle, &child_dir, &child)?;
418
419    let mut root = root;
420    let order = root
421        .entries
422        .iter()
423        .filter(|e| e.role == "node")
424        .filter_map(|e| e.order)
425        .max()
426        .unwrap_or(0)
427        + 1;
428    let entry = Entry {
429        path: id.clone(),
430        role: "node".into(),
431        id: Some(id.clone()),
432        r#type: type_,
433        title,
434        summary,
435        order: Some(order),
436        ..Default::default()
437    };
438    root.upsert_entry(&entry);
439    root.sort_collections();
440    root.touch();
441    save_meta(&bundle, &bundle.root, &root)?;
442    println!("已新增独立节点 {id}(深度 1)");
443    Ok(())
444}
445
446/// 在指定分支下新增关联分支(任意深度)。
447///
448/// 规范 §9:`<ANCHOR-UUID>` 缺省为 ROOT;但 ROOT 的直接子分支是 `node` 而非 `branch`,
449/// 因此缺省/显式给出 ROOT 时由下方的深度判据拒绝并指引到 `str node add`。
450pub fn branch_add(
451    dir: &Path,
452    anchor: Option<String>,
453    type_: Option<String>,
454    title: Option<String>,
455    summary: Option<String>,
456    order: Option<i64>,
457) -> Result<()> {
458    let bundle = open(dir)?;
459    let scan = bundle.scan()?;
460    let idx = target_branch(&scan, anchor.as_deref())?;
461    if scan.visits[idx].depth == 0 {
462        return Err(Error::BadArg(
463            "ROOT 的直接子分支应使用 `str node add`(role = node);\
464             `branch add` 的锚点须是深度 ≥ 1 的分支"
465                .into(),
466        ));
467    }
468    let anchor_dir = scan.visits[idx].dir.clone();
469    let mut parent = match bundle.read_meta(&anchor_dir)? {
470        MetaLoad::Ok(m, _) => m,
471        MetaLoad::Failed(_) => return Err(Error::BadArg("锚点 `._meta` 解析失败".into())),
472    };
473
474    let id = util::new_uuid(root_id_version(&bundle));
475    let created = util::now_rfc3339();
476    let child_dir = anchor_dir.join(&id);
477    std::fs::create_dir_all(&child_dir).map_err(|e| Error::io(&child_dir, e))?;
478    let text = render_branch_meta(
479        Kind::Branch,
480        &id,
481        type_.as_deref(),
482        title.as_deref(),
483        summary.as_deref(),
484        &created,
485    );
486    let child = meta_from_text(&text)?;
487    save_meta(&bundle, &child_dir, &child)?;
488
489    let order = order.unwrap_or_else(|| {
490        parent
491            .entries
492            .iter()
493            .filter(|e| e.is_branch())
494            .filter_map(|e| e.order)
495            .max()
496            .unwrap_or(0)
497            + 1
498    });
499    let entry = Entry {
500        path: id.clone(),
501        role: "branch".into(),
502        id: Some(id.clone()),
503        r#type: type_,
504        title,
505        summary,
506        order: Some(order),
507        ..Default::default()
508    };
509    parent.upsert_entry(&entry);
510    parent.sort_collections();
511    parent.touch();
512    save_meta(&bundle, &anchor_dir, &parent)?;
513    println!("已在 {} 下新增关联分支 {id}(深度 {})", scan.visits[idx].rel, scan.visits[idx].depth + 1);
514    Ok(())
515}
516
517/// 删除关联分支(含其全部下级)。
518///
519/// 规范 §9:`<UUID>` 缺省为 ROOT;而 ROOT 不可删除,故缺省调用会得到明确拒绝
520/// (而不是靠「参数缺失」这种不含原因的错误挡住)。
521pub fn branch_rm(dir: &Path, uuid: Option<String>, force: bool) -> Result<()> {
522    let bundle = open(dir)?;
523    let scan = bundle.scan()?;
524    let idx = target_branch(&scan, uuid.as_deref())?;
525    let v = &scan.visits[idx];
526    if v.depth == 0 {
527        return Err(Error::BadArg(
528            "不能删除 ROOT(`<UUID>` 缺省即为 ROOT,请显式给出要删除的分支 id)".into(),
529        ));
530    }
531    if !force {
532        return Err(Error::BadArg(format!(
533            "删除 {} 会移除其全部下级,请加 `--force` 确认",
534            v.rel
535        )));
536    }
537    let target = v.dir.clone();
538    let rel = v.rel.clone();
539    let parent_idx = v.parent;
540    std::fs::remove_dir_all(&target).map_err(|e| Error::io(&target, e))?;
541
542    if let Some(pi) = parent_idx {
543        let parent_dir = scan.visits[pi].dir.clone();
544        let name = target
545            .file_name()
546            .map(|s| s.to_string_lossy().to_string())
547            .unwrap_or_default();
548        let mut parent = match bundle.read_meta(&parent_dir)? {
549            MetaLoad::Ok(m, _) => m,
550            MetaLoad::Failed(_) => return Ok(()),
551        };
552        parent.remove_entry_path(&name);
553        parent.touch();
554        save_meta(&bundle, &parent_dir, &parent)?;
555    }
556    // 重扫一遍以丢弃被删分支的基线条目(否则 `E_REVISION_STALE` 基线会残留)
557    if let Ok(after) = bundle.scan() {
558        crate::baseline::record_scan(&bundle, &after);
559    }
560    println!("已删除 {rel}");
561    Ok(())
562}
563
564// ─────────────────────────── ref ───────────────────────────
565
566/// 新增跨枝关联线。(规范 §9:源分支 `<UUID>` 缺省为 ROOT;`--target` 仍必填。)
567pub fn ref_add(
568    dir: &Path,
569    uuid: Option<String>,
570    target: &str,
571    rel: String,
572    title: Option<String>,
573    note: Option<String>,
574) -> Result<()> {
575    let bundle = open(dir)?;
576    let scan = bundle.scan()?;
577    let idx = target_branch(&scan, uuid.as_deref())?;
578    if locate(&scan, target).is_none() {
579        return Err(Error::BadArg(format!("找不到目标分支 id `{target}`")));
580    }
581    let src_dir = scan.visits[idx].dir.clone();
582    let mut meta = match bundle.read_meta(&src_dir)? {
583        MetaLoad::Ok(m, _) => m,
584        MetaLoad::Failed(_) => return Err(Error::BadArg("源分支 `._meta` 解析失败".into())),
585    };
586    let ref_id = util::new_uuid_v7();
587    let order = meta.refs.len() as i64 + 1;
588    meta.push_ref(&RefItem {
589        id: ref_id.clone(),
590        target: target.to_string(),
591        rel,
592        title,
593        order: Some(order),
594        note,
595    });
596    meta.touch();
597    save_meta(&bundle, &src_dir, &meta)?;
598    // 源分支可能是缺省来的 ROOT(其 `id` 与目录名无关),故打印解析后的真实 `id`
599    let src_id = meta
600        .id
601        .clone()
602        .unwrap_or_else(|| scan.visits[idx].rel.clone());
603    println!("已新增关联线 {ref_id}:{src_id} → {target}");
604    Ok(())
605}
606
607/// 删除关联线。
608///
609/// 规范 §9 的形式是 `str ref rm <dir> <ref-uuid>`:只给关联线 id,由 CLI 在全 bundle 内定位
610/// 它所属的源分支。`--uuid <源分支>` 可把搜索范围钉死在一个分支上(旧版 `--ref` 形式等价)。
611pub fn ref_rm(dir: &Path, uuid: Option<String>, ref_id: &str) -> Result<()> {
612    let bundle = open(dir)?;
613    let scan = bundle.scan()?;
614    let has_ref = |i: usize| -> bool {
615        scan.visits[i]
616            .meta
617            .as_ref()
618            .map(|m| m.refs.iter().any(|r| r.id == ref_id))
619            .unwrap_or(false)
620    };
621    let idx = match uuid {
622        Some(u) => {
623            let i = locate(&scan, &u).ok_or_else(|| Error::BadArg(format!("找不到分支 id `{u}`")))?;
624            if !has_ref(i) {
625                return Err(Error::BadArg(format!("分支 `{u}` 内找不到关联线 `{ref_id}`")));
626            }
627            i
628        }
629        None => {
630            let hits: Vec<usize> = (0..scan.visits.len()).filter(|i| has_ref(*i)).collect();
631            match hits.as_slice() {
632                [only] => *only,
633                [] => return Err(Error::BadArg(format!("找不到关联线 `{ref_id}`"))),
634                _ => {
635                    let rels: Vec<String> =
636                        hits.iter().map(|i| scan.visits[*i].rel.clone()).collect();
637                    return Err(Error::BadArg(format!(
638                        "关联线 `{ref_id}` 在多个分支中出现({}),请用 `--uuid` 指定源分支",
639                        rels.join("、")
640                    )));
641                }
642            }
643        }
644    };
645    let rel = scan.visits[idx].rel.clone();
646    let src_dir = scan.visits[idx].dir.clone();
647    let mut meta = match bundle.read_meta(&src_dir)? {
648        MetaLoad::Ok(m, _) => m,
649        MetaLoad::Failed(_) => return Err(Error::BadArg("分支 `._meta` 解析失败".into())),
650    };
651    if !meta.remove_ref(ref_id) {
652        return Err(Error::BadArg(format!("找不到关联线 `{ref_id}`")));
653    }
654    meta.touch();
655    save_meta(&bundle, &src_dir, &meta)?;
656    println!("已删除关联线 {ref_id}(源分支 {rel})");
657    Ok(())
658}
659
660// ─────────────────── meta / entry / author(写入既有字段)───────────────────
661
662/// 允许的 `authors[].role`(规范 §4.4 与 `._schema` 枚举一致)。
663const AUTHOR_ROLES: &[&str] = &["owner", "editor", "viewer", "agent"];
664
665/// 读取目标分支的 `._meta`(供字段写入类命令复用)。
666fn read_target_meta(bundle: &Bundle, dir: &Path) -> Result<Meta> {
667    match bundle.read_meta(dir)? {
668        MetaLoad::Ok(m, _) => Ok(*m),
669        MetaLoad::Failed(_) => Err(Error::BadArg(format!(
670            "{} 的 `._meta` 解析失败",
671            bundle.rel(dir)
672        ))),
673    }
674}
675
676/// 至少给出一个字段,否则拒绝执行(避免「无参数空写」把 `revision` 白白推进)。
677fn need_one(given: bool, hint: &str) -> Result<()> {
678    if given {
679        Ok(())
680    } else {
681        Err(Error::BadArg(format!("至少需要指定一个字段({hint})")))
682    }
683}
684
685/// `str meta set`:设置分支自身(`._meta` 顶层)的元信息字段。
686///
687/// 空串表示**移除**该字段。字段语义见规范 §4.3。
688pub fn meta_set(
689    dir: &Path,
690    uuid: Option<String>,
691    type_: Option<String>,
692    title: Option<String>,
693    summary: Option<String>,
694    name: Option<String>,
695    tags: Option<Vec<String>>,
696) -> Result<()> {
697    need_one(
698        type_.is_some() || title.is_some() || summary.is_some() || name.is_some() || tags.is_some(),
699        "--type / --title / --summary / --name / --tags",
700    )?;
701    let bundle = open(dir)?;
702    let scan = bundle.scan()?;
703    let idx = target_branch(&scan, uuid.as_deref())?;
704    let target_dir = scan.visits[idx].dir.clone();
705    let rel = scan.visits[idx].rel.clone();
706    let mut meta = read_target_meta(&bundle, &target_dir)?;
707
708    if let Some(v) = &type_ {
709        meta.set_str_or_remove("type", v);
710    }
711    if let Some(v) = &title {
712        meta.set_str_or_remove("title", v);
713    }
714    if let Some(v) = &summary {
715        meta.set_str_or_remove("summary", v);
716    }
717    if let Some(v) = &name {
718        meta.set_str_or_remove("name", v);
719    }
720    if let Some(v) = &tags {
721        // `--tags ""` → 清空;顺带滤掉空项(否则写出 `tags = [""]` 会被 Schema 拒绝)
722        let cleaned: Vec<String> = v.iter().filter(|s| !s.is_empty()).cloned().collect();
723        meta.set_str_array("tags", &cleaned);
724    }
725
726    meta.touch();
727    save_meta(&bundle, &target_dir, &meta)?;
728    println!("已更新 {rel} 的元信息");
729    Ok(())
730}
731
732/// `str entry set` 的字段补丁:`None` 表示不改动,`Some("")` 表示移除该键。
733#[derive(Debug, Clone, Default)]
734pub struct EntryPatch {
735    /// 子分支类型。
736    pub type_: Option<String>,
737    /// 展示名。
738    pub title: Option<String>,
739    /// 子分支摘要。
740    pub summary: Option<String>,
741    /// 备注。
742    pub note: Option<String>,
743    /// 同层排序键。
744    pub order: Option<i64>,
745}
746
747impl EntryPatch {
748    /// 是否一个字段都没给。
749    pub fn is_empty(&self) -> bool {
750        self.type_.is_none()
751            && self.title.is_none()
752            && self.summary.is_none()
753            && self.note.is_none()
754            && self.order.is_none()
755    }
756}
757
758/// `str entry set`:设置某分支 `entries[]` 中指定 `path` 条目的字段。
759///
760/// `str sync` 补登出来的行只有 `path` / `role` / `id`,其 `type` / `title` / `summary`
761/// 由此命令补齐 —— 不再需要「手改 `._meta` 的唯一例外」。空串表示移除该字段。
762pub fn entry_set(dir: &Path, uuid: Option<String>, path: &str, patch: &EntryPatch) -> Result<()> {
763    need_one(
764        !patch.is_empty(),
765        "--type / --title / --summary / --note / --order",
766    )?;
767    let bundle = open(dir)?;
768    let scan = bundle.scan()?;
769    let idx = target_branch(&scan, uuid.as_deref())?;
770    let target_dir = scan.visits[idx].dir.clone();
771    let rel = scan.visits[idx].rel.clone();
772    let mut meta = read_target_meta(&bundle, &target_dir)?;
773
774    let mut applied = false;
775    for (key, val) in [
776        ("type", &patch.type_),
777        ("title", &patch.title),
778        ("summary", &patch.summary),
779        ("note", &patch.note),
780    ] {
781        if let Some(v) = val {
782            applied |= meta.set_entry_str(path, key, v);
783        }
784    }
785    if let Some(n) = patch.order {
786        applied |= meta.set_entry_int(path, "order", Some(n));
787    }
788    if !applied {
789        return Err(Error::BadArg(format!(
790            "{rel} 的 `entries[]` 内找不到 `path` = {path:?}"
791        )));
792    }
793
794    meta.touch();
795    save_meta(&bundle, &target_dir, &meta)?;
796    println!("已更新 {rel} 的条目 {path}");
797    Ok(())
798}
799
800/// `str author add`:按 `id` 新增 / 覆盖一条 `[[authors]]`(规范 §4.4)。
801pub fn author_add(
802    dir: &Path,
803    uuid: Option<String>,
804    id: String,
805    name: Option<String>,
806    role: String,
807    at: Option<String>,
808) -> Result<()> {
809    if !AUTHOR_ROLES.contains(&role.as_str()) {
810        return Err(Error::BadArg(format!(
811            "`--role` = {role:?} 非法(owner / editor / viewer / agent)"
812        )));
813    }
814    let at = match at {
815        Some(v) => {
816            let dt = v.parse::<toml_edit::Datetime>().map_err(|_| {
817                Error::BadArg(format!(
818                    "`--at` = {v:?} 不是合法 offset date-time(如 2026-09-14T10:03:11+08:00)"
819                ))
820            })?;
821            if dt.offset.is_none() {
822                return Err(Error::BadArg(format!("`--at` = {v:?} 缺少时区偏移(规范 4.1)")));
823            }
824            Some(dt.to_string())
825        }
826        None => Some(util::now_rfc3339()),
827    };
828
829    let bundle = open(dir)?;
830    let scan = bundle.scan()?;
831    let idx = target_branch(&scan, uuid.as_deref())?;
832    let target_dir = scan.visits[idx].dir.clone();
833    let rel = scan.visits[idx].rel.clone();
834    let mut meta = read_target_meta(&bundle, &target_dir)?;
835
836    let added = meta.upsert_author(&Author {
837        id: id.clone(),
838        name,
839        role: role.clone(),
840        at,
841    });
842    meta.touch();
843    save_meta(&bundle, &target_dir, &meta)?;
844    println!(
845        "已{} {rel} 的协作者 {id}(role = {role})",
846        if added { "新增" } else { "更新" }
847    );
848    Ok(())
849}
850
851/// `str author rm`:按 `id` 删除一条 `[[authors]]`。
852pub fn author_rm(dir: &Path, uuid: Option<String>, id: &str) -> Result<()> {
853    let bundle = open(dir)?;
854    let scan = bundle.scan()?;
855    let idx = target_branch(&scan, uuid.as_deref())?;
856    let target_dir = scan.visits[idx].dir.clone();
857    let rel = scan.visits[idx].rel.clone();
858    let mut meta = read_target_meta(&bundle, &target_dir)?;
859    if !meta.remove_author(id) {
860        return Err(Error::BadArg(format!("{rel} 内找不到协作者 `{id}`")));
861    }
862    meta.touch();
863    save_meta(&bundle, &target_dir, &meta)?;
864    println!("已删除 {rel} 的协作者 {id}");
865    Ok(())
866}
867
868// ─────────────────────────── spec ───────────────────────────
869
870/// 校验并规整 `spec` 版本串。
871///
872/// 只接受形如 `1.<minor>.<patch>` 的值 —— 与 `._schema` 里 `^1\.[0-9]+\.[0-9]+$` 的正则一致
873/// (写进去的必须能通过 bundle 自己声明的 Schema);允许 `v` 前缀,规整时去掉。
874/// `str` 主版本固定为 `1`(规范 §13:只有 `str` 主版本需要工具显式支持)。
875fn normalize_spec_version(raw: &str) -> Result<String> {
876    let v = raw.trim().trim_start_matches('v').trim().to_string();
877    let parts: Vec<&str> = v.split('.').collect();
878    let ok = parts.len() == 3
879        && parts[0] == "1"
880        && parts[1].parse::<u64>().is_ok()
881        && parts[2].parse::<u64>().is_ok();
882    if !ok {
883        return Err(Error::BadArg(format!(
884            "`spec` 版本串 {raw:?} 非法:须形如 `1.<minor>.<patch>`(`str` 主版本固定为 `1`)"
885        )));
886    }
887    Ok(v)
888}
889
890/// `str spec set`:把整份 bundle 声明的规范版本(`._meta.spec`)统一改写为 `version`。
891///
892/// 为什么是**整份 bundle**:`spec` 在三种档位里都是必填字段(规范 §4.3),只改 ROOT 会让
893/// 其余分支的声明与 ROOT 不一致 —— 文档就在说谎。子 bundle(`.str` 目录)是硬边界(§3.5),
894/// 不进入。只改写与目标值不同的分支,因此**幂等**(第二次「已更新 0 份」)。
895///
896/// 这是「不再需要手改 `._meta`」的最后一块:v1.9.0 之前 `spec` 没有任何 CLI 写入命令。
897pub fn spec_set(dir: &Path, version: &str, dry_run: bool) -> Result<()> {
898    let version = normalize_spec_version(version)?;
899    let bundle = open(dir)?;
900    let scan = bundle.scan()?;
901
902    // 先整体体检:任何一份 `._meta` 解析失败就拒绝执行 —— 既不半途写一半,也不静默跳过
903    let broken: Vec<String> = scan
904        .visits
905        .iter()
906        .filter(|v| v.meta.is_none())
907        .map(|v| v.rel.clone())
908        .collect();
909    if !broken.is_empty() {
910        return Err(Error::BadArg(format!(
911            "{} 份 `._meta` 解析失败({}),改 `spec` 前请先修复",
912            broken.len(),
913            broken.join("、")
914        )));
915    }
916
917    // 计划:只挑 `spec` 与目标值不同的分支
918    let mut plan: Vec<(usize, String)> = Vec::new();
919    for (i, v) in scan.visits.iter().enumerate() {
920        let from = v
921            .meta
922            .as_ref()
923            .and_then(|m| m.spec.clone())
924            .unwrap_or_else(|| "(缺失)".to_string());
925        if from == version {
926            continue;
927        }
928        plan.push((i, from));
929    }
930    for (i, from) in &plan {
931        println!("~ {}  spec: {from} → {version}", scan.visits[*i].rel);
932    }
933    if !plan.is_empty() && version != SPEC_VERSION {
934        println!(
935            "注意:目标 `spec` = {version} 与本实现对应的规范版本({SPEC_VERSION})不同;\
936             `spec` 仅用于人类追溯,工具只强校验 `str` 主版本(规范 §13)"
937        );
938    }
939
940    if plan.is_empty() {
941        println!(
942            "全部 `._meta` 的 `spec` 已是 {version}({} 份)",
943            scan.visits.len()
944        );
945        return Ok(());
946    }
947    if dry_run {
948        println!("(dry-run)将更新 {} 份 `._meta`", plan.len());
949        return Ok(());
950    }
951
952    for (i, _) in &plan {
953        let branch_dir = scan.visits[*i].dir.clone();
954        let mut work = match bundle.read_meta(&branch_dir)? {
955            MetaLoad::Ok(m, _) => m,
956            MetaLoad::Failed(_) => {
957                // 体检已通过,此处仅防御并发改动;一旦发生就中止,不留下写一半的结果
958                return Err(Error::BadArg(format!(
959                    "{} 的 `._meta` 解析失败,已中止(未写出部分结果)",
960                    scan.visits[*i].rel
961                )));
962            }
963        };
964        let had_spec = work.doc.as_table().contains_key("spec");
965        work.set_str("spec", &version);
966        if !had_spec {
967            // 缺失时插入会落到文件末尾,而 TOML 要求裸键写在任何表头之前 → 用 §4.9 规范化归位
968            work.canonicalize();
969        }
970        work.touch();
971        save_meta(&bundle, &branch_dir, &work)?;
972    }
973    println!("已更新 {} 份 `._meta`", plan.len());
974    Ok(())
975}
976
977// ─────────────────────────── sync ───────────────────────────
978
979/// 用磁盘实际状态修正全部 `entries`,并更新 `size` / `sha256`。
980pub fn sync(dir: &Path, dry_run: bool) -> Result<()> {
981    let bundle = open(dir)?;
982    let scan = bundle.scan()?;
983    let mut changed = 0usize;
984    let mut planned: Vec<String> = Vec::new();
985
986    for v in &scan.visits {
987        if v.meta.is_none() {
988            continue;
989        }
990        let dir_path = v.dir.clone();
991        let rel = v.rel.clone();
992        let mut work = match bundle.read_meta(&v.dir)? {
993            MetaLoad::Ok(m, _) => m,
994            MetaLoad::Failed(_) => continue,
995        };
996        let real = crate::validate::real_entries(&dir_path);
997        let declared: Vec<Entry> = work.entries.clone();
998        let mut touched = false;
999
1000        // 补登
1001        for (name, is_dir) in &real {
1002            if declared.iter().any(|e| e.path == *name) {
1003                continue;
1004            }
1005            let path = dir_path.join(name);
1006            let e = if *is_dir {
1007                if bundle.has_meta(&path) {
1008                    Entry {
1009                        path: name.clone(),
1010                        role: if v.depth == 0 { "node" } else { "branch" }.into(),
1011                        id: Some(name.clone()),
1012                        order: Some(declared.len() as i64 + 1),
1013                        ..Default::default()
1014                    }
1015                } else {
1016                    Entry {
1017                        path: name.clone(),
1018                        role: "dir".into(),
1019                        count: child_count(&path),
1020                        ..Default::default()
1021                    }
1022                }
1023            } else {
1024                let meta_info = std::fs::metadata(&path).ok();
1025                let size = meta_info.map(|m| m.len() as i64);
1026                let sha = util::sha256_file(&path).ok();
1027                Entry {
1028                    path: name.clone(),
1029                    role: guess_file_role(name).into(),
1030                    media_type: media_type_for(name),
1031                    size,
1032                    sha256: sha,
1033                    ..Default::default()
1034                }
1035            };
1036            planned.push(format!("+ {rel}/{name}  role={}", e.role));
1037            work.upsert_entry(&e);
1038            touched = true;
1039        }
1040
1041        // 移除已消失的条目 + 刷新指纹
1042        for e in &declared {
1043            let path = dir_path.join(&e.path);
1044            if !path.exists() {
1045                if !e.optional {
1046                    planned.push(format!("- {rel}/{}", e.path));
1047                    work.remove_entry_path(&e.path);
1048                    touched = true;
1049                }
1050                continue;
1051            }
1052            if !e.is_file_like() {
1053                continue;
1054            }
1055            let size = std::fs::metadata(&path).ok().map(|m| m.len() as i64);
1056            let sha = util::sha256_file(&path).ok();
1057            if size != e.size || sha != e.sha256 {
1058                planned.push(format!("~ {rel}/{}  指纹更新", e.path));
1059                let mut ne = e.clone();
1060                ne.size = size;
1061                ne.sha256 = sha;
1062                if ne.media_type.is_none() {
1063                    ne.media_type = media_type_for(&e.path);
1064                }
1065                work.upsert_entry(&ne);
1066                touched = true;
1067            }
1068        }
1069
1070        if touched {
1071            work.sort_collections();
1072            work.touch();
1073            changed += 1;
1074            if !dry_run {
1075                work.save(&bundle.meta_path(&dir_path))?;
1076            }
1077        }
1078    }
1079
1080    for line in &planned {
1081        println!("{line}");
1082    }
1083    if dry_run {
1084        println!("(dry-run)将更新 {changed} 份 `._meta`");
1085    } else {
1086        // `sync` 是「与磁盘对齐」的总入口:顺手把 `E_REVISION_STALE` 的基线刷成当前状态。
1087        if let Ok(after) = bundle.scan() {
1088            crate::baseline::record_scan(&bundle, &after);
1089        }
1090        println!("已更新 {changed} 份 `._meta`");
1091    }
1092    Ok(())
1093}
1094
1095fn guess_file_role(name: &str) -> &'static str {
1096    match media_type_for(name).as_deref() {
1097        Some("application/json")
1098        | Some("application/toml")
1099        | Some("application/yaml")
1100        | Some("text/csv")
1101        | Some("text/markdown")
1102        | Some("text/plain") => "payload",
1103        _ => "asset",
1104    }
1105}
1106
1107// ─────────────────────────── fmt ───────────────────────────
1108
1109/// 按规范 §4.9 键序 / 表序重写 `._meta`(保注释)。
1110///
1111/// 规范化**不触碰** `revision` / `updated_at`,因此不影响 `E_REVISION_STALE` 基线。
1112pub fn fmt(dir: &Path, check: bool, strip_comments: bool) -> Result<i32> {
1113    let bundle = open(dir)?;
1114    let scan = bundle.scan()?;
1115    let mut would_change = 0usize;
1116    for v in &scan.visits {
1117        if v.meta.is_none() {
1118            continue;
1119        }
1120        let meta = match bundle.read_meta(&v.dir)? {
1121            MetaLoad::Ok(m, _) => m,
1122            MetaLoad::Failed(_) => continue,
1123        };
1124        let text = meta.canonical_text(strip_comments);
1125        let path = bundle.meta_path(&v.dir);
1126        let current = std::fs::read_to_string(&path).unwrap_or_default();
1127        if current != text {
1128            would_change += 1;
1129            if !check {
1130                std::fs::write(&path, text).map_err(|e| Error::io(&path, e))?;
1131                println!("已规范化 {}", v.rel);
1132            }
1133        }
1134    }
1135    if check {
1136        if would_change == 0 {
1137            println!("全部 `._meta` 已是规范形式");
1138            return Ok(0);
1139        }
1140        println!("{would_change} 份 `._meta` 需要规范化");
1141        return Ok(1);
1142    }
1143    if would_change == 0 {
1144        println!("全部 `._meta` 已是规范形式");
1145    }
1146    Ok(0)
1147}
1148
1149// ─────────────────────────── norm / context / export ───────────────────────────
1150
1151/// 输出归一化 JSON。
1152///
1153/// `--out -`(或缺省)写 stdout;给路径则写文件(规范 §9)。
1154/// `<UUID>` 缺省为 ROOT。
1155pub fn norm(dir: &Path, uuid: Option<String>, out: Option<String>) -> Result<()> {
1156    let bundle = open(dir)?;
1157    let scan = bundle.scan()?;
1158    let idx = target_branch(&scan, uuid.as_deref())?;
1159    let Some(meta) = scan.visits[idx].meta.as_ref() else {
1160        return Err(Error::BadArg("`._meta` 解析失败".into()));
1161    };
1162    let mut text = serde_json::to_string_pretty(&meta.to_json()).unwrap_or_default();
1163    text.push('\n');
1164    emit(&text, out.as_deref())
1165}
1166
1167/// 生成供 AI 使用的上下文片段。(规范 §9:`<UUID>` 缺省为 ROOT。)
1168pub fn context(dir: &Path, uuid: Option<String>, depth: usize, budget: usize) -> Result<()> {
1169    let bundle = open(dir)?;
1170    let scan = bundle.scan()?;
1171    let idx = target_branch(&scan, uuid.as_deref())?;
1172    let mut out = String::new();
1173    out.push_str(&format!("# STR 上下文:{}\n\n", bundle.name()));
1174    render_context(&scan, idx, depth, &mut out, 0);
1175    if out.len() > budget {
1176        out.truncate(floor_char_boundary(&out, budget));
1177        out.push_str("\n…(已按 --budget 截断)\n");
1178    }
1179    print!("{out}");
1180    Ok(())
1181}
1182
1183fn render_context(scan: &Scan, idx: usize, depth: usize, out: &mut String, level: usize) {
1184    let v = &scan.visits[idx];
1185    let Some(meta) = v.meta.as_ref() else {
1186        return;
1187    };
1188    let indent = "  ".repeat(level);
1189    out.push_str(&format!(
1190        "{indent}- `{}` **{}** ({}) depth={}\n",
1191        meta.id.clone().unwrap_or_default(),
1192        meta.title.clone().unwrap_or_else(|| v.rel.clone()),
1193        meta.r#type.clone().unwrap_or_else(|| "-".into()),
1194        v.depth
1195    ));
1196    if let Some(s) = &meta.summary {
1197        out.push_str(&format!("{indent}  {s}\n"));
1198    }
1199    if !meta.tags.is_empty() {
1200        out.push_str(&format!("{indent}  tags: {}\n", meta.tags.join(", ")));
1201    }
1202    let files: Vec<String> = meta
1203        .entries
1204        .iter()
1205        .filter(|e| !e.is_branch())
1206        .map(|e| format!("{}({})", e.path, e.role))
1207        .collect();
1208    if !files.is_empty() {
1209        out.push_str(&format!("{indent}  内容:{}\n", files.join("、")));
1210    }
1211    for r in &meta.refs {
1212        let t = scan
1213            .resolve(&r.target)
1214            .map(|i| scan.visits[i].rel.clone())
1215            .unwrap_or_else(|| r.target.clone());
1216        out.push_str(&format!("{indent}  关联线 → {t} ({})\n", r.rel));
1217    }
1218    if level >= depth {
1219        return;
1220    }
1221    let children = ordered_children(scan, idx);
1222    for c in children {
1223        render_context(scan, c, depth, out, level + 1);
1224    }
1225}
1226
1227fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1228    if i >= s.len() {
1229        return s.len();
1230    }
1231    while i > 0 && !s.is_char_boundary(i) {
1232        i -= 1;
1233    }
1234    i
1235}
1236
1237/// 导出为单一 JSON / TOML(派生数据,只读)。
1238///
1239/// `--out -`(或缺省)写 stdout;给路径则写文件,且**不得**落在 bundle 内部(规范 §9)。
1240pub fn export(
1241    dir: &Path,
1242    format: String,
1243    depth: Option<usize>,
1244    out: Option<String>,
1245) -> Result<()> {
1246    if !matches!(format.as_str(), "json" | "toml") {
1247        return Err(Error::BadArg(format!(
1248            "`--format` = {format:?} 非法(只允许 json / toml)"
1249        )));
1250    }
1251    let bundle = open(dir)?;
1252    let scan = bundle.scan()?;
1253    let Some(root_idx) = scan.root_index else {
1254        return Err(Error::BadArg("bundle 缺少 `._meta`".into()));
1255    };
1256    let value = export_node(&scan, root_idx, depth);
1257    let mut text = if format == "toml" {
1258        toml_from_json(&value, 0)
1259    } else {
1260        serde_json::to_string_pretty(&value).unwrap_or_default()
1261    };
1262    if !text.ends_with('\n') {
1263        text.push('\n');
1264    }
1265    match out.as_deref() {
1266        None | Some("-") => emit(&text, None),
1267        Some(p) => {
1268            let path = resolve_out_path(&bundle, p)?;
1269            std::fs::write(&path, text).map_err(|e| Error::io(&path, e))
1270        }
1271    }
1272}
1273
1274fn export_node(scan: &Scan, idx: usize, depth: Option<usize>) -> JValue {
1275    let v = &scan.visits[idx];
1276    let meta = match v.meta.as_ref() {
1277        Some(m) => m.to_json(),
1278        None => serde_json::json!({ "id": null, "error": "parse_failed" }),
1279    };
1280    let children = ordered_children(scan, idx);
1281    let descend = depth.map(|d| v.depth < d).unwrap_or(true);
1282    let kids: Vec<JValue> = if descend {
1283        children.iter().map(|c| export_node(scan, *c, depth)).collect()
1284    } else {
1285        Vec::new()
1286    };
1287    serde_json::json!({
1288        "path": v.rel,
1289        "depth": v.depth,
1290        "meta": meta,
1291        "children": kids,
1292    })
1293}
1294
1295/// 简易 JSON → TOML(仅用于 `str export --format toml`)。
1296fn toml_from_json(v: &JValue, indent: usize) -> String {
1297    let pad = "  ".repeat(indent);
1298    match v {
1299        JValue::Object(m) => {
1300            let mut scalars = String::new();
1301            let mut tables = String::new();
1302            for (k, val) in m {
1303                match val {
1304                    JValue::Object(_) | JValue::Array(_) => {
1305                        tables.push_str(&format!("\n{pad}[{k}]\n{}", toml_from_json(val, indent + 1)));
1306                    }
1307                    _ => scalars.push_str(&format!("{pad}{k} = {}\n", toml_from_json(val, 0))),
1308                }
1309            }
1310            format!("{scalars}{tables}")
1311        }
1312        JValue::Array(items) => {
1313            if items.iter().all(|i| matches!(i, JValue::Object(_))) {
1314                let mut s = String::new();
1315                for it in items {
1316                    s.push_str(&format!("{pad}[[_item]]\n{}", toml_from_json(it, indent + 1)));
1317                }
1318                s
1319            } else {
1320                let inner: Vec<String> = items.iter().map(|i| toml_from_json(i, 0)).collect();
1321                format!("[{}]", inner.join(", "))
1322            }
1323        }
1324        JValue::String(s) => toml_str(s),
1325        JValue::Bool(b) => b.to_string(),
1326        JValue::Number(n) => n.to_string(),
1327        JValue::Null => "\"\"".to_string(),
1328    }
1329}
1330
1331// ─────────────────────────── reveal ───────────────────────────
1332
1333/// 平台适配:macOS 上把 `.str` 目录标记为 bundle,并让 `._meta` 可见。
1334pub fn reveal(dir: &Path) -> Result<()> {
1335    let bundle = open(dir)?;
1336    let scan = bundle.scan()?;
1337    let root = bundle.root.display().to_string();
1338    if cfg!(target_os = "macos") {
1339        // Finder 里显示包内容的前提是 bundle 位(SetFile 属于 Xcode CLI 工具)
1340        let status = std::process::Command::new("SetFile")
1341            .args(["-a", "B", &root])
1342            .status();
1343        match status {
1344            Ok(s) if s.success() => println!("已设置 bundle 位:{root}"),
1345            _ => println!(
1346                "未找到 `SetFile`(需 Xcode Command Line Tools)。手动执行:SetFile -a B {}",
1347                root
1348            ),
1349        }
1350        let mut n = 0;
1351        for v in &scan.visits {
1352            let p = bundle.meta_path(&v.dir);
1353            let _ = std::process::Command::new("chflags")
1354                .args(["nohidden", &p.display().to_string()])
1355                .status();
1356            n += 1;
1357        }
1358        println!("已取消 {n} 个 `._meta` 的隐藏标记");
1359    } else {
1360        println!("当前平台无 bundle 概念:`.str` 就是普通目录,`._meta` 为点文件(可能默认隐藏)。");
1361    }
1362    Ok(())
1363}
1364
1365// ─────────────────────────── codes ───────────────────────────
1366
1367/// 列出全部错误码(测试矩阵用)。
1368pub fn list_codes() {
1369    for c in code::ALL {
1370        println!("{c}");
1371    }
1372}
1373
1374/// 便捷:把相对路径映射为绝对路径。
1375pub fn abs(p: &str) -> PathBuf {
1376    let path = Path::new(p);
1377    if path.is_absolute() {
1378        path.to_path_buf()
1379    } else {
1380        std::env::current_dir()
1381            .unwrap_or_else(|_| PathBuf::from("."))
1382            .join(path)
1383    }
1384}