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