1use 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
14pub fn open(dir: &Path) -> Result<Bundle> {
22 Bundle::new(bundle_root_of(dir)?)
23}
24
25fn 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
52pub fn locate(scan: &Scan, uuid: &str) -> Option<usize> {
54 scan.resolve(uuid)
55}
56
57pub 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
82fn child_count(dir: &Path) -> Option<i64> {
84 crate::validate::dir_child_count(dir)
85}
86
87pub 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
149fn 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
157fn 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
164fn 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 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
194fn 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
205fn 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
226fn 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
240pub fn validate(dir: &Path, strict: bool, json: bool, fix_manifest: bool) -> Result<i32> {
244 let bundle = open(dir)?;
245 if fix_manifest {
246 sync(dir, false)?;
248 }
249 let mut report = crate::validate::validate(&bundle)?;
250 if strict {
251 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
269pub 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 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
310fn 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
330fn marks(ascii: bool) -> (&'static str, &'static str, &'static str, &'static str) {
332 if ascii {
333 ("|-- ", "`-- ", "| ", " ")
334 } else {
335 ("├─ ", "└─ ", "│ ", " ")
336 }
337}
338
339fn 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
417pub 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
455pub 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
481pub 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 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
552pub 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
624pub 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 if let Ok(after) = bundle.scan() {
668 crate::baseline::record_scan(&bundle, &after);
669 }
670 println!("已删除 {rel}");
671 Ok(())
672}
673
674pub 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 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
717pub 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
770const AUTHOR_ROLES: &[&str] = &["owner", "editor", "viewer", "agent"];
774
775fn 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
786fn need_one(given: bool, hint: &str) -> Result<()> {
788 if given {
789 Ok(())
790 } else {
791 Err(Error::BadArg(format!("至少需要指定一个字段({hint})")))
792 }
793}
794
795pub 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 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#[derive(Debug, Clone, Default)]
844pub struct EntryPatch {
845 pub type_: Option<String>,
847 pub title: Option<String>,
849 pub summary: Option<String>,
851 pub note: Option<String>,
853 pub order: Option<i64>,
855}
856
857impl EntryPatch {
858 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
868pub 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
910pub 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
961pub 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
978fn 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
1001pub 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 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 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 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 work.canonicalize();
1080 }
1081 work.touch();
1082 save_meta(&bundle, &branch_dir, &work)?;
1083 }
1084 println!("已更新 {} 份 `._meta`", plan.len());
1085 Ok(())
1086}
1087
1088pub 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 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 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 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
1218pub 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
1260pub 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
1278pub 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
1348pub 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
1406fn 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
1442pub 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 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
1476pub fn list_codes() {
1480 for c in code::ALL {
1481 println!("{c}");
1482 }
1483}
1484
1485pub 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}