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