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> {
18 Bundle::new(dir.to_path_buf())
19}
20
21pub fn locate(scan: &Scan, uuid: &str) -> Option<usize> {
23 scan.resolve(uuid)
24}
25
26pub 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
51fn child_count(dir: &Path) -> Option<i64> {
53 crate::validate::dir_child_count(dir)
54}
55
56pub 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
118fn 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
126fn 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
133fn target_branch(scan: &Scan, uuid: Option<&str>) -> Result<usize> {
138 match uuid {
139 Some(u) => {
140 locate(scan, u).ok_or_else(|| Error::BadArg(format!("找不到分支 id `{u}`")))
141 }
142 None => scan
143 .root_index
144 .ok_or_else(|| Error::BadArg("bundle 缺少 `._meta`".into())),
145 }
146}
147
148fn emit(text: &str, out: Option<&str>) -> Result<()> {
150 match out {
151 None | Some("-") => {
152 print!("{text}");
153 Ok(())
154 }
155 Some(p) => std::fs::write(p, text).map_err(|e| Error::io(p, e)),
156 }
157}
158
159fn resolve_out_path(bundle: &Bundle, out: &str) -> Result<PathBuf> {
165 let p = abs(out);
166 let probe = match p.parent() {
167 Some(dir) => canonical_ancestor(dir),
168 None => p.clone(),
169 };
170 if probe.starts_with(&bundle.root) {
171 return Err(Error::BadArg(format!(
172 "`--out` 不得指向 bundle 内部({} 在 {} 内):export 的产物是派生数据",
173 p.display(),
174 bundle.root.display()
175 )));
176 }
177 Ok(p)
178}
179
180fn canonical_ancestor(dir: &Path) -> PathBuf {
182 let mut cur = dir.to_path_buf();
183 loop {
184 if let Ok(resolved) = std::fs::canonicalize(&cur) {
185 return resolved;
186 }
187 match cur.parent() {
188 Some(parent) if parent != cur => cur = parent.to_path_buf(),
189 _ => return dir.to_path_buf(),
190 }
191 }
192}
193
194pub fn validate(dir: &Path, strict: bool, json: bool, fix_manifest: bool) -> Result<i32> {
198 let bundle = open(dir)?;
199 if fix_manifest {
200 sync(dir, false)?;
202 }
203 let mut report = crate::validate::validate(&bundle)?;
204 if strict {
205 for i in &mut report.issues {
207 if i.level == crate::error::Level::Warn {
208 i.level = crate::error::Level::Error;
209 }
210 }
211 }
212 if json {
213 println!(
214 "{}",
215 serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()
216 );
217 } else {
218 println!("{}", report.to_text());
219 }
220 Ok(report.exit_code())
221}
222
223pub fn tree(dir: &Path, max_depth: Option<usize>, show_refs: bool, ascii: bool) -> Result<()> {
227 let bundle = open(dir)?;
228 let scan = bundle.scan()?;
229 println!("{}", bundle.name());
230 if scan.root_index.is_none() {
231 println!(" (缺少 `._meta`,无法渲染)");
232 return Ok(());
233 }
234 render_children(&scan, 0, "", max_depth, show_refs, ascii);
235 Ok(())
236}
237
238fn marks(ascii: bool) -> (&'static str, &'static str, &'static str, &'static str) {
240 if ascii {
241 ("|-- ", "`-- ", "| ", " ")
242 } else {
243 ("├─ ", "└─ ", "│ ", " ")
244 }
245}
246
247fn ordered_children(scan: &Scan, idx: usize) -> Vec<usize> {
251 let parent = scan.visits[idx].meta.as_ref();
252 let mut children: Vec<(i64, String, usize)> = Vec::new();
253 for (i, v) in scan.visits.iter().enumerate() {
254 if v.parent != Some(idx) {
255 continue;
256 }
257 let name = v
258 .dir
259 .file_name()
260 .map(|s| s.to_string_lossy().to_string())
261 .unwrap_or_default();
262 let order = parent
263 .and_then(|m| m.entries.iter().find(|e| e.path == name))
264 .and_then(|e| e.order)
265 .unwrap_or(i64::MAX);
266 children.push((order, name, i));
267 }
268 children.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
269 children.into_iter().map(|(_, _, i)| i).collect()
270}
271
272fn render_children(
273 scan: &Scan,
274 idx: usize,
275 prefix: &str,
276 max_depth: Option<usize>,
277 show_refs: bool,
278 ascii: bool,
279) {
280 let Some(meta) = scan.visits[idx].meta.as_ref() else {
281 return;
282 };
283 let children = ordered_children(scan, idx);
284 let (mid, last_mark, pipe, blank) = marks(ascii);
285 if show_refs {
286 let arrow = if ascii { "->" } else { "⇢" };
287 for r in &meta.refs {
288 let target = scan
289 .resolve(&r.target)
290 .map(|i| scan.visits[i].rel.clone())
291 .unwrap_or_else(|| format!("{}(未解析)", r.target));
292 println!("{prefix}{arrow} 关联: {target} --{}--", r.rel);
293 }
294 }
295 for (n, ci) in children.iter().enumerate() {
296 let last = n + 1 == children.len();
297 let v = &scan.visits[*ci];
298 let (title, type_) = match v.meta.as_ref() {
299 Some(m) => (
300 m.title.clone().unwrap_or_default(),
301 m.r#type.clone().unwrap_or_default(),
302 ),
303 None => (String::new(), String::new()),
304 };
305 let mark = if last { last_mark } else { mid };
306 let mut line = format!("{prefix}{mark}[{}] {}", n + 1, title);
307 if !type_.is_empty() {
308 line.push_str(&format!(" ({type_})"));
309 }
310 if v.meta.is_none() {
311 line.push_str(" ! 解析失败");
312 }
313 println!("{line}");
314 if max_depth.map(|d| v.depth < d).unwrap_or(true) {
315 let next_prefix = format!("{prefix}{}", if last { blank } else { pipe });
316 render_children(scan, *ci, &next_prefix, max_depth, show_refs, ascii);
317 }
318 }
319}
320
321pub fn ls(dir: &Path, uuid: Option<String>, raw: bool) -> Result<()> {
325 let bundle = open(dir)?;
326 let scan = bundle.scan()?;
327 let idx = target_branch(&scan, uuid.as_deref())?;
328 let v = &scan.visits[idx];
329 println!("{} ({})", v.rel, if raw { "磁盘原始" } else { "清单" });
330 if raw {
331 for (name, is_dir) in bundle.list_names(&v.dir)? {
332 if util::is_meta_file(&name) {
333 continue;
334 }
335 println!(" {}{}", if is_dir { "d " } else { "- " }, name);
336 }
337 return Ok(());
338 }
339 let Some(meta) = v.meta.as_ref() else {
340 println!(" (`._meta` 解析失败)");
341 return Ok(());
342 };
343 for e in &meta.entries {
344 let mut line = format!(" {:<28} {}", e.path, e.role);
345 if let Some(t) = &e.title {
346 line.push_str(&format!(" {t}"));
347 }
348 if let Some(s) = e.size {
349 line.push_str(&format!(" {s}B"));
350 }
351 if e.optional {
352 line.push_str(" (optional)");
353 }
354 println!("{line}");
355 }
356 Ok(())
357}
358
359pub fn show(dir: &Path, uuid: Option<String>, full: bool) -> Result<()> {
361 let bundle = open(dir)?;
362 let scan = bundle.scan()?;
363 let idx = target_branch(&scan, uuid.as_deref())?;
364 let v = &scan.visits[idx];
365 let Some(meta) = v.meta.as_ref() else {
366 return Err(Error::BadArg(format!("{} 的 `._meta` 解析失败", v.rel)));
367 };
368 println!(
369 "{}",
370 serde_json::to_string_pretty(&meta.to_json()).unwrap_or_default()
371 );
372 if full {
373 for e in &meta.entries {
374 if (e.role == "payload" || e.role == "asset") && !e.path.is_empty() {
375 let p = v.dir.join(&e.path);
376 if let Ok(text) = std::fs::read_to_string(&p) {
377 println!("\n── {} ──\n{}", e.path, text);
378 }
379 }
380 }
381 }
382 Ok(())
383}
384
385pub fn node_add(
389 dir: &Path,
390 type_: Option<String>,
391 title: Option<String>,
392 summary: Option<String>,
393) -> Result<()> {
394 let bundle = open(dir)?;
395 let root = match bundle.read_meta(&bundle.root)? {
396 MetaLoad::Ok(m, _) => m,
397 MetaLoad::Failed(_) => {
398 return Err(Error::BadArg("root `._meta` 解析失败,无法新增节点".into()));
399 }
400 };
401 if root.kind != Some(Kind::Root) {
402 return Err(Error::BadArg("root `._meta` 的 kind 不是 root".into()));
403 }
404 let id = util::new_uuid(root.policies.id_version);
405 let created = util::now_rfc3339();
406 let child_dir = bundle.root.join(&id);
407 std::fs::create_dir_all(&child_dir).map_err(|e| Error::io(&child_dir, e))?;
408 let text = render_branch_meta(
409 Kind::Node,
410 &id,
411 type_.as_deref(),
412 title.as_deref(),
413 summary.as_deref(),
414 &created,
415 );
416 let child = meta_from_text(&text)?;
417 save_meta(&bundle, &child_dir, &child)?;
418
419 let mut root = root;
420 let order = root
421 .entries
422 .iter()
423 .filter(|e| e.role == "node")
424 .filter_map(|e| e.order)
425 .max()
426 .unwrap_or(0)
427 + 1;
428 let entry = Entry {
429 path: id.clone(),
430 role: "node".into(),
431 id: Some(id.clone()),
432 r#type: type_,
433 title,
434 summary,
435 order: Some(order),
436 ..Default::default()
437 };
438 root.upsert_entry(&entry);
439 root.sort_collections();
440 root.touch();
441 save_meta(&bundle, &bundle.root, &root)?;
442 println!("已新增独立节点 {id}(深度 1)");
443 Ok(())
444}
445
446pub fn branch_add(
451 dir: &Path,
452 anchor: Option<String>,
453 type_: Option<String>,
454 title: Option<String>,
455 summary: Option<String>,
456 order: Option<i64>,
457) -> Result<()> {
458 let bundle = open(dir)?;
459 let scan = bundle.scan()?;
460 let idx = target_branch(&scan, anchor.as_deref())?;
461 if scan.visits[idx].depth == 0 {
462 return Err(Error::BadArg(
463 "ROOT 的直接子分支应使用 `str node add`(role = node);\
464 `branch add` 的锚点须是深度 ≥ 1 的分支"
465 .into(),
466 ));
467 }
468 let anchor_dir = scan.visits[idx].dir.clone();
469 let mut parent = match bundle.read_meta(&anchor_dir)? {
470 MetaLoad::Ok(m, _) => m,
471 MetaLoad::Failed(_) => return Err(Error::BadArg("锚点 `._meta` 解析失败".into())),
472 };
473
474 let id = util::new_uuid(root_id_version(&bundle));
475 let created = util::now_rfc3339();
476 let child_dir = anchor_dir.join(&id);
477 std::fs::create_dir_all(&child_dir).map_err(|e| Error::io(&child_dir, e))?;
478 let text = render_branch_meta(
479 Kind::Branch,
480 &id,
481 type_.as_deref(),
482 title.as_deref(),
483 summary.as_deref(),
484 &created,
485 );
486 let child = meta_from_text(&text)?;
487 save_meta(&bundle, &child_dir, &child)?;
488
489 let order = order.unwrap_or_else(|| {
490 parent
491 .entries
492 .iter()
493 .filter(|e| e.is_branch())
494 .filter_map(|e| e.order)
495 .max()
496 .unwrap_or(0)
497 + 1
498 });
499 let entry = Entry {
500 path: id.clone(),
501 role: "branch".into(),
502 id: Some(id.clone()),
503 r#type: type_,
504 title,
505 summary,
506 order: Some(order),
507 ..Default::default()
508 };
509 parent.upsert_entry(&entry);
510 parent.sort_collections();
511 parent.touch();
512 save_meta(&bundle, &anchor_dir, &parent)?;
513 println!("已在 {} 下新增关联分支 {id}(深度 {})", scan.visits[idx].rel, scan.visits[idx].depth + 1);
514 Ok(())
515}
516
517pub fn branch_rm(dir: &Path, uuid: Option<String>, force: bool) -> Result<()> {
522 let bundle = open(dir)?;
523 let scan = bundle.scan()?;
524 let idx = target_branch(&scan, uuid.as_deref())?;
525 let v = &scan.visits[idx];
526 if v.depth == 0 {
527 return Err(Error::BadArg(
528 "不能删除 ROOT(`<UUID>` 缺省即为 ROOT,请显式给出要删除的分支 id)".into(),
529 ));
530 }
531 if !force {
532 return Err(Error::BadArg(format!(
533 "删除 {} 会移除其全部下级,请加 `--force` 确认",
534 v.rel
535 )));
536 }
537 let target = v.dir.clone();
538 let rel = v.rel.clone();
539 let parent_idx = v.parent;
540 std::fs::remove_dir_all(&target).map_err(|e| Error::io(&target, e))?;
541
542 if let Some(pi) = parent_idx {
543 let parent_dir = scan.visits[pi].dir.clone();
544 let name = target
545 .file_name()
546 .map(|s| s.to_string_lossy().to_string())
547 .unwrap_or_default();
548 let mut parent = match bundle.read_meta(&parent_dir)? {
549 MetaLoad::Ok(m, _) => m,
550 MetaLoad::Failed(_) => return Ok(()),
551 };
552 parent.remove_entry_path(&name);
553 parent.touch();
554 save_meta(&bundle, &parent_dir, &parent)?;
555 }
556 if let Ok(after) = bundle.scan() {
558 crate::baseline::record_scan(&bundle, &after);
559 }
560 println!("已删除 {rel}");
561 Ok(())
562}
563
564pub fn ref_add(
568 dir: &Path,
569 uuid: Option<String>,
570 target: &str,
571 rel: String,
572 title: Option<String>,
573 note: Option<String>,
574) -> Result<()> {
575 let bundle = open(dir)?;
576 let scan = bundle.scan()?;
577 let idx = target_branch(&scan, uuid.as_deref())?;
578 if locate(&scan, target).is_none() {
579 return Err(Error::BadArg(format!("找不到目标分支 id `{target}`")));
580 }
581 let src_dir = scan.visits[idx].dir.clone();
582 let mut meta = match bundle.read_meta(&src_dir)? {
583 MetaLoad::Ok(m, _) => m,
584 MetaLoad::Failed(_) => return Err(Error::BadArg("源分支 `._meta` 解析失败".into())),
585 };
586 let ref_id = util::new_uuid_v7();
587 let order = meta.refs.len() as i64 + 1;
588 meta.push_ref(&RefItem {
589 id: ref_id.clone(),
590 target: target.to_string(),
591 rel,
592 title,
593 order: Some(order),
594 note,
595 });
596 meta.touch();
597 save_meta(&bundle, &src_dir, &meta)?;
598 let src_id = meta
600 .id
601 .clone()
602 .unwrap_or_else(|| scan.visits[idx].rel.clone());
603 println!("已新增关联线 {ref_id}:{src_id} → {target}");
604 Ok(())
605}
606
607pub fn ref_rm(dir: &Path, uuid: Option<String>, ref_id: &str) -> Result<()> {
612 let bundle = open(dir)?;
613 let scan = bundle.scan()?;
614 let has_ref = |i: usize| -> bool {
615 scan.visits[i]
616 .meta
617 .as_ref()
618 .map(|m| m.refs.iter().any(|r| r.id == ref_id))
619 .unwrap_or(false)
620 };
621 let idx = match uuid {
622 Some(u) => {
623 let i = locate(&scan, &u).ok_or_else(|| Error::BadArg(format!("找不到分支 id `{u}`")))?;
624 if !has_ref(i) {
625 return Err(Error::BadArg(format!("分支 `{u}` 内找不到关联线 `{ref_id}`")));
626 }
627 i
628 }
629 None => {
630 let hits: Vec<usize> = (0..scan.visits.len()).filter(|i| has_ref(*i)).collect();
631 match hits.as_slice() {
632 [only] => *only,
633 [] => return Err(Error::BadArg(format!("找不到关联线 `{ref_id}`"))),
634 _ => {
635 let rels: Vec<String> =
636 hits.iter().map(|i| scan.visits[*i].rel.clone()).collect();
637 return Err(Error::BadArg(format!(
638 "关联线 `{ref_id}` 在多个分支中出现({}),请用 `--uuid` 指定源分支",
639 rels.join("、")
640 )));
641 }
642 }
643 }
644 };
645 let rel = scan.visits[idx].rel.clone();
646 let src_dir = scan.visits[idx].dir.clone();
647 let mut meta = match bundle.read_meta(&src_dir)? {
648 MetaLoad::Ok(m, _) => m,
649 MetaLoad::Failed(_) => return Err(Error::BadArg("分支 `._meta` 解析失败".into())),
650 };
651 if !meta.remove_ref(ref_id) {
652 return Err(Error::BadArg(format!("找不到关联线 `{ref_id}`")));
653 }
654 meta.touch();
655 save_meta(&bundle, &src_dir, &meta)?;
656 println!("已删除关联线 {ref_id}(源分支 {rel})");
657 Ok(())
658}
659
660const AUTHOR_ROLES: &[&str] = &["owner", "editor", "viewer", "agent"];
664
665fn read_target_meta(bundle: &Bundle, dir: &Path) -> Result<Meta> {
667 match bundle.read_meta(dir)? {
668 MetaLoad::Ok(m, _) => Ok(*m),
669 MetaLoad::Failed(_) => Err(Error::BadArg(format!(
670 "{} 的 `._meta` 解析失败",
671 bundle.rel(dir)
672 ))),
673 }
674}
675
676fn need_one(given: bool, hint: &str) -> Result<()> {
678 if given {
679 Ok(())
680 } else {
681 Err(Error::BadArg(format!("至少需要指定一个字段({hint})")))
682 }
683}
684
685pub fn meta_set(
689 dir: &Path,
690 uuid: Option<String>,
691 type_: Option<String>,
692 title: Option<String>,
693 summary: Option<String>,
694 name: Option<String>,
695 tags: Option<Vec<String>>,
696) -> Result<()> {
697 need_one(
698 type_.is_some() || title.is_some() || summary.is_some() || name.is_some() || tags.is_some(),
699 "--type / --title / --summary / --name / --tags",
700 )?;
701 let bundle = open(dir)?;
702 let scan = bundle.scan()?;
703 let idx = target_branch(&scan, uuid.as_deref())?;
704 let target_dir = scan.visits[idx].dir.clone();
705 let rel = scan.visits[idx].rel.clone();
706 let mut meta = read_target_meta(&bundle, &target_dir)?;
707
708 if let Some(v) = &type_ {
709 meta.set_str_or_remove("type", v);
710 }
711 if let Some(v) = &title {
712 meta.set_str_or_remove("title", v);
713 }
714 if let Some(v) = &summary {
715 meta.set_str_or_remove("summary", v);
716 }
717 if let Some(v) = &name {
718 meta.set_str_or_remove("name", v);
719 }
720 if let Some(v) = &tags {
721 let cleaned: Vec<String> = v.iter().filter(|s| !s.is_empty()).cloned().collect();
723 meta.set_str_array("tags", &cleaned);
724 }
725
726 meta.touch();
727 save_meta(&bundle, &target_dir, &meta)?;
728 println!("已更新 {rel} 的元信息");
729 Ok(())
730}
731
732#[derive(Debug, Clone, Default)]
734pub struct EntryPatch {
735 pub type_: Option<String>,
737 pub title: Option<String>,
739 pub summary: Option<String>,
741 pub note: Option<String>,
743 pub order: Option<i64>,
745}
746
747impl EntryPatch {
748 pub fn is_empty(&self) -> bool {
750 self.type_.is_none()
751 && self.title.is_none()
752 && self.summary.is_none()
753 && self.note.is_none()
754 && self.order.is_none()
755 }
756}
757
758pub fn entry_set(dir: &Path, uuid: Option<String>, path: &str, patch: &EntryPatch) -> Result<()> {
763 need_one(
764 !patch.is_empty(),
765 "--type / --title / --summary / --note / --order",
766 )?;
767 let bundle = open(dir)?;
768 let scan = bundle.scan()?;
769 let idx = target_branch(&scan, uuid.as_deref())?;
770 let target_dir = scan.visits[idx].dir.clone();
771 let rel = scan.visits[idx].rel.clone();
772 let mut meta = read_target_meta(&bundle, &target_dir)?;
773
774 let mut applied = false;
775 for (key, val) in [
776 ("type", &patch.type_),
777 ("title", &patch.title),
778 ("summary", &patch.summary),
779 ("note", &patch.note),
780 ] {
781 if let Some(v) = val {
782 applied |= meta.set_entry_str(path, key, v);
783 }
784 }
785 if let Some(n) = patch.order {
786 applied |= meta.set_entry_int(path, "order", Some(n));
787 }
788 if !applied {
789 return Err(Error::BadArg(format!(
790 "{rel} 的 `entries[]` 内找不到 `path` = {path:?}"
791 )));
792 }
793
794 meta.touch();
795 save_meta(&bundle, &target_dir, &meta)?;
796 println!("已更新 {rel} 的条目 {path}");
797 Ok(())
798}
799
800pub fn author_add(
802 dir: &Path,
803 uuid: Option<String>,
804 id: String,
805 name: Option<String>,
806 role: String,
807 at: Option<String>,
808) -> Result<()> {
809 if !AUTHOR_ROLES.contains(&role.as_str()) {
810 return Err(Error::BadArg(format!(
811 "`--role` = {role:?} 非法(owner / editor / viewer / agent)"
812 )));
813 }
814 let at = match at {
815 Some(v) => {
816 let dt = v.parse::<toml_edit::Datetime>().map_err(|_| {
817 Error::BadArg(format!(
818 "`--at` = {v:?} 不是合法 offset date-time(如 2026-09-14T10:03:11+08:00)"
819 ))
820 })?;
821 if dt.offset.is_none() {
822 return Err(Error::BadArg(format!("`--at` = {v:?} 缺少时区偏移(规范 4.1)")));
823 }
824 Some(dt.to_string())
825 }
826 None => Some(util::now_rfc3339()),
827 };
828
829 let bundle = open(dir)?;
830 let scan = bundle.scan()?;
831 let idx = target_branch(&scan, uuid.as_deref())?;
832 let target_dir = scan.visits[idx].dir.clone();
833 let rel = scan.visits[idx].rel.clone();
834 let mut meta = read_target_meta(&bundle, &target_dir)?;
835
836 let added = meta.upsert_author(&Author {
837 id: id.clone(),
838 name,
839 role: role.clone(),
840 at,
841 });
842 meta.touch();
843 save_meta(&bundle, &target_dir, &meta)?;
844 println!(
845 "已{} {rel} 的协作者 {id}(role = {role})",
846 if added { "新增" } else { "更新" }
847 );
848 Ok(())
849}
850
851pub fn author_rm(dir: &Path, uuid: Option<String>, id: &str) -> Result<()> {
853 let bundle = open(dir)?;
854 let scan = bundle.scan()?;
855 let idx = target_branch(&scan, uuid.as_deref())?;
856 let target_dir = scan.visits[idx].dir.clone();
857 let rel = scan.visits[idx].rel.clone();
858 let mut meta = read_target_meta(&bundle, &target_dir)?;
859 if !meta.remove_author(id) {
860 return Err(Error::BadArg(format!("{rel} 内找不到协作者 `{id}`")));
861 }
862 meta.touch();
863 save_meta(&bundle, &target_dir, &meta)?;
864 println!("已删除 {rel} 的协作者 {id}");
865 Ok(())
866}
867
868fn normalize_spec_version(raw: &str) -> Result<String> {
876 let v = raw.trim().trim_start_matches('v').trim().to_string();
877 let parts: Vec<&str> = v.split('.').collect();
878 let ok = parts.len() == 3
879 && parts[0] == "1"
880 && parts[1].parse::<u64>().is_ok()
881 && parts[2].parse::<u64>().is_ok();
882 if !ok {
883 return Err(Error::BadArg(format!(
884 "`spec` 版本串 {raw:?} 非法:须形如 `1.<minor>.<patch>`(`str` 主版本固定为 `1`)"
885 )));
886 }
887 Ok(v)
888}
889
890pub fn spec_set(dir: &Path, version: &str, dry_run: bool) -> Result<()> {
898 let version = normalize_spec_version(version)?;
899 let bundle = open(dir)?;
900 let scan = bundle.scan()?;
901
902 let broken: Vec<String> = scan
904 .visits
905 .iter()
906 .filter(|v| v.meta.is_none())
907 .map(|v| v.rel.clone())
908 .collect();
909 if !broken.is_empty() {
910 return Err(Error::BadArg(format!(
911 "{} 份 `._meta` 解析失败({}),改 `spec` 前请先修复",
912 broken.len(),
913 broken.join("、")
914 )));
915 }
916
917 let mut plan: Vec<(usize, String)> = Vec::new();
919 for (i, v) in scan.visits.iter().enumerate() {
920 let from = v
921 .meta
922 .as_ref()
923 .and_then(|m| m.spec.clone())
924 .unwrap_or_else(|| "(缺失)".to_string());
925 if from == version {
926 continue;
927 }
928 plan.push((i, from));
929 }
930 for (i, from) in &plan {
931 println!("~ {} spec: {from} → {version}", scan.visits[*i].rel);
932 }
933 if !plan.is_empty() && version != SPEC_VERSION {
934 println!(
935 "注意:目标 `spec` = {version} 与本实现对应的规范版本({SPEC_VERSION})不同;\
936 `spec` 仅用于人类追溯,工具只强校验 `str` 主版本(规范 §13)"
937 );
938 }
939
940 if plan.is_empty() {
941 println!(
942 "全部 `._meta` 的 `spec` 已是 {version}({} 份)",
943 scan.visits.len()
944 );
945 return Ok(());
946 }
947 if dry_run {
948 println!("(dry-run)将更新 {} 份 `._meta`", plan.len());
949 return Ok(());
950 }
951
952 for (i, _) in &plan {
953 let branch_dir = scan.visits[*i].dir.clone();
954 let mut work = match bundle.read_meta(&branch_dir)? {
955 MetaLoad::Ok(m, _) => m,
956 MetaLoad::Failed(_) => {
957 return Err(Error::BadArg(format!(
959 "{} 的 `._meta` 解析失败,已中止(未写出部分结果)",
960 scan.visits[*i].rel
961 )));
962 }
963 };
964 let had_spec = work.doc.as_table().contains_key("spec");
965 work.set_str("spec", &version);
966 if !had_spec {
967 work.canonicalize();
969 }
970 work.touch();
971 save_meta(&bundle, &branch_dir, &work)?;
972 }
973 println!("已更新 {} 份 `._meta`", plan.len());
974 Ok(())
975}
976
977pub fn sync(dir: &Path, dry_run: bool) -> Result<()> {
981 let bundle = open(dir)?;
982 let scan = bundle.scan()?;
983 let mut changed = 0usize;
984 let mut planned: Vec<String> = Vec::new();
985
986 for v in &scan.visits {
987 if v.meta.is_none() {
988 continue;
989 }
990 let dir_path = v.dir.clone();
991 let rel = v.rel.clone();
992 let mut work = match bundle.read_meta(&v.dir)? {
993 MetaLoad::Ok(m, _) => m,
994 MetaLoad::Failed(_) => continue,
995 };
996 let real = crate::validate::real_entries(&dir_path);
997 let declared: Vec<Entry> = work.entries.clone();
998 let mut touched = false;
999
1000 for (name, is_dir) in &real {
1002 if declared.iter().any(|e| e.path == *name) {
1003 continue;
1004 }
1005 let path = dir_path.join(name);
1006 let e = if *is_dir {
1007 if bundle.has_meta(&path) {
1008 Entry {
1009 path: name.clone(),
1010 role: if v.depth == 0 { "node" } else { "branch" }.into(),
1011 id: Some(name.clone()),
1012 order: Some(declared.len() as i64 + 1),
1013 ..Default::default()
1014 }
1015 } else {
1016 Entry {
1017 path: name.clone(),
1018 role: "dir".into(),
1019 count: child_count(&path),
1020 ..Default::default()
1021 }
1022 }
1023 } else {
1024 let meta_info = std::fs::metadata(&path).ok();
1025 let size = meta_info.map(|m| m.len() as i64);
1026 let sha = util::sha256_file(&path).ok();
1027 Entry {
1028 path: name.clone(),
1029 role: guess_file_role(name).into(),
1030 media_type: media_type_for(name),
1031 size,
1032 sha256: sha,
1033 ..Default::default()
1034 }
1035 };
1036 planned.push(format!("+ {rel}/{name} role={}", e.role));
1037 work.upsert_entry(&e);
1038 touched = true;
1039 }
1040
1041 for e in &declared {
1043 let path = dir_path.join(&e.path);
1044 if !path.exists() {
1045 if !e.optional {
1046 planned.push(format!("- {rel}/{}", e.path));
1047 work.remove_entry_path(&e.path);
1048 touched = true;
1049 }
1050 continue;
1051 }
1052 if !e.is_file_like() {
1053 continue;
1054 }
1055 let size = std::fs::metadata(&path).ok().map(|m| m.len() as i64);
1056 let sha = util::sha256_file(&path).ok();
1057 if size != e.size || sha != e.sha256 {
1058 planned.push(format!("~ {rel}/{} 指纹更新", e.path));
1059 let mut ne = e.clone();
1060 ne.size = size;
1061 ne.sha256 = sha;
1062 if ne.media_type.is_none() {
1063 ne.media_type = media_type_for(&e.path);
1064 }
1065 work.upsert_entry(&ne);
1066 touched = true;
1067 }
1068 }
1069
1070 if touched {
1071 work.sort_collections();
1072 work.touch();
1073 changed += 1;
1074 if !dry_run {
1075 work.save(&bundle.meta_path(&dir_path))?;
1076 }
1077 }
1078 }
1079
1080 for line in &planned {
1081 println!("{line}");
1082 }
1083 if dry_run {
1084 println!("(dry-run)将更新 {changed} 份 `._meta`");
1085 } else {
1086 if let Ok(after) = bundle.scan() {
1088 crate::baseline::record_scan(&bundle, &after);
1089 }
1090 println!("已更新 {changed} 份 `._meta`");
1091 }
1092 Ok(())
1093}
1094
1095fn guess_file_role(name: &str) -> &'static str {
1096 match media_type_for(name).as_deref() {
1097 Some("application/json")
1098 | Some("application/toml")
1099 | Some("application/yaml")
1100 | Some("text/csv")
1101 | Some("text/markdown")
1102 | Some("text/plain") => "payload",
1103 _ => "asset",
1104 }
1105}
1106
1107pub fn fmt(dir: &Path, check: bool, strip_comments: bool) -> Result<i32> {
1113 let bundle = open(dir)?;
1114 let scan = bundle.scan()?;
1115 let mut would_change = 0usize;
1116 for v in &scan.visits {
1117 if v.meta.is_none() {
1118 continue;
1119 }
1120 let meta = match bundle.read_meta(&v.dir)? {
1121 MetaLoad::Ok(m, _) => m,
1122 MetaLoad::Failed(_) => continue,
1123 };
1124 let text = meta.canonical_text(strip_comments);
1125 let path = bundle.meta_path(&v.dir);
1126 let current = std::fs::read_to_string(&path).unwrap_or_default();
1127 if current != text {
1128 would_change += 1;
1129 if !check {
1130 std::fs::write(&path, text).map_err(|e| Error::io(&path, e))?;
1131 println!("已规范化 {}", v.rel);
1132 }
1133 }
1134 }
1135 if check {
1136 if would_change == 0 {
1137 println!("全部 `._meta` 已是规范形式");
1138 return Ok(0);
1139 }
1140 println!("{would_change} 份 `._meta` 需要规范化");
1141 return Ok(1);
1142 }
1143 if would_change == 0 {
1144 println!("全部 `._meta` 已是规范形式");
1145 }
1146 Ok(0)
1147}
1148
1149pub fn norm(dir: &Path, uuid: Option<String>, out: Option<String>) -> Result<()> {
1156 let bundle = open(dir)?;
1157 let scan = bundle.scan()?;
1158 let idx = target_branch(&scan, uuid.as_deref())?;
1159 let Some(meta) = scan.visits[idx].meta.as_ref() else {
1160 return Err(Error::BadArg("`._meta` 解析失败".into()));
1161 };
1162 let mut text = serde_json::to_string_pretty(&meta.to_json()).unwrap_or_default();
1163 text.push('\n');
1164 emit(&text, out.as_deref())
1165}
1166
1167pub fn context(dir: &Path, uuid: Option<String>, depth: usize, budget: usize) -> Result<()> {
1169 let bundle = open(dir)?;
1170 let scan = bundle.scan()?;
1171 let idx = target_branch(&scan, uuid.as_deref())?;
1172 let mut out = String::new();
1173 out.push_str(&format!("# STR 上下文:{}\n\n", bundle.name()));
1174 render_context(&scan, idx, depth, &mut out, 0);
1175 if out.len() > budget {
1176 out.truncate(floor_char_boundary(&out, budget));
1177 out.push_str("\n…(已按 --budget 截断)\n");
1178 }
1179 print!("{out}");
1180 Ok(())
1181}
1182
1183fn render_context(scan: &Scan, idx: usize, depth: usize, out: &mut String, level: usize) {
1184 let v = &scan.visits[idx];
1185 let Some(meta) = v.meta.as_ref() else {
1186 return;
1187 };
1188 let indent = " ".repeat(level);
1189 out.push_str(&format!(
1190 "{indent}- `{}` **{}** ({}) depth={}\n",
1191 meta.id.clone().unwrap_or_default(),
1192 meta.title.clone().unwrap_or_else(|| v.rel.clone()),
1193 meta.r#type.clone().unwrap_or_else(|| "-".into()),
1194 v.depth
1195 ));
1196 if let Some(s) = &meta.summary {
1197 out.push_str(&format!("{indent} {s}\n"));
1198 }
1199 if !meta.tags.is_empty() {
1200 out.push_str(&format!("{indent} tags: {}\n", meta.tags.join(", ")));
1201 }
1202 let files: Vec<String> = meta
1203 .entries
1204 .iter()
1205 .filter(|e| !e.is_branch())
1206 .map(|e| format!("{}({})", e.path, e.role))
1207 .collect();
1208 if !files.is_empty() {
1209 out.push_str(&format!("{indent} 内容:{}\n", files.join("、")));
1210 }
1211 for r in &meta.refs {
1212 let t = scan
1213 .resolve(&r.target)
1214 .map(|i| scan.visits[i].rel.clone())
1215 .unwrap_or_else(|| r.target.clone());
1216 out.push_str(&format!("{indent} 关联线 → {t} ({})\n", r.rel));
1217 }
1218 if level >= depth {
1219 return;
1220 }
1221 let children = ordered_children(scan, idx);
1222 for c in children {
1223 render_context(scan, c, depth, out, level + 1);
1224 }
1225}
1226
1227fn floor_char_boundary(s: &str, mut i: usize) -> usize {
1228 if i >= s.len() {
1229 return s.len();
1230 }
1231 while i > 0 && !s.is_char_boundary(i) {
1232 i -= 1;
1233 }
1234 i
1235}
1236
1237pub fn export(
1241 dir: &Path,
1242 format: String,
1243 depth: Option<usize>,
1244 out: Option<String>,
1245) -> Result<()> {
1246 if !matches!(format.as_str(), "json" | "toml") {
1247 return Err(Error::BadArg(format!(
1248 "`--format` = {format:?} 非法(只允许 json / toml)"
1249 )));
1250 }
1251 let bundle = open(dir)?;
1252 let scan = bundle.scan()?;
1253 let Some(root_idx) = scan.root_index else {
1254 return Err(Error::BadArg("bundle 缺少 `._meta`".into()));
1255 };
1256 let value = export_node(&scan, root_idx, depth);
1257 let mut text = if format == "toml" {
1258 toml_from_json(&value, 0)
1259 } else {
1260 serde_json::to_string_pretty(&value).unwrap_or_default()
1261 };
1262 if !text.ends_with('\n') {
1263 text.push('\n');
1264 }
1265 match out.as_deref() {
1266 None | Some("-") => emit(&text, None),
1267 Some(p) => {
1268 let path = resolve_out_path(&bundle, p)?;
1269 std::fs::write(&path, text).map_err(|e| Error::io(&path, e))
1270 }
1271 }
1272}
1273
1274fn export_node(scan: &Scan, idx: usize, depth: Option<usize>) -> JValue {
1275 let v = &scan.visits[idx];
1276 let meta = match v.meta.as_ref() {
1277 Some(m) => m.to_json(),
1278 None => serde_json::json!({ "id": null, "error": "parse_failed" }),
1279 };
1280 let children = ordered_children(scan, idx);
1281 let descend = depth.map(|d| v.depth < d).unwrap_or(true);
1282 let kids: Vec<JValue> = if descend {
1283 children.iter().map(|c| export_node(scan, *c, depth)).collect()
1284 } else {
1285 Vec::new()
1286 };
1287 serde_json::json!({
1288 "path": v.rel,
1289 "depth": v.depth,
1290 "meta": meta,
1291 "children": kids,
1292 })
1293}
1294
1295fn toml_from_json(v: &JValue, indent: usize) -> String {
1297 let pad = " ".repeat(indent);
1298 match v {
1299 JValue::Object(m) => {
1300 let mut scalars = String::new();
1301 let mut tables = String::new();
1302 for (k, val) in m {
1303 match val {
1304 JValue::Object(_) | JValue::Array(_) => {
1305 tables.push_str(&format!("\n{pad}[{k}]\n{}", toml_from_json(val, indent + 1)));
1306 }
1307 _ => scalars.push_str(&format!("{pad}{k} = {}\n", toml_from_json(val, 0))),
1308 }
1309 }
1310 format!("{scalars}{tables}")
1311 }
1312 JValue::Array(items) => {
1313 if items.iter().all(|i| matches!(i, JValue::Object(_))) {
1314 let mut s = String::new();
1315 for it in items {
1316 s.push_str(&format!("{pad}[[_item]]\n{}", toml_from_json(it, indent + 1)));
1317 }
1318 s
1319 } else {
1320 let inner: Vec<String> = items.iter().map(|i| toml_from_json(i, 0)).collect();
1321 format!("[{}]", inner.join(", "))
1322 }
1323 }
1324 JValue::String(s) => toml_str(s),
1325 JValue::Bool(b) => b.to_string(),
1326 JValue::Number(n) => n.to_string(),
1327 JValue::Null => "\"\"".to_string(),
1328 }
1329}
1330
1331pub fn reveal(dir: &Path) -> Result<()> {
1335 let bundle = open(dir)?;
1336 let scan = bundle.scan()?;
1337 let root = bundle.root.display().to_string();
1338 if cfg!(target_os = "macos") {
1339 let status = std::process::Command::new("SetFile")
1341 .args(["-a", "B", &root])
1342 .status();
1343 match status {
1344 Ok(s) if s.success() => println!("已设置 bundle 位:{root}"),
1345 _ => println!(
1346 "未找到 `SetFile`(需 Xcode Command Line Tools)。手动执行:SetFile -a B {}",
1347 root
1348 ),
1349 }
1350 let mut n = 0;
1351 for v in &scan.visits {
1352 let p = bundle.meta_path(&v.dir);
1353 let _ = std::process::Command::new("chflags")
1354 .args(["nohidden", &p.display().to_string()])
1355 .status();
1356 n += 1;
1357 }
1358 println!("已取消 {n} 个 `._meta` 的隐藏标记");
1359 } else {
1360 println!("当前平台无 bundle 概念:`.str` 就是普通目录,`._meta` 为点文件(可能默认隐藏)。");
1361 }
1362 Ok(())
1363}
1364
1365pub fn list_codes() {
1369 for c in code::ALL {
1370 println!("{c}");
1371 }
1372}
1373
1374pub fn abs(p: &str) -> PathBuf {
1376 let path = Path::new(p);
1377 if path.is_absolute() {
1378 path.to_path_buf()
1379 } else {
1380 std::env::current_dir()
1381 .unwrap_or_else(|_| PathBuf::from("."))
1382 .join(path)
1383 }
1384}