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> {
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
145fn 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
156fn 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
177fn 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
191pub fn validate(dir: &Path, strict: bool, json: bool, fix_manifest: bool) -> Result<i32> {
195 let bundle = open(dir)?;
196 if fix_manifest {
197 sync(dir, false)?;
199 }
200 let mut report = crate::validate::validate(&bundle)?;
201 if strict {
202 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
220pub 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
235fn marks(ascii: bool) -> (&'static str, &'static str, &'static str, &'static str) {
237 if ascii {
238 ("|-- ", "`-- ", "| ", " ")
239 } else {
240 ("├─ ", "└─ ", "│ ", " ")
241 }
242}
243
244fn 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
318pub 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
362pub 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
388pub 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
449pub 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
516pub 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 if let Ok(after) = bundle.scan() {
552 crate::baseline::record_scan(&bundle, &after);
553 }
554 println!("已删除 {rel}");
555 Ok(())
556}
557
558pub 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
596pub 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
649const AUTHOR_ROLES: &[&str] = &["owner", "editor", "viewer", "agent"];
653
654fn 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
665fn need_one(given: bool, hint: &str) -> Result<()> {
667 if given {
668 Ok(())
669 } else {
670 Err(Error::BadArg(format!("至少需要指定一个字段({hint})")))
671 }
672}
673
674pub 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 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#[derive(Debug, Clone, Default)]
723pub struct EntryPatch {
724 pub type_: Option<String>,
726 pub title: Option<String>,
728 pub summary: Option<String>,
730 pub note: Option<String>,
732 pub order: Option<i64>,
734}
735
736impl EntryPatch {
737 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
747pub 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
789pub 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
840pub 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
857pub 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 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 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 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
987pub 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
1029pub 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
1046pub 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
1116pub 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
1174fn 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
1210pub 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 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
1244pub fn list_codes() {
1248 for c in code::ALL {
1249 println!("{c}");
1250 }
1251}
1252
1253pub 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}