1use anyhow::{Context, Result};
2use clap::{Parser, Subcommand};
3use std::{collections::HashMap, fs, io::Read, path::PathBuf};
4
5pub mod cache;
6pub mod context;
7mod ui;
8
9#[derive(clap::ValueEnum, Clone)]
10pub enum OutputFormat {
11 Default,
12 Json,
13}
14
15#[derive(Parser)]
16#[command(name = "mindmap-cli")]
17#[command(about = "CLI tool for working with MINDMAP files")]
18#[command(
19 long_about = r#"mindmap-cli - small CLI for inspecting and safely editing one-line MINDMAP files (default: ./MINDMAP.md).
20One-node-per-line format: [N] **Title** - description with [N] references. IDs must be stable numeric values.
21
22EXAMPLES:
23 mindmap-cli show 10
24 mindmap-cli list --type AE --grep auth
25 mindmap-cli add --type AE --title "AuthService" --desc "Handles auth [12]"
26 mindmap-cli edit 12 # opens $EDITOR for an atomic, validated edit
27 mindmap-cli patch 12 --title "AuthSvc" --desc "Updated desc" # partial update (PATCH)
28 mindmap-cli put 12 --line "[31] **WF: Example** - Full line text [12]" # full-line replace (PUT)
29 mindmap-cli graph 10 | dot -Tpng > graph.png # generate neighborhood graph
30 mindmap-cli lint
31 mindmap-cli batch --input - --dry-run <<EOF # atomic batch from stdin
32 add --type WF --title "New Workflow" --desc "Steps here"
33 patch 15 --title "Updated Workflow"
34 delete 19
35 EOF
36
37Notes:
38 - Default file: ./MINDMAP.md (override with --file)
39 - Use `--file -` to read a mindmap from stdin for read-only commands (list/show/refs/links/search/lint/orphans). Mutating commands will error when source is `-`.
40 - Use the EDITOR env var to control the editor used by 'edit'
41"#
42)]
43pub struct Cli {
44 #[arg(global = true, short, long)]
46 pub file: Option<PathBuf>,
47
48 #[arg(global = true, long, value_enum, default_value_t = OutputFormat::Default)]
50 pub output: OutputFormat,
51
52 #[command(subcommand)]
53 pub command: Commands,
54}
55
56#[derive(Subcommand)]
57pub enum Commands {
58 #[command(alias = "get", alias = "inspect")]
60 Show {
61 id: u32,
63 #[arg(long)]
65 follow: bool,
66 },
67
68 List {
70 #[arg(long)]
72 r#type: Option<String>,
73 #[arg(long)]
75 grep: Option<String>,
76 #[arg(long)]
78 case_sensitive: bool,
79 #[arg(long)]
81 exact_match: bool,
82 #[arg(long)]
84 regex_mode: bool,
85 },
86
87 #[command(alias = "incoming")]
89 Refs {
90 id: u32,
92 #[arg(long)]
94 follow: bool,
95 },
96
97 #[command(alias = "outgoing")]
99 Links {
100 id: u32,
102 #[arg(long)]
104 follow: bool,
105 },
106
107 #[command(alias = "query")]
110 Search {
111 query: String,
113 #[arg(long)]
115 case_sensitive: bool,
116 #[arg(long)]
118 exact_match: bool,
119 #[arg(long)]
121 regex_mode: bool,
122 #[arg(long)]
124 follow: bool,
125 },
126
127 Add {
129 #[arg(long)]
130 r#type: Option<String>,
131 #[arg(long)]
132 title: Option<String>,
133 #[arg(long)]
134 desc: Option<String>,
135 #[arg(long)]
137 strict: bool,
138 },
139
140 Deprecate {
142 id: u32,
143 #[arg(long)]
144 to: u32,
145 },
146
147 Edit { id: u32 },
149
150 Patch {
152 id: u32,
153 #[arg(long)]
154 r#type: Option<String>,
155 #[arg(long)]
156 title: Option<String>,
157 #[arg(long)]
158 desc: Option<String>,
159 #[arg(long)]
160 strict: bool,
161 },
162
163 #[command(alias = "update")]
165 Put {
166 id: u32,
167 #[arg(long)]
168 line: String,
169 #[arg(long)]
170 strict: bool,
171 },
172
173 Verify { id: u32 },
175
176 Delete {
178 id: u32,
179 #[arg(long)]
180 force: bool,
181 },
182
183 Lint {
185 #[arg(long)]
187 fix: bool,
188 },
189
190 Orphans {
192 #[arg(long)]
194 with_descriptions: bool,
195 },
196
197 #[command(alias = "types")]
199 Type {
200 #[arg(long)]
202 of: Option<String>,
203 },
204
205 #[command(alias = "rel")]
207 Relationships {
208 id: u32,
210 #[arg(long)]
212 follow: bool,
213 },
214
215 Graph {
217 id: u32,
219 #[arg(long)]
221 follow: bool,
222 },
223
224 Prime,
226
227 Batch {
229 #[arg(long)]
231 input: Option<PathBuf>,
232 #[arg(long, default_value = "lines")]
234 format: String,
235 #[arg(long)]
237 dry_run: bool,
238 #[arg(long)]
240 fix: bool,
241 },
242}
243
244#[derive(Debug, Clone)]
245pub struct Node {
246 pub id: u32,
247 pub raw_title: String,
248 pub description: String,
249 pub references: Vec<Reference>,
250 pub line_index: usize,
251}
252
253#[derive(Debug, Clone, PartialEq, serde::Serialize)]
254pub enum Reference {
255 Internal(u32),
256 External(u32, String),
257}
258
259#[derive(Debug)]
260pub struct Mindmap {
261 pub path: PathBuf,
262 pub lines: Vec<String>,
263 pub nodes: Vec<Node>,
264 pub by_id: HashMap<u32, usize>,
265}
266
267impl Mindmap {
268 pub fn load(path: PathBuf) -> Result<Self> {
269 let content = fs::read_to_string(&path)
271 .with_context(|| format!("Failed to read file {}", path.display()))?;
272 Self::from_string(content, path)
273 }
274
275 pub fn load_from_reader<R: Read>(mut reader: R, path: PathBuf) -> Result<Self> {
278 let mut content = String::new();
279 reader.read_to_string(&mut content)?;
280 Self::from_string(content, path)
281 }
282
283 fn from_string(content: String, path: PathBuf) -> Result<Self> {
284 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
285
286 let mut nodes = Vec::new();
287 let mut by_id = HashMap::new();
288
289 for (i, line) in lines.iter().enumerate() {
290 if let Ok(node) = parse_node_line(line, i) {
291 if by_id.contains_key(&node.id) {
292 eprintln!("Warning: duplicate node id {} at line {}", node.id, i + 1);
293 }
294 by_id.insert(node.id, nodes.len());
295 nodes.push(node);
296 }
297 }
298
299 Ok(Mindmap {
300 path,
301 lines,
302 nodes,
303 by_id,
304 })
305 }
306
307 pub fn save(&mut self) -> Result<()> {
308 if self.path.as_os_str() == "-" {
310 return Err(anyhow::anyhow!(
311 "Cannot save: mindmap was loaded from stdin ('-'); use --file <path> to save changes"
312 ));
313 }
314
315 self.normalize_spacing()?;
318
319 let dir = self
321 .path
322 .parent()
323 .map(|p| p.to_path_buf())
324 .unwrap_or_else(|| PathBuf::from("."));
325 let mut tmp = tempfile::NamedTempFile::new_in(&dir)
326 .with_context(|| format!("Failed to create temp file in {}", dir.display()))?;
327 let content = self.lines.join("\n") + "\n";
328 use std::io::Write;
329 tmp.write_all(content.as_bytes())?;
330 tmp.flush()?;
331 tmp.persist(&self.path)
332 .with_context(|| format!("Failed to persist temp file to {}", self.path.display()))?;
333 Ok(())
334 }
335
336 pub fn next_id(&self) -> u32 {
337 self.by_id.keys().max().copied().unwrap_or(0) + 1
338 }
339
340 pub fn get_node(&self, id: u32) -> Option<&Node> {
341 self.by_id.get(&id).map(|&idx| &self.nodes[idx])
342 }
343
344 pub fn normalize_spacing(&mut self) -> Result<()> {
348 if self.lines.is_empty() {
350 return Ok(());
351 }
352
353 let orig = self.lines.clone();
354 let mut new_lines: Vec<String> = Vec::new();
355
356 for i in 0..orig.len() {
357 let line = orig[i].clone();
358 new_lines.push(line.clone());
359
360 if parse_node_line(&line, i).is_ok()
364 && i + 1 < orig.len()
365 && parse_node_line(&orig[i + 1], i + 1).is_ok()
366 {
367 new_lines.push(String::new());
368 }
369 }
370
371 if new_lines == orig {
373 return Ok(());
374 }
375
376 let content = new_lines.join("\n") + "\n";
378 let normalized_mm = Mindmap::from_string(content, self.path.clone())?;
379 self.lines = normalized_mm.lines;
380 self.nodes = normalized_mm.nodes;
381 self.by_id = normalized_mm.by_id;
382
383 Ok(())
384 }
385
386 pub fn apply_fixes(&mut self) -> Result<FixReport> {
389 let mut report = FixReport::default();
390
391 if self.lines.is_empty() {
393 return Ok(report);
394 }
395
396 let orig = self.lines.clone();
397 let mut new_lines: Vec<String> = Vec::new();
398 let mut i = 0usize;
399 while i < orig.len() {
400 let line = orig[i].clone();
401 new_lines.push(line.clone());
402
403 if parse_node_line(&line, i).is_ok() {
405 let mut j = i + 1;
406 while j < orig.len() && orig[j].trim().is_empty() {
408 j += 1;
409 }
410
411 if j < orig.len() && parse_node_line(&orig[j], j).is_ok() {
413 if j == i + 1 {
414 new_lines.push(String::new());
416 report.spacing.push(i + 1);
417 } else if j > i + 2 {
418 new_lines.push(String::new());
420 report.spacing.push(i + 1);
421 }
422 i = j;
423 continue;
424 }
425 }
426 i += 1;
427 }
428
429 if !report.spacing.is_empty() {
431 let content = new_lines.join("\n") + "\n";
432 let normalized_mm = Mindmap::from_string(content, self.path.clone())?;
433 self.lines = normalized_mm.lines;
434 self.nodes = normalized_mm.nodes;
435 self.by_id = normalized_mm.by_id;
436 }
437
438 let mut changed = false;
440 let mut new_lines = self.lines.clone();
441 for node in &self.nodes {
442 if let Some(colon_pos) = node.raw_title.find(':') {
443 let leading_type = node.raw_title[..colon_pos].trim();
444 let after_colon = node.raw_title[colon_pos + 1..].trim_start();
445
446 if after_colon.starts_with(&format!("{}:", leading_type)) {
448 let after_dup = after_colon[leading_type.len() + 1..].trim_start();
450 let new_raw = if after_dup.is_empty() {
451 leading_type.to_string()
452 } else {
453 format!("{}: {}", leading_type, after_dup)
454 };
455
456 report.title_fixes.push(TitleFix {
457 id: node.id,
458 old: node.raw_title.clone(),
459 new: new_raw.clone(),
460 });
461
462 new_lines[node.line_index] =
464 format!("[{}] **{}** - {}", node.id, new_raw, node.description);
465 changed = true;
466 }
467 }
468 }
469
470 if changed {
471 let content = new_lines.join("\n") + "\n";
472 let normalized_mm = Mindmap::from_string(content, self.path.clone())?;
473 self.lines = normalized_mm.lines;
474 self.nodes = normalized_mm.nodes;
475 self.by_id = normalized_mm.by_id;
476 }
477
478 Ok(report)
479 }
480}
481
482pub fn parse_node_line(line: &str, line_index: usize) -> Result<Node> {
485 let trimmed = line.trim_start();
487 if !trimmed.starts_with('[') {
488 return Err(anyhow::anyhow!("Line does not match node format"));
489 }
490
491 let end_bracket = match trimmed.find(']') {
493 Some(pos) => pos,
494 None => return Err(anyhow::anyhow!("Line does not match node format")),
495 };
496
497 let id_str = &trimmed[1..end_bracket];
498 let id: u32 = id_str.parse()?;
499
500 let mut pos = end_bracket + 1;
502 let chars = trimmed.as_bytes();
503 if chars.get(pos).map(|b| *b as char) == Some(' ') {
504 pos += 1;
505 } else {
506 return Err(anyhow::anyhow!("Line does not match node format"));
507 }
508
509 if trimmed.get(pos..pos + 2) != Some("**") {
511 return Err(anyhow::anyhow!("Line does not match node format"));
512 }
513 pos += 2;
514
515 let rem = &trimmed[pos..];
517 let title_rel_end = match rem.find("**") {
518 Some(p) => p,
519 None => return Err(anyhow::anyhow!("Line does not match node format")),
520 };
521 let title = rem[..title_rel_end].to_string();
522 pos += title_rel_end + 2; if trimmed.get(pos..pos + 3) != Some(" - ") {
526 return Err(anyhow::anyhow!("Line does not match node format"));
527 }
528 pos += 3;
529
530 let description = trimmed[pos..].to_string();
531
532 let references = extract_refs_from_str(&description, Some(id));
534
535 Ok(Node {
536 id,
537 raw_title: title,
538 description,
539 references,
540 line_index,
541 })
542}
543
544fn extract_refs_from_str(s: &str, skip_self: Option<u32>) -> Vec<Reference> {
547 let mut refs = Vec::new();
548 let mut i = 0usize;
549 while i < s.len() {
550 if let Some(rel) = s[i..].find('[') {
552 let start = i + rel;
553 if let Some(rel_end) = s[start..].find(']') {
554 let end = start + rel_end;
555 let idslice = &s[start + 1..end];
556 if !idslice.is_empty()
557 && idslice.chars().all(|c| c.is_ascii_digit())
558 && let Ok(rid) = idslice.parse::<u32>()
559 && Some(rid) != skip_self
560 {
561 let after = &s[end..];
563 if after.starts_with("](") {
564 if let Some(paren_end) = after.find(')') {
566 let path_start = end + 2; let path_end = end + paren_end;
568 let path = &s[path_start..path_end];
569 refs.push(Reference::External(rid, path.to_string()));
570 i = path_end + 1;
571 continue;
572 }
573 }
574 refs.push(Reference::Internal(rid));
576 }
577 i = end + 1;
578 continue;
579 } else {
580 break; }
582 } else {
583 break;
584 }
585 }
586 refs
587}
588
589pub fn cmd_show(mm: &Mindmap, id: u32) -> String {
592 if let Some(node) = mm.get_node(id) {
593 let mut out = format!(
594 "[{}] **{}** - {}",
595 node.id, node.raw_title, node.description
596 );
597
598 let mut inbound = Vec::new();
600 for n in &mm.nodes {
601 if n.references
602 .iter()
603 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
604 {
605 inbound.push(n.id);
606 }
607 }
608 if !inbound.is_empty() {
609 out.push_str(&format!("\nReferred to by: {:?}", inbound));
610 }
611 out
612 } else {
613 format!("Node [{}] not found", id)
614 }
615}
616
617pub fn cmd_list(
618 mm: &Mindmap,
619 type_filter: Option<&str>,
620 grep: Option<&str>,
621 case_sensitive: bool,
622 exact_match: bool,
623 regex_mode: bool,
624) -> Vec<String> {
625 let mut res = Vec::new();
626
627 let regex_pattern: Option<regex::Regex> = if regex_mode && let Some(grep) = grep {
629 match regex::Regex::new(grep) {
630 Ok(r) => Some(r),
631 Err(_) => return vec!["Invalid regex pattern".to_string()],
632 }
633 } else {
634 None
635 };
636
637 for n in &mm.nodes {
638 if let Some(tf) = type_filter
640 && !n.raw_title.starts_with(&format!("{}:", tf))
641 {
642 continue;
643 }
644
645 if let Some(q) = grep {
647 let matches = if let Some(re) = ®ex_pattern {
648 re.is_match(&n.raw_title) || re.is_match(&n.description)
650 } else if exact_match {
651 let query = if case_sensitive {
653 q.to_string()
654 } else {
655 q.to_lowercase()
656 };
657 let title = if case_sensitive {
658 n.raw_title.clone()
659 } else {
660 n.raw_title.to_lowercase()
661 };
662 let desc = if case_sensitive {
663 n.description.clone()
664 } else {
665 n.description.to_lowercase()
666 };
667 title == query
668 || desc == query
669 || title.contains(&format!(" {} ", query))
670 || desc.contains(&format!(" {} ", query))
671 } else {
672 let query = if case_sensitive {
674 q.to_string()
675 } else {
676 q.to_lowercase()
677 };
678 let title = if case_sensitive {
679 n.raw_title.clone()
680 } else {
681 n.raw_title.to_lowercase()
682 };
683 let desc = if case_sensitive {
684 n.description.clone()
685 } else {
686 n.description.to_lowercase()
687 };
688 title.contains(&query) || desc.contains(&query)
689 };
690
691 if !matches {
692 continue;
693 }
694 }
695
696 res.push(format!(
697 "[{}] **{}** - {}",
698 n.id, n.raw_title, n.description
699 ));
700 }
701 res
702}
703
704pub fn cmd_refs(mm: &Mindmap, id: u32) -> Vec<String> {
705 let mut out = Vec::new();
706 for n in &mm.nodes {
707 if n.references
708 .iter()
709 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
710 {
711 out.push(format!(
712 "[{}] **{}** - {}",
713 n.id, n.raw_title, n.description
714 ));
715 }
716 }
717 out
718}
719
720pub fn cmd_links(mm: &Mindmap, id: u32) -> Option<Vec<Reference>> {
721 mm.get_node(id).map(|n| n.references.clone())
722}
723
724pub fn cmd_add(mm: &mut Mindmap, type_prefix: &str, title: &str, desc: &str) -> Result<u32> {
728 let id = mm.next_id();
729 let full_title = format!("{}: {}", type_prefix, title);
730 let line = format!("[{}] **{}** - {}", id, full_title, desc);
731
732 mm.lines.push(line.clone());
733
734 let line_index = mm.lines.len() - 1;
735 let references = extract_refs_from_str(desc, Some(id));
736
737 let node = Node {
738 id,
739 raw_title: full_title,
740 description: desc.to_string(),
741 references,
742 line_index,
743 };
744 mm.by_id.insert(id, mm.nodes.len());
745 mm.nodes.push(node);
746
747 Ok(id)
748}
749
750pub fn cmd_add_editor(mm: &mut Mindmap, editor: &str, strict: bool) -> Result<u32> {
751 if !atty::is(atty::Stream::Stdin) {
753 return Err(anyhow::anyhow!(
754 "add via editor requires an interactive terminal"
755 ));
756 }
757
758 let id = mm.next_id();
759 let template = format!("[{}] **TYPE: Title** - description", id);
760
761 let mut tmp = tempfile::NamedTempFile::new()
763 .with_context(|| "Failed to create temp file for add editor")?;
764 use std::io::Write;
765 writeln!(tmp, "{}", template)?;
766 tmp.flush()?;
767
768 let status = std::process::Command::new(editor)
770 .arg(tmp.path())
771 .status()
772 .with_context(|| "Failed to launch editor")?;
773 if !status.success() {
774 return Err(anyhow::anyhow!("Editor exited with non-zero status"));
775 }
776
777 let edited = std::fs::read_to_string(tmp.path())?;
779 let nonempty: Vec<&str> = edited
780 .lines()
781 .map(|l| l.trim())
782 .filter(|l| !l.is_empty())
783 .collect();
784 if nonempty.is_empty() {
785 return Err(anyhow::anyhow!("No content written in editor"));
786 }
787 if nonempty.len() > 1 {
788 return Err(anyhow::anyhow!(
789 "Expected exactly one node line in editor; found multiple lines"
790 ));
791 }
792 let line = nonempty[0];
793
794 let parsed = parse_node_line(line, mm.lines.len())?;
796 if parsed.id != id {
797 return Err(anyhow::anyhow!(format!(
798 "Added line id changed; expected [{}]",
799 id
800 )));
801 }
802
803 if strict {
804 for r in &parsed.references {
805 if let Reference::Internal(iid) = r
806 && !mm.by_id.contains_key(iid)
807 {
808 return Err(anyhow::anyhow!(format!(
809 "ADD strict: reference to missing node {}",
810 iid
811 )));
812 }
813 }
814 }
815
816 mm.lines.push(line.to_string());
818 let line_index = mm.lines.len() - 1;
819 let node = Node {
820 id: parsed.id,
821 raw_title: parsed.raw_title,
822 description: parsed.description,
823 references: parsed.references,
824 line_index,
825 };
826 mm.by_id.insert(id, mm.nodes.len());
827 mm.nodes.push(node);
828
829 Ok(id)
830}
831
832pub fn cmd_deprecate(mm: &mut Mindmap, id: u32, to: u32) -> Result<()> {
833 let idx = *mm
834 .by_id
835 .get(&id)
836 .ok_or_else(|| anyhow::anyhow!(format!("Node [{}] not found", id)))?;
837
838 if !mm.by_id.contains_key(&to) {
839 eprintln!(
840 "Warning: target node {} does not exist (still updating title)",
841 to
842 );
843 }
844
845 let node = &mut mm.nodes[idx];
846 if !node.raw_title.starts_with("[DEPRECATED") {
847 node.raw_title = format!("[DEPRECATED → {}] {}", to, node.raw_title);
848 mm.lines[node.line_index] = format!(
849 "[{}] **{}** - {}",
850 node.id, node.raw_title, node.description
851 );
852 }
853
854 Ok(())
855}
856
857pub fn cmd_verify(mm: &mut Mindmap, id: u32) -> Result<()> {
858 let idx = *mm
859 .by_id
860 .get(&id)
861 .ok_or_else(|| anyhow::anyhow!(format!("Node [{}] not found", id)))?;
862 let node = &mut mm.nodes[idx];
863
864 let tag = format!("(verify {})", chrono::Local::now().format("%Y-%m-%d"));
865 if !node.description.contains("(verify ") {
866 if node.description.is_empty() {
867 node.description = tag.clone();
868 } else {
869 node.description = format!("{} {}", node.description, tag);
870 }
871 mm.lines[node.line_index] = format!(
872 "[{}] **{}** - {}",
873 node.id, node.raw_title, node.description
874 );
875 }
876 Ok(())
877}
878
879pub fn cmd_edit(mm: &mut Mindmap, id: u32, editor: &str) -> Result<()> {
880 let idx = *mm
881 .by_id
882 .get(&id)
883 .ok_or_else(|| anyhow::anyhow!(format!("Node [{}] not found", id)))?;
884 let node = &mm.nodes[idx];
885
886 let mut tmp =
888 tempfile::NamedTempFile::new().with_context(|| "Failed to create temp file for editing")?;
889 use std::io::Write;
890 writeln!(
891 tmp,
892 "[{}] **{}** - {}",
893 node.id, node.raw_title, node.description
894 )?;
895 tmp.flush()?;
896
897 let status = std::process::Command::new(editor)
899 .arg(tmp.path())
900 .status()
901 .with_context(|| "Failed to launch editor")?;
902 if !status.success() {
903 return Err(anyhow::anyhow!("Editor exited with non-zero status"));
904 }
905
906 let edited = std::fs::read_to_string(tmp.path())?;
908 let edited_line = edited.lines().next().unwrap_or("").trim();
909
910 let parsed = parse_node_line(edited_line, node.line_index)?;
912 if parsed.id != id {
913 return Err(anyhow::anyhow!("Cannot change node ID"));
914 }
915
916 mm.lines[node.line_index] = edited_line.to_string();
918 let new_title = parsed.raw_title;
919 let new_desc = parsed.description;
920 let new_refs = parsed.references;
921
922 let node_mut = &mut mm.nodes[idx];
924 node_mut.raw_title = new_title;
925 node_mut.description = new_desc;
926 node_mut.references = new_refs;
927
928 Ok(())
929}
930
931pub fn cmd_put(mm: &mut Mindmap, id: u32, line: &str, strict: bool) -> Result<()> {
932 let idx = *mm
934 .by_id
935 .get(&id)
936 .ok_or_else(|| anyhow::anyhow!(format!("Node [{}] not found", id)))?;
937
938 let parsed = parse_node_line(line, mm.nodes[idx].line_index)?;
939 if parsed.id != id {
940 return Err(anyhow::anyhow!("PUT line id does not match target id"));
941 }
942
943 if strict {
945 for r in &parsed.references {
946 if let Reference::Internal(iid) = r
947 && !mm.by_id.contains_key(iid)
948 {
949 return Err(anyhow::anyhow!(format!(
950 "PUT strict: reference to missing node {}",
951 iid
952 )));
953 }
954 }
955 }
956
957 mm.lines[mm.nodes[idx].line_index] = line.to_string();
959 let node_mut = &mut mm.nodes[idx];
960 node_mut.raw_title = parsed.raw_title;
961 node_mut.description = parsed.description;
962 node_mut.references = parsed.references;
963
964 Ok(())
965}
966
967pub fn cmd_patch(
968 mm: &mut Mindmap,
969 id: u32,
970 typ: Option<&str>,
971 title: Option<&str>,
972 desc: Option<&str>,
973 strict: bool,
974) -> Result<()> {
975 let idx = *mm
976 .by_id
977 .get(&id)
978 .ok_or_else(|| anyhow::anyhow!(format!("Node [{}] not found", id)))?;
979 let node = &mm.nodes[idx];
980
981 let mut existing_type: Option<&str> = None;
983 let mut existing_title = node.raw_title.as_str();
984 if let Some(pos) = node.raw_title.find(':') {
985 existing_type = Some(node.raw_title[..pos].trim());
986 existing_title = node.raw_title[pos + 1..].trim();
987 }
988
989 let new_type = typ.unwrap_or(existing_type.unwrap_or(""));
990 let new_title = title.unwrap_or(existing_title);
991 let new_desc = desc.unwrap_or(&node.description);
992
993 let new_raw_title = if new_type.is_empty() {
995 new_title.to_string()
996 } else {
997 format!("{}: {}", new_type, new_title)
998 };
999
1000 let new_line = format!("[{}] **{}** - {}", id, new_raw_title, new_desc);
1001
1002 let parsed = parse_node_line(&new_line, node.line_index)?;
1004 if parsed.id != id {
1005 return Err(anyhow::anyhow!("Patch resulted in different id"));
1006 }
1007
1008 if strict {
1009 for r in &parsed.references {
1010 if let Reference::Internal(iid) = r
1011 && !mm.by_id.contains_key(iid)
1012 {
1013 return Err(anyhow::anyhow!(format!(
1014 "PATCH strict: reference to missing node {}",
1015 iid
1016 )));
1017 }
1018 }
1019 }
1020
1021 mm.lines[node.line_index] = new_line;
1023 let node_mut = &mut mm.nodes[idx];
1024 node_mut.raw_title = parsed.raw_title;
1025 node_mut.description = parsed.description;
1026 node_mut.references = parsed.references;
1027
1028 Ok(())
1029}
1030
1031pub fn cmd_delete(mm: &mut Mindmap, id: u32, force: bool) -> Result<()> {
1032 let idx = *mm
1034 .by_id
1035 .get(&id)
1036 .ok_or_else(|| anyhow::anyhow!(format!("Node [{}] not found", id)))?;
1037
1038 let mut incoming_from = Vec::new();
1040 for n in &mm.nodes {
1041 if n.references
1042 .iter()
1043 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
1044 {
1045 incoming_from.push(n.id);
1046 }
1047 }
1048 if !incoming_from.is_empty() && !force {
1049 return Err(anyhow::anyhow!(format!(
1050 "Node {} is referenced by {:?}; use --force to delete",
1051 id, incoming_from
1052 )));
1053 }
1054
1055 let line_idx = mm.nodes[idx].line_index;
1057 mm.lines.remove(line_idx);
1058
1059 mm.nodes.remove(idx);
1061
1062 mm.by_id.clear();
1064 for (i, node) in mm.nodes.iter_mut().enumerate() {
1065 if node.line_index > line_idx {
1067 node.line_index -= 1;
1068 }
1069 mm.by_id.insert(node.id, i);
1070 }
1071
1072 Ok(())
1073}
1074
1075fn validate_external_references(mm: &Mindmap, workspace: &std::path::Path) -> Vec<String> {
1078 let mut issues = Vec::new();
1079 let mut cache = crate::cache::MindmapCache::new(workspace.to_path_buf());
1080
1081 for node in &mm.nodes {
1082 for reference in &node.references {
1083 if let Reference::External(ref_id, ref_path) = reference {
1084 let canonical_path = match cache.resolve_path(&mm.path, ref_path) {
1086 Ok(p) => p,
1087 Err(_) => {
1088 issues.push(format!(
1089 "Missing file: node [{}] references missing file {}",
1090 node.id, ref_path
1091 ));
1092 continue;
1093 }
1094 };
1095
1096 let ext_mm = match cache.load(&mm.path, ref_path, &std::collections::HashSet::new())
1098 {
1099 Ok(m) => m,
1100 Err(e) => {
1101 issues.push(format!(
1102 "Unreadable file: node [{}] cannot read {}: {}",
1103 node.id, ref_path, e
1104 ));
1105 continue;
1106 }
1107 };
1108
1109 if !ext_mm.by_id.contains_key(ref_id) {
1111 issues.push(format!(
1112 "Invalid node: node [{}] references non-existent [{}] in {}",
1113 node.id,
1114 ref_id,
1115 canonical_path.display()
1116 ));
1117 }
1118 }
1119 }
1120 }
1121
1122 issues
1123}
1124
1125pub fn cmd_lint(mm: &Mindmap) -> Result<Vec<String>> {
1126 let mut warnings = Vec::new();
1127
1128 for (i, line) in mm.lines.iter().enumerate() {
1130 let trimmed = line.trim_start();
1131 if trimmed.starts_with('[') && parse_node_line(trimmed, i).is_err() {
1132 warnings.push(format!(
1133 "Syntax: line {} starts with '[' but does not match node format",
1134 i + 1
1135 ));
1136 }
1137 }
1138
1139 let mut id_map: HashMap<u32, Vec<usize>> = HashMap::new();
1141 for (i, line) in mm.lines.iter().enumerate() {
1142 if let Ok(node) = parse_node_line(line, i) {
1143 id_map.entry(node.id).or_default().push(i + 1);
1144 }
1145 }
1146 for (id, locations) in &id_map {
1147 if locations.len() > 1 {
1148 warnings.push(format!(
1149 "Duplicate ID: node {} appears on lines {:?}",
1150 id, locations
1151 ));
1152 }
1153 }
1154
1155 for n in &mm.nodes {
1157 for r in &n.references {
1158 match r {
1159 Reference::Internal(iid) => {
1160 if !mm.by_id.contains_key(iid) {
1161 warnings.push(format!(
1162 "Missing ref: node {} references missing node {}",
1163 n.id, iid
1164 ));
1165 }
1166 }
1167 Reference::External(eid, file) => {
1168 if !std::path::Path::new(file).exists() {
1170 warnings.push(format!(
1171 "Missing file: node {} references {} in missing file {}",
1172 n.id, eid, file
1173 ));
1174 }
1175 }
1176 }
1177 }
1178 }
1179
1180 let workspace = mm
1182 .path
1183 .parent()
1184 .unwrap_or_else(|| std::path::Path::new("."));
1185 let external_issues = validate_external_references(mm, workspace);
1186 warnings.extend(external_issues);
1187
1188 if warnings.is_empty() {
1189 Ok(vec!["Lint OK".to_string()])
1190 } else {
1191 Ok(warnings)
1192 }
1193}
1194
1195pub fn cmd_orphans(mm: &Mindmap, with_descriptions: bool) -> Result<Vec<String>> {
1196 let mut warnings = Vec::new();
1197
1198 let mut incoming: HashMap<u32, usize> = HashMap::new();
1200 for n in &mm.nodes {
1201 incoming.entry(n.id).or_insert(0);
1202 }
1203 for n in &mm.nodes {
1204 for r in &n.references {
1205 if let Reference::Internal(iid) = r
1206 && incoming.contains_key(iid)
1207 {
1208 *incoming.entry(*iid).or_insert(0) += 1;
1209 }
1210 }
1211 }
1212
1213 let mut orphan_nodes = Vec::new();
1214 for n in &mm.nodes {
1215 let inc = incoming.get(&n.id).copied().unwrap_or(0);
1216 let out = n.references.len();
1217 let title_up = n.raw_title.to_uppercase();
1218 if inc == 0 && out == 0 && !title_up.starts_with("META") {
1219 orphan_nodes.push(n.clone());
1220 }
1221 }
1222
1223 if orphan_nodes.is_empty() {
1224 Ok(vec!["No orphans".to_string()])
1225 } else {
1226 for n in orphan_nodes {
1227 if with_descriptions {
1228 warnings.push(format!(
1229 "[{}] **{}** - {}",
1230 n.id, n.raw_title, n.description
1231 ));
1232 } else {
1233 warnings.push(format!("{}", n.id));
1234 }
1235 }
1236 Ok(warnings)
1237 }
1238}
1239
1240pub fn cmd_graph(mm: &Mindmap, id: u32) -> Result<String> {
1241 if !mm.by_id.contains_key(&id) {
1242 return Err(anyhow::anyhow!(format!("Node {} not found", id)));
1243 }
1244
1245 let mut nodes = std::collections::HashSet::new();
1247 nodes.insert(id);
1248
1249 if let Some(node) = mm.get_node(id) {
1251 for r in &node.references {
1252 if let Reference::Internal(rid) = r {
1253 nodes.insert(*rid);
1254 }
1255 }
1256 }
1257
1258 for n in &mm.nodes {
1260 for r in &n.references {
1261 if let Reference::Internal(rid) = r
1262 && *rid == id
1263 {
1264 nodes.insert(n.id);
1265 }
1266 }
1267 }
1268
1269 let mut dot = String::new();
1271 dot.push_str("digraph {\n");
1272 dot.push_str(" rankdir=LR;\n");
1273
1274 for &nid in &nodes {
1276 if let Some(node) = mm.get_node(nid) {
1277 let label = format!("{}: {}", node.id, node.raw_title.replace("\"", "\\\""));
1278 dot.push_str(&format!(" {} [label=\"{}\"];\n", nid, label));
1279 }
1280 }
1281
1282 for &nid in &nodes {
1284 if let Some(node) = mm.get_node(nid) {
1285 for r in &node.references {
1286 if let Reference::Internal(rid) = r
1287 && nodes.contains(rid)
1288 {
1289 dot.push_str(&format!(" {} -> {};\n", nid, rid));
1290 }
1291 }
1292 }
1293 }
1294
1295 dot.push_str("}\n");
1296 Ok(dot)
1297}
1298
1299pub fn cmd_types(mm: &Mindmap, type_of: Option<&str>) -> Result<Vec<String>> {
1300 let mut type_counts: std::collections::HashMap<String, usize> =
1302 std::collections::HashMap::new();
1303 let mut type_examples: std::collections::HashMap<String, Vec<u32>> =
1304 std::collections::HashMap::new();
1305
1306 for n in &mm.nodes {
1307 if let Some(colon_pos) = n.raw_title.find(':') {
1308 let node_type = n.raw_title[..colon_pos].to_string();
1309 *type_counts.entry(node_type.clone()).or_insert(0) += 1;
1310 type_examples.entry(node_type).or_default().push(n.id);
1311 }
1312 }
1313
1314 let mut results = Vec::new();
1315
1316 if let Some(specific_type) = type_of {
1317 if let Some(count) = type_counts.get(specific_type) {
1319 results.push(format!("Type '{}': {} nodes", specific_type, count));
1320 if let Some(examples) = type_examples.get(specific_type) {
1321 results.push(format!(
1322 " Examples: {}",
1323 examples
1324 .iter()
1325 .take(5)
1326 .map(|id| format!("[{}]", id))
1327 .collect::<Vec<_>>()
1328 .join(", ")
1329 ));
1330 }
1331 } else {
1332 results.push(format!("Type '{}' not found in use", specific_type));
1333 }
1334 } else {
1335 results.push(format!("Node types in use ({} types):", type_counts.len()));
1337 let mut sorted_types: Vec<_> = type_counts.iter().collect();
1338 sorted_types.sort_by(|a, b| b.1.cmp(a.1)); for (node_type, count) in sorted_types {
1340 results.push(format!(" {:<10} ({:>3} nodes)", node_type, count));
1341 }
1342 }
1343
1344 Ok(results)
1345}
1346
1347pub fn cmd_relationships(mm: &Mindmap, id: u32) -> Result<(Vec<u32>, Vec<Reference>)> {
1348 mm.get_node(id)
1350 .ok_or_else(|| anyhow::anyhow!(format!("Node [{}] not found", id)))?;
1351
1352 let mut incoming = Vec::new();
1354 for n in &mm.nodes {
1355 if n.references
1356 .iter()
1357 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
1358 {
1359 incoming.push(n.id);
1360 }
1361 }
1362
1363 let outgoing = mm
1365 .get_node(id)
1366 .map(|n| n.references.clone())
1367 .unwrap_or_default();
1368
1369 Ok((incoming, outgoing))
1370}
1371
1372fn blake3_hash(content: &[u8]) -> String {
1374 blake3::hash(content).to_hex().to_string()
1375}
1376
1377#[derive(Debug, Clone)]
1378enum BatchOp {
1379 Add {
1380 type_prefix: String,
1381 title: String,
1382 desc: String,
1383 },
1384 Patch {
1385 id: u32,
1386 type_prefix: Option<String>,
1387 title: Option<String>,
1388 desc: Option<String>,
1389 },
1390 Put {
1391 id: u32,
1392 line: String,
1393 },
1394 Delete {
1395 id: u32,
1396 force: bool,
1397 },
1398 Deprecate {
1399 id: u32,
1400 to: u32,
1401 },
1402 Verify {
1403 id: u32,
1404 },
1405}
1406
1407#[derive(Debug, Clone, serde::Serialize)]
1408pub struct BatchResult {
1409 pub total_ops: usize,
1410 pub applied: usize,
1411 pub added_ids: Vec<u32>,
1412 pub patched_ids: Vec<u32>,
1413 pub deleted_ids: Vec<u32>,
1414 pub warnings: Vec<String>,
1415}
1416
1417fn parse_batch_op_json(val: &serde_json::Value) -> Result<BatchOp> {
1419 let obj = val
1420 .as_object()
1421 .ok_or_else(|| anyhow::anyhow!("Op must be a JSON object"))?;
1422 let op_type = obj
1423 .get("op")
1424 .and_then(|v| v.as_str())
1425 .ok_or_else(|| anyhow::anyhow!("Missing 'op' field"))?;
1426
1427 match op_type {
1428 "add" => {
1429 let type_prefix = obj
1430 .get("type")
1431 .and_then(|v| v.as_str())
1432 .ok_or_else(|| anyhow::anyhow!("add: missing 'type' field"))?
1433 .to_string();
1434 let title = obj
1435 .get("title")
1436 .and_then(|v| v.as_str())
1437 .ok_or_else(|| anyhow::anyhow!("add: missing 'title' field"))?
1438 .to_string();
1439 let desc = obj
1440 .get("desc")
1441 .and_then(|v| v.as_str())
1442 .ok_or_else(|| anyhow::anyhow!("add: missing 'desc' field"))?
1443 .to_string();
1444 Ok(BatchOp::Add {
1445 type_prefix,
1446 title,
1447 desc,
1448 })
1449 }
1450 "patch" => {
1451 let id = obj
1452 .get("id")
1453 .and_then(|v| v.as_u64())
1454 .ok_or_else(|| anyhow::anyhow!("patch: missing 'id' field"))?
1455 as u32;
1456 let type_prefix = obj.get("type").and_then(|v| v.as_str()).map(String::from);
1457 let title = obj.get("title").and_then(|v| v.as_str()).map(String::from);
1458 let desc = obj.get("desc").and_then(|v| v.as_str()).map(String::from);
1459 Ok(BatchOp::Patch {
1460 id,
1461 type_prefix,
1462 title,
1463 desc,
1464 })
1465 }
1466 "put" => {
1467 let id = obj
1468 .get("id")
1469 .and_then(|v| v.as_u64())
1470 .ok_or_else(|| anyhow::anyhow!("put: missing 'id' field"))?
1471 as u32;
1472 let line = obj
1473 .get("line")
1474 .and_then(|v| v.as_str())
1475 .ok_or_else(|| anyhow::anyhow!("put: missing 'line' field"))?
1476 .to_string();
1477 Ok(BatchOp::Put { id, line })
1478 }
1479 "delete" => {
1480 let id = obj
1481 .get("id")
1482 .and_then(|v| v.as_u64())
1483 .ok_or_else(|| anyhow::anyhow!("delete: missing 'id' field"))?
1484 as u32;
1485 let force = obj.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1486 Ok(BatchOp::Delete { id, force })
1487 }
1488 "deprecate" => {
1489 let id = obj
1490 .get("id")
1491 .and_then(|v| v.as_u64())
1492 .ok_or_else(|| anyhow::anyhow!("deprecate: missing 'id' field"))?
1493 as u32;
1494 let to = obj
1495 .get("to")
1496 .and_then(|v| v.as_u64())
1497 .ok_or_else(|| anyhow::anyhow!("deprecate: missing 'to' field"))?
1498 as u32;
1499 Ok(BatchOp::Deprecate { id, to })
1500 }
1501 "verify" => {
1502 let id = obj
1503 .get("id")
1504 .and_then(|v| v.as_u64())
1505 .ok_or_else(|| anyhow::anyhow!("verify: missing 'id' field"))?
1506 as u32;
1507 Ok(BatchOp::Verify { id })
1508 }
1509 other => Err(anyhow::anyhow!("Unknown op type: {}", other)),
1510 }
1511}
1512
1513fn parse_batch_op_line(line: &str) -> Result<BatchOp> {
1515 use shell_words;
1516
1517 let parts = shell_words::split(line)?;
1518 if parts.is_empty() {
1519 return Err(anyhow::anyhow!("Empty operation line"));
1520 }
1521
1522 match parts[0].as_str() {
1523 "add" => {
1524 let mut type_prefix = String::new();
1525 let mut title = String::new();
1526 let mut desc = String::new();
1527 let mut i = 1;
1528 while i < parts.len() {
1529 match parts[i].as_str() {
1530 "--type" => {
1531 i += 1;
1532 type_prefix = parts
1533 .get(i)
1534 .ok_or_else(|| anyhow::anyhow!("add: --type requires value"))?
1535 .clone();
1536 }
1537 "--title" => {
1538 i += 1;
1539 title = parts
1540 .get(i)
1541 .ok_or_else(|| anyhow::anyhow!("add: --title requires value"))?
1542 .clone();
1543 }
1544 "--desc" => {
1545 i += 1;
1546 desc = parts
1547 .get(i)
1548 .ok_or_else(|| anyhow::anyhow!("add: --desc requires value"))?
1549 .clone();
1550 }
1551 _ => {}
1552 }
1553 i += 1;
1554 }
1555 if type_prefix.is_empty() || title.is_empty() || desc.is_empty() {
1556 return Err(anyhow::anyhow!("add: requires --type, --title, --desc"));
1557 }
1558 Ok(BatchOp::Add {
1559 type_prefix,
1560 title,
1561 desc,
1562 })
1563 }
1564 "patch" => {
1565 let id: u32 = parts
1566 .get(1)
1567 .ok_or_else(|| anyhow::anyhow!("patch: missing id"))?
1568 .parse()?;
1569 let mut type_prefix: Option<String> = None;
1570 let mut title: Option<String> = None;
1571 let mut desc: Option<String> = None;
1572 let mut i = 2;
1573 while i < parts.len() {
1574 match parts[i].as_str() {
1575 "--type" => {
1576 i += 1;
1577 type_prefix = Some(
1578 parts
1579 .get(i)
1580 .ok_or_else(|| anyhow::anyhow!("patch: --type requires value"))?
1581 .clone(),
1582 );
1583 }
1584 "--title" => {
1585 i += 1;
1586 title = Some(
1587 parts
1588 .get(i)
1589 .ok_or_else(|| anyhow::anyhow!("patch: --title requires value"))?
1590 .clone(),
1591 );
1592 }
1593 "--desc" => {
1594 i += 1;
1595 desc = Some(
1596 parts
1597 .get(i)
1598 .ok_or_else(|| anyhow::anyhow!("patch: --desc requires value"))?
1599 .clone(),
1600 );
1601 }
1602 _ => {}
1603 }
1604 i += 1;
1605 }
1606 Ok(BatchOp::Patch {
1607 id,
1608 type_prefix,
1609 title,
1610 desc,
1611 })
1612 }
1613 "put" => {
1614 let id: u32 = parts
1615 .get(1)
1616 .ok_or_else(|| anyhow::anyhow!("put: missing id"))?
1617 .parse()?;
1618 let mut line = String::new();
1619 let mut i = 2;
1620 while i < parts.len() {
1621 if parts[i] == "--line" {
1622 i += 1;
1623 line = parts
1624 .get(i)
1625 .ok_or_else(|| anyhow::anyhow!("put: --line requires value"))?
1626 .clone();
1627 break;
1628 }
1629 i += 1;
1630 }
1631 if line.is_empty() {
1632 return Err(anyhow::anyhow!("put: requires --line"));
1633 }
1634 Ok(BatchOp::Put { id, line })
1635 }
1636 "delete" => {
1637 let id: u32 = parts
1638 .get(1)
1639 .ok_or_else(|| anyhow::anyhow!("delete: missing id"))?
1640 .parse()?;
1641 let force = parts.contains(&"--force".to_string());
1642 Ok(BatchOp::Delete { id, force })
1643 }
1644 "deprecate" => {
1645 let id: u32 = parts
1646 .get(1)
1647 .ok_or_else(|| anyhow::anyhow!("deprecate: missing id"))?
1648 .parse()?;
1649 let mut to: Option<u32> = None;
1650 let mut i = 2;
1651 while i < parts.len() {
1652 if parts[i] == "--to" {
1653 i += 1;
1654 to = Some(
1655 parts
1656 .get(i)
1657 .ok_or_else(|| anyhow::anyhow!("deprecate: --to requires value"))?
1658 .parse()?,
1659 );
1660 break;
1661 }
1662 i += 1;
1663 }
1664 let to = to.ok_or_else(|| anyhow::anyhow!("deprecate: requires --to"))?;
1665 Ok(BatchOp::Deprecate { id, to })
1666 }
1667 "verify" => {
1668 let id: u32 = parts
1669 .get(1)
1670 .ok_or_else(|| anyhow::anyhow!("verify: missing id"))?
1671 .parse()?;
1672 Ok(BatchOp::Verify { id })
1673 }
1674 other => Err(anyhow::anyhow!("Unknown batch command: {}", other)),
1675 }
1676}
1677
1678#[allow(dead_code)]
1683fn resolve_reference(
1684 cache: &mut crate::cache::MindmapCache,
1685 mm: &Mindmap,
1686 current_file: &std::path::Path,
1687 reference: &Reference,
1688 visited: &std::collections::HashSet<std::path::PathBuf>,
1689 _ctx: &mut crate::context::NavigationContext,
1690) -> Result<Option<(u32, std::path::PathBuf, Node)>> {
1691 match reference {
1692 Reference::Internal(id) => {
1693 if let Some(node) = mm.get_node(*id) {
1695 Ok(Some((*id, current_file.to_path_buf(), node.clone())))
1696 } else {
1697 Ok(None) }
1699 }
1700 Reference::External(id, path) => {
1701 if _ctx.at_max_depth() {
1703 return Ok(None); }
1705
1706 let _guard = _ctx.descend()?;
1707
1708 let canonical = match cache.resolve_path(current_file, path) {
1710 Ok(p) => p,
1711 Err(_) => return Ok(None),
1712 };
1713
1714 match cache.load(current_file, path, visited) {
1716 Ok(ext_mm) => {
1717 if let Some(node) = ext_mm.get_node(*id) {
1718 Ok(Some((*id, canonical, node.clone())))
1719 } else {
1720 Ok(None) }
1722 }
1723 Err(_) => Ok(None), }
1725 }
1726 }
1727}
1728
1729#[allow(dead_code)]
1731fn get_incoming_recursive(
1732 _cache: &mut crate::cache::MindmapCache,
1733 mm: &Mindmap,
1734 current_file: &std::path::Path,
1735 id: u32,
1736 _visited: &std::collections::HashSet<std::path::PathBuf>,
1737 _ctx: &mut crate::context::NavigationContext,
1738) -> Result<Vec<(u32, std::path::PathBuf, Node)>> {
1739 let mut inbound = Vec::new();
1740
1741 for n in &mm.nodes {
1743 if n.references
1744 .iter()
1745 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
1746 {
1747 inbound.push((n.id, current_file.to_path_buf(), n.clone()));
1748 }
1749 }
1750
1751 Ok(inbound)
1752}
1753
1754#[allow(dead_code)]
1756fn get_outgoing_recursive(
1757 cache: &mut crate::cache::MindmapCache,
1758 mm: &Mindmap,
1759 current_file: &std::path::Path,
1760 id: u32,
1761 visited: &std::collections::HashSet<std::path::PathBuf>,
1762 ctx: &mut crate::context::NavigationContext,
1763) -> Result<Vec<(u32, std::path::PathBuf, Node)>> {
1764 let mut outbound = Vec::new();
1765
1766 if let Some(node) = mm.get_node(id) {
1767 for reference in &node.references {
1768 if let Ok(Some((ref_id, ref_path, ref_node))) =
1769 resolve_reference(cache, mm, current_file, reference, visited, ctx)
1770 {
1771 outbound.push((ref_id, ref_path, ref_node));
1772 }
1773 }
1774 }
1775
1776 Ok(outbound)
1777}
1778
1779pub fn run(cli: Cli) -> Result<()> {
1780 let path = cli.file.unwrap_or_else(|| PathBuf::from("MINDMAP.md"));
1781
1782 let mut mm = if path.as_os_str() == "-" {
1784 Mindmap::load_from_reader(std::io::stdin(), path.clone())?
1785 } else {
1786 Mindmap::load(path.clone())?
1787 };
1788
1789 let interactive = atty::is(atty::Stream::Stdout);
1791 let env_override = std::env::var("MINDMAP_PRETTY").ok();
1792 let pretty_enabled = match env_override.as_deref() {
1793 Some("0") => false,
1794 Some("1") => true,
1795 _ => interactive,
1796 } && matches!(cli.output, OutputFormat::Default);
1797
1798 let printer: Option<Box<dyn ui::Printer>> = if matches!(cli.output, OutputFormat::Default) {
1799 if pretty_enabled {
1800 Some(Box::new(crate::ui::PrettyPrinter::new()?))
1801 } else {
1802 Some(Box::new(crate::ui::PlainPrinter::new()?))
1803 }
1804 } else {
1805 None
1806 };
1807
1808 let cannot_write_err = |cmd_name: &str| -> anyhow::Error {
1810 anyhow::anyhow!(format!(
1811 "Cannot {}: mindmap was loaded from stdin ('-'); use --file <path> to save changes",
1812 cmd_name
1813 ))
1814 };
1815
1816 match cli.command {
1817 Commands::Show { id, follow } => match mm.get_node(id) {
1818 Some(node) => {
1819 if follow {
1820 let workspace = path.parent().unwrap_or_else(|| std::path::Path::new("."));
1822 let mut cache = crate::cache::MindmapCache::new(workspace.to_path_buf());
1823 let mut ctx = crate::context::NavigationContext::new();
1824 let mut visited = std::collections::HashSet::new();
1825 visited.insert(path.clone());
1826
1827 if matches!(cli.output, OutputFormat::Json) {
1828 let inbound =
1830 get_incoming_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
1831 .unwrap_or_default();
1832 let outbound =
1833 get_outgoing_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
1834 .unwrap_or_default();
1835
1836 let inbound_refs: Vec<_> = inbound
1837 .iter()
1838 .map(|(ref_id, ref_path, ref_node)| {
1839 serde_json::json!({
1840 "id": ref_id,
1841 "title": ref_node.raw_title,
1842 "file": ref_path.to_string_lossy(),
1843 })
1844 })
1845 .collect();
1846
1847 let outbound_refs: Vec<_> = outbound
1848 .iter()
1849 .map(|(ref_id, ref_path, ref_node)| {
1850 serde_json::json!({
1851 "id": ref_id,
1852 "title": ref_node.raw_title,
1853 "file": ref_path.to_string_lossy(),
1854 })
1855 })
1856 .collect();
1857
1858 let obj = serde_json::json!({
1859 "command": "show",
1860 "follow": true,
1861 "node": {
1862 "id": node.id,
1863 "raw_title": node.raw_title,
1864 "description": node.description,
1865 "file": path.to_string_lossy(),
1866 "line_index": node.line_index,
1867 },
1868 "incoming": inbound_refs,
1869 "outgoing": outbound_refs,
1870 });
1871 println!("{}", serde_json::to_string_pretty(&obj)?);
1872 } else {
1873 let inbound =
1875 get_incoming_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
1876 .unwrap_or_default();
1877 let outbound =
1878 get_outgoing_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
1879 .unwrap_or_default();
1880
1881 println!(
1882 "[{}] **{}** - {} ({})",
1883 node.id,
1884 node.raw_title,
1885 node.description,
1886 path.display()
1887 );
1888
1889 if !inbound.is_empty() {
1890 eprintln!(
1891 "← Nodes referring to [{}] (recursive, {} total):",
1892 id,
1893 inbound.len()
1894 );
1895 for (ref_id, ref_path, ref_node) in &inbound {
1896 eprintln!(
1897 " [{}] {} ({})",
1898 ref_id,
1899 ref_node.raw_title,
1900 ref_path.display()
1901 );
1902 }
1903 }
1904
1905 if !outbound.is_empty() {
1906 eprintln!(
1907 "→ [{}] refers to (recursive, {} total):",
1908 id,
1909 outbound.len()
1910 );
1911 for (ref_id, ref_path, ref_node) in &outbound {
1912 eprintln!(
1913 " [{}] {} ({})",
1914 ref_id,
1915 ref_node.raw_title,
1916 ref_path.display()
1917 );
1918 }
1919 }
1920 }
1921 } else {
1922 if matches!(cli.output, OutputFormat::Json) {
1924 let obj = serde_json::json!({
1925 "command": "show",
1926 "follow": false,
1927 "node": {
1928 "id": node.id,
1929 "raw_title": node.raw_title,
1930 "description": node.description,
1931 "file": path.to_string_lossy(),
1932 "references": node.references,
1933 "line_index": node.line_index,
1934 }
1935 });
1936 println!("{}", serde_json::to_string_pretty(&obj)?);
1937 } else {
1938 let mut inbound = Vec::new();
1940 for n in &mm.nodes {
1941 if n.references
1942 .iter()
1943 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
1944 {
1945 inbound.push(n.id);
1946 }
1947 }
1948
1949 if let Some(p) = &printer {
1950 p.show(node, &inbound, &node.references)?;
1951 } else {
1952 println!(
1953 "[{}] **{}** - {}",
1954 node.id, node.raw_title, node.description
1955 );
1956 if !inbound.is_empty() {
1957 eprintln!("← Nodes referring to [{}]: {:?}", id, inbound);
1958 }
1959 let outbound: Vec<u32> = node
1960 .references
1961 .iter()
1962 .filter_map(|r| match r {
1963 Reference::Internal(rid) => Some(*rid),
1964 _ => None,
1965 })
1966 .collect();
1967 if !outbound.is_empty() {
1968 eprintln!("→ [{}] refers to: {:?}", id, outbound);
1969 }
1970 }
1971 }
1972 }
1973 }
1974 None => {
1975 let min_id = mm.nodes.iter().map(|n| n.id).min();
1976 let max_id = mm.nodes.iter().map(|n| n.id).max();
1977 let hint = if let (Some(min), Some(max)) = (min_id, max_id) {
1978 format!(
1979 " (Valid node IDs: {} to {}). Use `mindmap-cli list` to see all nodes.",
1980 min, max
1981 )
1982 } else {
1983 " No nodes exist yet. Use `mindmap-cli add` to create one.".to_string()
1984 };
1985 return Err(anyhow::anyhow!(format!("Node [{}] not found{}", id, hint)));
1986 }
1987 },
1988 Commands::List {
1989 r#type,
1990 grep,
1991 case_sensitive,
1992 exact_match,
1993 regex_mode,
1994 } => {
1995 let items = cmd_list(
1996 &mm,
1997 r#type.as_deref(),
1998 grep.as_deref(),
1999 case_sensitive,
2000 exact_match,
2001 regex_mode,
2002 );
2003 let count = items.len();
2004
2005 if matches!(cli.output, OutputFormat::Json) {
2006 let arr: Vec<_> = items
2007 .into_iter()
2008 .map(|line| serde_json::json!({"line": line}))
2009 .collect();
2010 let obj = serde_json::json!({"command": "list", "count": count, "items": arr});
2011 println!("{}", serde_json::to_string_pretty(&obj)?);
2012 } else {
2013 if count == 0 {
2014 eprintln!("No matching nodes found (0 results)");
2015 } else {
2016 eprintln!(
2017 "Matching nodes ({} result{}:)",
2018 count,
2019 if count == 1 { "" } else { "s" },
2020 );
2021 }
2022 if let Some(p) = &printer {
2023 p.list(&items)?;
2024 } else {
2025 for it in items {
2026 println!("{}", it);
2027 }
2028 }
2029 }
2030 }
2031 Commands::Refs { id, follow } => {
2032 if mm.get_node(id).is_none() {
2034 let min_id = mm.nodes.iter().map(|n| n.id).min();
2035 let max_id = mm.nodes.iter().map(|n| n.id).max();
2036 let hint = if let (Some(min), Some(max)) = (min_id, max_id) {
2037 format!(" (Valid node IDs: {} to {})", min, max)
2038 } else {
2039 " No nodes exist.".to_string()
2040 };
2041 return Err(anyhow::anyhow!(format!("Node [{}] not found{}", id, hint)));
2042 }
2043
2044 if follow {
2045 let workspace = path.parent().unwrap_or_else(|| std::path::Path::new("."));
2047 let mut cache = crate::cache::MindmapCache::new(workspace.to_path_buf());
2048 let mut ctx = crate::context::NavigationContext::new();
2049 let mut visited = std::collections::HashSet::new();
2050 visited.insert(path.clone());
2051
2052 let inbound =
2053 get_incoming_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
2054 .unwrap_or_default();
2055 let count = inbound.len();
2056
2057 if matches!(cli.output, OutputFormat::Json) {
2058 let items: Vec<_> = inbound
2059 .iter()
2060 .map(|(ref_id, ref_path, ref_node)| {
2061 serde_json::json!({
2062 "id": ref_id,
2063 "title": ref_node.raw_title,
2064 "file": ref_path.to_string_lossy(),
2065 })
2066 })
2067 .collect();
2068 let obj = serde_json::json!({
2069 "command": "refs",
2070 "target": id,
2071 "follow": true,
2072 "count": count,
2073 "items": items
2074 });
2075 println!("{}", serde_json::to_string_pretty(&obj)?);
2076 } else {
2077 if count == 0 {
2078 eprintln!("No nodes refer to [{}] (0 results)", id);
2079 } else {
2080 eprintln!(
2081 "← Nodes referring to [{}] (recursive, {} result{})",
2082 id,
2083 count,
2084 if count == 1 { "" } else { "s" }
2085 );
2086 }
2087 for (ref_id, ref_path, ref_node) in inbound {
2088 println!(
2089 "[{}] **{}** - {} ({})",
2090 ref_id,
2091 ref_node.raw_title,
2092 ref_node.description,
2093 ref_path.display()
2094 );
2095 }
2096 }
2097 } else {
2098 let items = cmd_refs(&mm, id);
2100 let count = items.len();
2101
2102 if matches!(cli.output, OutputFormat::Json) {
2103 let obj = serde_json::json!({
2104 "command": "refs",
2105 "target": id,
2106 "follow": false,
2107 "count": count,
2108 "items": items
2109 });
2110 println!("{}", serde_json::to_string_pretty(&obj)?);
2111 } else {
2112 if count == 0 {
2113 eprintln!("No nodes refer to [{}] (0 results)", id);
2114 } else {
2115 eprintln!(
2116 "← Nodes referring to [{}] ({} result{})",
2117 id,
2118 count,
2119 if count == 1 { "" } else { "s" }
2120 );
2121 }
2122 if let Some(p) = &printer {
2123 p.refs(&items)?;
2124 } else {
2125 for it in items {
2126 println!("{}", it);
2127 }
2128 }
2129 }
2130 }
2131 }
2132 Commands::Links { id, follow } => {
2133 if mm.get_node(id).is_none() {
2135 let min_id = mm.nodes.iter().map(|n| n.id).min();
2136 let max_id = mm.nodes.iter().map(|n| n.id).max();
2137 let hint = if let (Some(min), Some(max)) = (min_id, max_id) {
2138 format!(" (Valid node IDs: {} to {})", min, max)
2139 } else {
2140 " No nodes exist.".to_string()
2141 };
2142 return Err(anyhow::anyhow!(format!("Node [{}] not found{}", id, hint)));
2143 }
2144
2145 if follow {
2146 let workspace = path.parent().unwrap_or_else(|| std::path::Path::new("."));
2148 let mut cache = crate::cache::MindmapCache::new(workspace.to_path_buf());
2149 let mut ctx = crate::context::NavigationContext::new();
2150 let mut visited = std::collections::HashSet::new();
2151 visited.insert(path.clone());
2152
2153 let outbound =
2154 get_outgoing_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
2155 .unwrap_or_default();
2156 let count = outbound.len();
2157
2158 if matches!(cli.output, OutputFormat::Json) {
2159 let items: Vec<_> = outbound
2160 .iter()
2161 .map(|(ref_id, ref_path, ref_node)| {
2162 serde_json::json!({
2163 "id": ref_id,
2164 "title": ref_node.raw_title,
2165 "file": ref_path.to_string_lossy(),
2166 })
2167 })
2168 .collect();
2169 let obj = serde_json::json!({
2170 "command": "links",
2171 "source": id,
2172 "follow": true,
2173 "count": count,
2174 "links": items
2175 });
2176 println!("{}", serde_json::to_string_pretty(&obj)?);
2177 } else {
2178 if count == 0 {
2179 eprintln!("→ [{}] refers to no nodes (0 results)", id);
2180 } else {
2181 eprintln!(
2182 "→ [{}] refers to (recursive, {} result{})",
2183 id,
2184 count,
2185 if count == 1 { "" } else { "s" }
2186 );
2187 }
2188 for (ref_id, ref_path, ref_node) in outbound {
2189 println!(
2190 "[{}] **{}** - {} ({})",
2191 ref_id,
2192 ref_node.raw_title,
2193 ref_node.description,
2194 ref_path.display()
2195 );
2196 }
2197 }
2198 } else {
2199 match cmd_links(&mm, id) {
2201 Some(v) => {
2202 let count = v
2203 .iter()
2204 .filter(|r| matches!(r, Reference::Internal(_)))
2205 .count();
2206 if matches!(cli.output, OutputFormat::Json) {
2207 let obj = serde_json::json!({
2208 "command": "links",
2209 "source": id,
2210 "follow": false,
2211 "count": count,
2212 "links": v
2213 });
2214 println!("{}", serde_json::to_string_pretty(&obj)?);
2215 } else {
2216 if count == 0 {
2217 eprintln!("→ [{}] refers to no nodes (0 results)", id);
2218 } else {
2219 eprintln!(
2220 "→ [{}] refers to ({} result{})",
2221 id,
2222 count,
2223 if count == 1 { "" } else { "s" }
2224 );
2225 }
2226 if let Some(p) = &printer {
2227 p.links(id, &v)?;
2228 } else {
2229 println!("Node [{}] references: {:?}", id, v);
2230 }
2231 }
2232 }
2233 None => {
2234 let min_id = mm.nodes.iter().map(|n| n.id).min();
2235 let max_id = mm.nodes.iter().map(|n| n.id).max();
2236 let hint = if let (Some(min), Some(max)) = (min_id, max_id) {
2237 format!(" (Valid node IDs: {} to {})", min, max)
2238 } else {
2239 " No nodes exist.".to_string()
2240 };
2241 return Err(anyhow::anyhow!(format!("Node [{}] not found{}", id, hint)));
2242 }
2243 }
2244 }
2245 }
2246 Commands::Search {
2247 query,
2248 case_sensitive,
2249 exact_match,
2250 regex_mode,
2251 follow,
2252 } => {
2253 if follow {
2254 let workspace = path.parent().unwrap_or_else(|| std::path::Path::new("."));
2256 let mut cache = crate::cache::MindmapCache::new(workspace.to_path_buf());
2257 let _ctx = crate::context::NavigationContext::new();
2258 let mut visited_files = std::collections::HashSet::new();
2259 visited_files.insert(path.clone());
2260
2261 let mut all_items = cmd_list(
2263 &mm,
2264 None,
2265 Some(&query),
2266 case_sensitive,
2267 exact_match,
2268 regex_mode,
2269 );
2270
2271 let mut processed_files = std::collections::HashSet::new();
2273 processed_files.insert(path.clone());
2274
2275 for node in &mm.nodes {
2277 for ref_item in &node.references {
2278 if let Reference::External(_id, ref_path) = ref_item {
2279 let canonical_path = match cache.resolve_path(&path, ref_path) {
2281 Ok(p) => p,
2282 Err(_) => continue,
2283 };
2284
2285 if processed_files.contains(&canonical_path) {
2287 continue;
2288 }
2289 processed_files.insert(canonical_path.clone());
2290
2291 if let Ok(ext_mm) = cache.load(&path, ref_path, &visited_files) {
2293 let ext_items = cmd_list(
2294 ext_mm,
2295 None,
2296 Some(&query),
2297 case_sensitive,
2298 exact_match,
2299 regex_mode,
2300 );
2301 for item in ext_items {
2302 all_items.push(format!(
2304 "{} ({})",
2305 item,
2306 canonical_path.display()
2307 ));
2308 }
2309 }
2310 }
2311 }
2312 }
2313
2314 let count = all_items.len();
2315
2316 if matches!(cli.output, OutputFormat::Json) {
2317 let arr: Vec<_> = all_items
2318 .into_iter()
2319 .map(|line| serde_json::json!({"line": line}))
2320 .collect();
2321 let obj = serde_json::json!({
2322 "command": "search",
2323 "query": query,
2324 "follow": true,
2325 "count": count,
2326 "items": arr
2327 });
2328 println!("{}", serde_json::to_string_pretty(&obj)?);
2329 } else {
2330 if count == 0 {
2331 eprintln!("No matches for '{}' (0 results)", query);
2332 } else {
2333 eprintln!(
2334 "Search results for '{}' (recursive, {} result{})",
2335 query,
2336 count,
2337 if count == 1 { "" } else { "s" }
2338 );
2339 }
2340 if let Some(p) = &printer {
2341 p.list(&all_items)?;
2342 } else {
2343 for it in all_items {
2344 println!("{}", it);
2345 }
2346 }
2347 }
2348 } else {
2349 let items = cmd_list(
2351 &mm,
2352 None,
2353 Some(&query),
2354 case_sensitive,
2355 exact_match,
2356 regex_mode,
2357 );
2358 let count = items.len();
2359
2360 if matches!(cli.output, OutputFormat::Json) {
2361 let arr: Vec<_> = items
2362 .into_iter()
2363 .map(|line| serde_json::json!({"line": line}))
2364 .collect();
2365 let obj = serde_json::json!({
2366 "command": "search",
2367 "query": query,
2368 "follow": false,
2369 "count": count,
2370 "items": arr
2371 });
2372 println!("{}", serde_json::to_string_pretty(&obj)?);
2373 } else {
2374 if count == 0 {
2375 eprintln!("No matches for '{}' (0 results)", query);
2376 } else {
2377 eprintln!(
2378 "Search results for '{}' ({} result{})",
2379 query,
2380 count,
2381 if count == 1 { "" } else { "s" }
2382 );
2383 }
2384 if let Some(p) = &printer {
2385 p.list(&items)?;
2386 } else {
2387 for it in items {
2388 println!("{}", it);
2389 }
2390 }
2391 }
2392 }
2393 }
2394 Commands::Add {
2395 r#type,
2396 title,
2397 desc,
2398 strict,
2399 } => {
2400 if mm.path.as_os_str() == "-" {
2401 return Err(cannot_write_err("add"));
2402 }
2403 match (r#type.as_deref(), title.as_deref(), desc.as_deref()) {
2404 (Some(tp), Some(tt), Some(dd)) => {
2405 let id = cmd_add(&mut mm, tp, tt, dd)?;
2406 mm.save()?;
2407 if matches!(cli.output, OutputFormat::Json)
2408 && let Some(node) = mm.get_node(id)
2409 {
2410 let obj = serde_json::json!({"command": "add", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
2411 println!("{}", serde_json::to_string_pretty(&obj)?);
2412 }
2413 eprintln!("Added node [{}]", id);
2414 }
2415 (None, None, None) => {
2416 if !atty::is(atty::Stream::Stdin) {
2418 return Err(anyhow::anyhow!(
2419 "add via editor requires an interactive terminal"
2420 ));
2421 }
2422 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
2423 let id = cmd_add_editor(&mut mm, &editor, strict)?;
2424 mm.save()?;
2425 if matches!(cli.output, OutputFormat::Json)
2426 && let Some(node) = mm.get_node(id)
2427 {
2428 let obj = serde_json::json!({"command": "add", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
2429 println!("{}", serde_json::to_string_pretty(&obj)?);
2430 }
2431 eprintln!("Added node [{}]", id);
2432 }
2433 _ => {
2434 return Err(anyhow::anyhow!(
2435 "add requires either all of --type,--title,--desc or none (editor)"
2436 ));
2437 }
2438 }
2439 }
2440 Commands::Deprecate { id, to } => {
2441 if mm.path.as_os_str() == "-" {
2442 return Err(cannot_write_err("deprecate"));
2443 }
2444 cmd_deprecate(&mut mm, id, to)?;
2445 mm.save()?;
2446 if matches!(cli.output, OutputFormat::Json)
2447 && let Some(node) = mm.get_node(id)
2448 {
2449 let obj = serde_json::json!({"command": "deprecate", "node": {"id": node.id, "raw_title": node.raw_title}});
2450 println!("{}", serde_json::to_string_pretty(&obj)?);
2451 }
2452 eprintln!("Deprecated node [{}] → [{}]", id, to);
2453 }
2454 Commands::Edit { id } => {
2455 if mm.path.as_os_str() == "-" {
2456 return Err(cannot_write_err("edit"));
2457 }
2458 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
2459 cmd_edit(&mut mm, id, &editor)?;
2460 mm.save()?;
2461 if matches!(cli.output, OutputFormat::Json)
2462 && let Some(node) = mm.get_node(id)
2463 {
2464 let obj = serde_json::json!({"command": "edit", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
2465 println!("{}", serde_json::to_string_pretty(&obj)?);
2466 }
2467 eprintln!("Edited node [{}]", id);
2468 }
2469 Commands::Patch {
2470 id,
2471 r#type,
2472 title,
2473 desc,
2474 strict,
2475 } => {
2476 if mm.path.as_os_str() == "-" {
2477 return Err(cannot_write_err("patch"));
2478 }
2479 cmd_patch(
2480 &mut mm,
2481 id,
2482 r#type.as_deref(),
2483 title.as_deref(),
2484 desc.as_deref(),
2485 strict,
2486 )?;
2487 mm.save()?;
2488 if matches!(cli.output, OutputFormat::Json)
2489 && let Some(node) = mm.get_node(id)
2490 {
2491 let obj = serde_json::json!({"command": "patch", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
2492 println!("{}", serde_json::to_string_pretty(&obj)?);
2493 }
2494 eprintln!("Patched node [{}]", id);
2495 }
2496 Commands::Put { id, line, strict } => {
2497 if mm.path.as_os_str() == "-" {
2498 return Err(cannot_write_err("put"));
2499 }
2500 cmd_put(&mut mm, id, &line, strict)?;
2501 mm.save()?;
2502 if matches!(cli.output, OutputFormat::Json)
2503 && let Some(node) = mm.get_node(id)
2504 {
2505 let obj = serde_json::json!({"command": "put", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
2506 println!("{}", serde_json::to_string_pretty(&obj)?);
2507 }
2508 eprintln!("Put node [{}]", id);
2509 }
2510 Commands::Verify { id } => {
2511 if mm.path.as_os_str() == "-" {
2512 return Err(cannot_write_err("verify"));
2513 }
2514 cmd_verify(&mut mm, id)?;
2515 mm.save()?;
2516 if matches!(cli.output, OutputFormat::Json)
2517 && let Some(node) = mm.get_node(id)
2518 {
2519 let obj = serde_json::json!({"command": "verify", "node": {"id": node.id, "description": node.description}});
2520 println!("{}", serde_json::to_string_pretty(&obj)?);
2521 }
2522 eprintln!("Marked node [{}] for verification", id);
2523 }
2524 Commands::Delete { id, force } => {
2525 if mm.path.as_os_str() == "-" {
2526 return Err(cannot_write_err("delete"));
2527 }
2528 cmd_delete(&mut mm, id, force)?;
2529 mm.save()?;
2530 if matches!(cli.output, OutputFormat::Json) {
2531 let obj = serde_json::json!({"command": "delete", "deleted": id});
2532 println!("{}", serde_json::to_string_pretty(&obj)?);
2533 }
2534 eprintln!("Deleted node [{}]", id);
2535 }
2536 Commands::Lint { fix } => {
2537 if fix {
2538 if mm.path.as_os_str() == "-" {
2539 return Err(cannot_write_err("lint --fix"));
2540 }
2541
2542 let report = mm.apply_fixes()?;
2544 if report.any_changes() {
2545 mm.save()?;
2546 }
2547
2548 if matches!(cli.output, OutputFormat::Json) {
2549 let obj = serde_json::json!({"command": "lint", "fixed": report.any_changes(), "fixes": report});
2550 println!("{}", serde_json::to_string_pretty(&obj)?);
2551 } else {
2552 if !report.spacing.is_empty() {
2553 eprintln!(
2554 "Fixed spacing: inserted {} blank lines",
2555 report.spacing.len()
2556 );
2557 }
2558 for tf in &report.title_fixes {
2559 eprintln!(
2560 "Fixed title for node {}: '{}' -> '{}'",
2561 tf.id, tf.old, tf.new
2562 );
2563 }
2564 if !report.any_changes() {
2565 eprintln!("No fixes necessary");
2566 }
2567
2568 let res = cmd_lint(&mm)?;
2570 for r in res {
2571 eprintln!("{}", r);
2572 }
2573 }
2574 } else {
2575 let res = cmd_lint(&mm)?;
2576 if matches!(cli.output, OutputFormat::Json) {
2577 let obj = serde_json::json!({"command": "lint", "warnings": res.iter().filter(|r| *r != "Lint OK").collect::<Vec<_>>()});
2578 println!("{}", serde_json::to_string_pretty(&obj)?);
2579 } else if res.len() == 1 && res[0] == "Lint OK" {
2580 eprintln!("✓ Lint OK (0 warnings)");
2581 } else {
2582 eprintln!(
2583 "Lint found {} warning{}:",
2584 res.len(),
2585 if res.len() == 1 { "" } else { "s" }
2586 );
2587 for r in res {
2588 eprintln!(" - {}", r);
2589 }
2590 }
2591 }
2592 }
2593 Commands::Orphans { with_descriptions } => {
2594 let res = cmd_orphans(&mm, with_descriptions)?;
2595 if matches!(cli.output, OutputFormat::Json) {
2596 let count = if res.iter().any(|r| r == "No orphans") {
2597 0
2598 } else {
2599 res.len()
2600 };
2601 let obj = serde_json::json!({"command": "orphans", "count": count, "orphans": res});
2602 println!("{}", serde_json::to_string_pretty(&obj)?);
2603 } else {
2604 if res.iter().any(|r| r == "No orphans") {
2606 eprintln!("✓ No orphans found (0 results)");
2607 } else {
2608 eprintln!(
2609 "Orphan nodes ({} result{}):",
2610 res.len(),
2611 if res.len() == 1 { "" } else { "s" }
2612 );
2613 }
2614
2615 if let Some(p) = &printer {
2617 p.orphans(&res)?;
2618 } else {
2619 for r in res {
2620 if r != "No orphans" {
2621 println!("{}", r);
2622 }
2623 }
2624 }
2625 }
2626 }
2627 Commands::Type { of } => {
2628 let res = cmd_types(&mm, of.as_deref())?;
2629 if matches!(cli.output, OutputFormat::Json) {
2630 let obj = serde_json::json!({"command": "type", "filter": of, "results": res});
2631 println!("{}", serde_json::to_string_pretty(&obj)?);
2632 } else {
2633 eprintln!("Node types information:");
2634 for line in res {
2635 if line.starts_with(" ") {
2636 println!("{}", line);
2637 } else {
2638 eprintln!("{}", line);
2639 }
2640 }
2641 }
2642 }
2643 Commands::Relationships { id, follow } => {
2644 if follow {
2645 let workspace = path.parent().unwrap_or_else(|| std::path::Path::new("."));
2647 let mut cache = crate::cache::MindmapCache::new(workspace.to_path_buf());
2648 let mut ctx = crate::context::NavigationContext::new();
2649 let mut visited = std::collections::HashSet::new();
2650 visited.insert(path.clone());
2651
2652 if mm.get_node(id).is_none() {
2654 return Err(anyhow::anyhow!(format!("Node [{}] not found", id)));
2655 }
2656
2657 let incoming =
2658 get_incoming_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
2659 .unwrap_or_default();
2660 let outgoing =
2661 get_outgoing_recursive(&mut cache, &mm, &path, id, &visited, &mut ctx)
2662 .unwrap_or_default();
2663
2664 if matches!(cli.output, OutputFormat::Json) {
2665 let incoming_json: Vec<_> = incoming
2666 .iter()
2667 .map(|(ref_id, ref_path, ref_node)| {
2668 serde_json::json!({
2669 "id": ref_id,
2670 "title": ref_node.raw_title,
2671 "file": ref_path.to_string_lossy(),
2672 })
2673 })
2674 .collect();
2675
2676 let outgoing_json: Vec<_> = outgoing
2677 .iter()
2678 .map(|(ref_id, ref_path, ref_node)| {
2679 serde_json::json!({
2680 "id": ref_id,
2681 "title": ref_node.raw_title,
2682 "file": ref_path.to_string_lossy(),
2683 })
2684 })
2685 .collect();
2686
2687 let obj = serde_json::json!({
2688 "command": "relationships",
2689 "node": id,
2690 "follow": true,
2691 "incoming": incoming_json,
2692 "outgoing": outgoing_json,
2693 "incoming_count": incoming.len(),
2694 "outgoing_count": outgoing.len(),
2695 });
2696 println!("{}", serde_json::to_string_pretty(&obj)?);
2697 } else {
2698 eprintln!("Relationships for [{}] (recursive):", id);
2699 eprintln!("← Incoming ({} nodes):", incoming.len());
2700 for (ref_id, ref_path, ref_node) in &incoming {
2701 eprintln!(
2702 " [{}] **{}** ({})",
2703 ref_id,
2704 ref_node.raw_title,
2705 ref_path.display()
2706 );
2707 }
2708 eprintln!("→ Outgoing ({} nodes):", outgoing.len());
2709 for (ref_id, ref_path, ref_node) in &outgoing {
2710 eprintln!(
2711 " [{}] **{}** ({})",
2712 ref_id,
2713 ref_node.raw_title,
2714 ref_path.display()
2715 );
2716 }
2717 }
2718 } else {
2719 let (incoming, outgoing) = cmd_relationships(&mm, id)?;
2721 if matches!(cli.output, OutputFormat::Json) {
2722 let obj = serde_json::json!({
2723 "command": "relationships",
2724 "node": id,
2725 "follow": false,
2726 "incoming": incoming,
2727 "outgoing": outgoing,
2728 "incoming_count": incoming.len(),
2729 "outgoing_count": outgoing.len(),
2730 });
2731 println!("{}", serde_json::to_string_pretty(&obj)?);
2732 } else {
2733 eprintln!("Relationships for [{}]:", id);
2734 eprintln!("← Incoming ({} nodes):", incoming.len());
2735 for incoming_id in &incoming {
2736 if let Some(node) = mm.get_node(*incoming_id) {
2737 eprintln!(" [{}] **{}**", incoming_id, node.raw_title);
2738 }
2739 }
2740 eprintln!("→ Outgoing ({} nodes):", outgoing.len());
2741 for outgoing_ref in &outgoing {
2742 if let Reference::Internal(outgoing_id) = outgoing_ref
2743 && let Some(node) = mm.get_node(*outgoing_id)
2744 {
2745 eprintln!(" [{}] **{}**", outgoing_id, node.raw_title);
2746 }
2747 }
2748 }
2749 }
2750 }
2751 Commands::Graph { id, follow } => {
2752 let dot = if follow {
2753 cmd_graph(&mm, id)?
2756 } else {
2757 cmd_graph(&mm, id)?
2759 };
2760 println!("{}", dot);
2761 }
2762 Commands::Prime => {
2763 use clap::CommandFactory;
2765 use std::path::Path;
2766
2767 let mut cmd = Cli::command();
2768 let mut buf: Vec<u8> = Vec::new();
2770 cmd.write_long_help(&mut buf)?;
2771 let help_str = String::from_utf8(buf)?;
2772
2773 let protocol_path = mm
2775 .path
2776 .parent()
2777 .map(|p| p.to_path_buf())
2778 .unwrap_or_else(|| PathBuf::from("."))
2779 .join("PROTOCOL_MINDMAP.md");
2780
2781 let protocol = if Path::new(&protocol_path).exists() {
2782 match fs::read_to_string(&protocol_path) {
2783 Ok(s) => Some(s),
2784 Err(e) => {
2785 eprintln!("Warning: failed to read {}: {}", protocol_path.display(), e);
2786 None
2787 }
2788 }
2789 } else {
2790 None
2791 };
2792
2793 let items = cmd_list(&mm, None, None, false, false, false);
2794
2795 if matches!(cli.output, OutputFormat::Json) {
2796 let arr: Vec<_> = items
2797 .into_iter()
2798 .map(|line| serde_json::json!({"line": line}))
2799 .collect();
2800 let mut obj =
2801 serde_json::json!({"command": "prime", "help": help_str, "items": arr});
2802 if let Some(proto) = protocol {
2803 obj["protocol"] = serde_json::json!(proto);
2804 }
2805 println!("{}", serde_json::to_string_pretty(&obj)?);
2806 } else {
2807 println!("{}", help_str);
2809
2810 if let Some(proto) = protocol {
2812 eprintln!("--- PROTOCOL_MINDMAP.md ---");
2813 println!("{}", proto);
2814 eprintln!("--- end protocol ---");
2815 }
2816
2817 if let Some(p) = &printer {
2819 p.list(&items)?;
2820 } else {
2821 for it in items {
2822 println!("{}", it);
2823 }
2824 }
2825 }
2826 }
2827 Commands::Batch {
2828 input,
2829 format,
2830 dry_run,
2831 fix,
2832 } => {
2833 if path.as_os_str() == "-" {
2835 return Err(anyhow::anyhow!(
2836 "Cannot batch: mindmap was loaded from stdin ('-'); use --file <path> to save changes"
2837 ));
2838 }
2839
2840 let base_content = fs::read_to_string(&path)
2842 .with_context(|| format!("Failed to read base file {}", path.display()))?;
2843 let base_hash = blake3_hash(base_content.as_bytes());
2844
2845 let mut buf = String::new();
2847 match input {
2848 Some(p) if p.as_os_str() == "-" => {
2849 std::io::stdin().read_to_string(&mut buf)?;
2850 }
2851 Some(p) => {
2852 buf = std::fs::read_to_string(p)?;
2853 }
2854 None => {
2855 std::io::stdin().read_to_string(&mut buf)?;
2856 }
2857 }
2858
2859 let mut ops: Vec<BatchOp> = Vec::new();
2861 if format == "json" {
2862 let arr = serde_json::from_str::<Vec<serde_json::Value>>(&buf)?;
2864 for (i, val) in arr.iter().enumerate() {
2865 match parse_batch_op_json(val) {
2866 Ok(op) => ops.push(op),
2867 Err(e) => {
2868 return Err(anyhow::anyhow!("Failed to parse batch op {}: {}", i, e));
2869 }
2870 }
2871 }
2872 } else {
2873 for (i, line) in buf.lines().enumerate() {
2875 let line = line.trim();
2876 if line.is_empty() || line.starts_with('#') {
2877 continue;
2878 }
2879 match parse_batch_op_line(line) {
2880 Ok(op) => ops.push(op),
2881 Err(e) => {
2882 return Err(anyhow::anyhow!(
2883 "Failed to parse batch line {}: {}",
2884 i + 1,
2885 e
2886 ));
2887 }
2888 }
2889 }
2890 }
2891
2892 let mut mm_clone = Mindmap::from_string(base_content.clone(), path.clone())?;
2894
2895 let mut result = BatchResult {
2897 total_ops: ops.len(),
2898 applied: 0,
2899 added_ids: Vec::new(),
2900 patched_ids: Vec::new(),
2901 deleted_ids: Vec::new(),
2902 warnings: Vec::new(),
2903 };
2904
2905 for (i, op) in ops.iter().enumerate() {
2906 match op {
2907 BatchOp::Add {
2908 type_prefix,
2909 title,
2910 desc,
2911 } => match cmd_add(&mut mm_clone, type_prefix, title, desc) {
2912 Ok(id) => {
2913 result.added_ids.push(id);
2914 result.applied += 1;
2915 }
2916 Err(e) => {
2917 return Err(anyhow::anyhow!("Op {}: add failed: {}", i, e));
2918 }
2919 },
2920 BatchOp::Patch {
2921 id,
2922 type_prefix,
2923 title,
2924 desc,
2925 } => {
2926 match cmd_patch(
2927 &mut mm_clone,
2928 *id,
2929 type_prefix.as_deref(),
2930 title.as_deref(),
2931 desc.as_deref(),
2932 false,
2933 ) {
2934 Ok(_) => {
2935 result.patched_ids.push(*id);
2936 result.applied += 1;
2937 }
2938 Err(e) => {
2939 return Err(anyhow::anyhow!("Op {}: patch failed: {}", i, e));
2940 }
2941 }
2942 }
2943 BatchOp::Put { id, line } => match cmd_put(&mut mm_clone, *id, line, false) {
2944 Ok(_) => {
2945 result.patched_ids.push(*id);
2946 result.applied += 1;
2947 }
2948 Err(e) => {
2949 return Err(anyhow::anyhow!("Op {}: put failed: {}", i, e));
2950 }
2951 },
2952 BatchOp::Delete { id, force } => match cmd_delete(&mut mm_clone, *id, *force) {
2953 Ok(_) => {
2954 result.deleted_ids.push(*id);
2955 result.applied += 1;
2956 }
2957 Err(e) => {
2958 return Err(anyhow::anyhow!("Op {}: delete failed: {}", i, e));
2959 }
2960 },
2961 BatchOp::Deprecate { id, to } => match cmd_deprecate(&mut mm_clone, *id, *to) {
2962 Ok(_) => {
2963 result.patched_ids.push(*id);
2964 result.applied += 1;
2965 }
2966 Err(e) => {
2967 return Err(anyhow::anyhow!("Op {}: deprecate failed: {}", i, e));
2968 }
2969 },
2970 BatchOp::Verify { id } => match cmd_verify(&mut mm_clone, *id) {
2971 Ok(_) => {
2972 result.patched_ids.push(*id);
2973 result.applied += 1;
2974 }
2975 Err(e) => {
2976 return Err(anyhow::anyhow!("Op {}: verify failed: {}", i, e));
2977 }
2978 },
2979 }
2980 }
2981
2982 if fix {
2984 match mm_clone.apply_fixes() {
2985 Ok(report) => {
2986 if !report.spacing.is_empty() {
2987 result.warnings.push(format!(
2988 "Auto-fixed: inserted {} spacing lines",
2989 report.spacing.len()
2990 ));
2991 }
2992 for tf in &report.title_fixes {
2993 result.warnings.push(format!(
2994 "Auto-fixed title for node {}: '{}' -> '{}'",
2995 tf.id, tf.old, tf.new
2996 ));
2997 }
2998 }
2999 Err(e) => {
3000 return Err(anyhow::anyhow!("Failed to apply fixes: {}", e));
3001 }
3002 }
3003 }
3004
3005 match cmd_lint(&mm_clone) {
3007 Ok(warnings) => {
3008 result.warnings.extend(warnings);
3009 }
3010 Err(e) => {
3011 return Err(anyhow::anyhow!("Lint check failed: {}", e));
3012 }
3013 }
3014
3015 if dry_run {
3016 if matches!(cli.output, OutputFormat::Json) {
3018 let obj = serde_json::json!({
3019 "command": "batch",
3020 "dry_run": true,
3021 "result": result,
3022 "content": mm_clone.lines.join("\n") + "\n"
3023 });
3024 println!("{}", serde_json::to_string_pretty(&obj)?);
3025 } else {
3026 eprintln!("--- DRY RUN: No changes written ---");
3027 eprintln!(
3028 "Would apply {} operations: {} added, {} patched, {} deleted",
3029 result.applied,
3030 result.added_ids.len(),
3031 result.patched_ids.len(),
3032 result.deleted_ids.len()
3033 );
3034 if !result.warnings.is_empty() {
3035 eprintln!("Warnings:");
3036 for w in &result.warnings {
3037 eprintln!(" {}", w);
3038 }
3039 }
3040 println!("{}", mm_clone.lines.join("\n"));
3041 }
3042 } else {
3043 let current_content = fs::read_to_string(&path).with_context(|| {
3045 format!("Failed to re-read file before commit {}", path.display())
3046 })?;
3047 let current_hash = blake3_hash(current_content.as_bytes());
3048
3049 if current_hash != base_hash {
3050 return Err(anyhow::anyhow!(
3051 "Cannot commit batch: target file changed since batch began (hash mismatch).\n\
3052 Base hash: {}\n\
3053 Current hash: {}\n\
3054 The file was likely modified by another process. \
3055 Re-run begin your batch on the current file.",
3056 base_hash,
3057 current_hash
3058 ));
3059 }
3060
3061 mm_clone.save()?;
3063
3064 if matches!(cli.output, OutputFormat::Json) {
3065 let obj = serde_json::json!({
3066 "command": "batch",
3067 "dry_run": false,
3068 "result": result
3069 });
3070 println!("{}", serde_json::to_string_pretty(&obj)?);
3071 } else {
3072 eprintln!("Batch applied successfully: {} ops applied", result.applied);
3073 if !result.added_ids.is_empty() {
3074 eprintln!(" Added nodes: {:?}", result.added_ids);
3075 }
3076 if !result.patched_ids.is_empty() {
3077 eprintln!(" Patched nodes: {:?}", result.patched_ids);
3078 }
3079 if !result.deleted_ids.is_empty() {
3080 eprintln!(" Deleted nodes: {:?}", result.deleted_ids);
3081 }
3082 if !result.warnings.is_empty() {
3083 eprintln!("Warnings:");
3084 for w in &result.warnings {
3085 eprintln!(" {}", w);
3086 }
3087 }
3088 }
3089 }
3090 }
3091 }
3092
3093 Ok(())
3094}
3095
3096#[derive(Debug, Clone, serde::Serialize, Default)]
3097pub struct FixReport {
3098 pub spacing: Vec<usize>,
3099 pub title_fixes: Vec<TitleFix>,
3100}
3101
3102#[derive(Debug, Clone, serde::Serialize)]
3103pub struct TitleFix {
3104 pub id: u32,
3105 pub old: String,
3106 pub new: String,
3107}
3108
3109impl FixReport {
3110 pub fn any_changes(&self) -> bool {
3111 !self.spacing.is_empty() || !self.title_fixes.is_empty()
3112 }
3113}
3114
3115#[cfg(test)]
3116mod tests {
3117 use super::*;
3118 use assert_fs::prelude::*;
3119
3120 #[test]
3121 fn test_parse_nodes() -> Result<()> {
3122 let temp = assert_fs::TempDir::new()?;
3123 let file = temp.child("MINDMAP.md");
3124 file.write_str(
3125 "Header line\n[1] **AE: A** - refers to [2]\nSome note\n[2] **AE: B** - base\n",
3126 )?;
3127
3128 let mm = Mindmap::load(file.path().to_path_buf())?;
3129 assert_eq!(mm.nodes.len(), 2);
3130 assert!(mm.by_id.contains_key(&1));
3131 assert!(mm.by_id.contains_key(&2));
3132 let n1 = mm.get_node(1).unwrap();
3133 assert_eq!(n1.references, vec![Reference::Internal(2)]);
3134 temp.close()?;
3135 Ok(())
3136 }
3137
3138 #[test]
3139 fn test_save_atomic() -> Result<()> {
3140 let temp = assert_fs::TempDir::new()?;
3141 let file = temp.child("MINDMAP.md");
3142 file.write_str("[1] **AE: A** - base\n")?;
3143
3144 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3145 let id = mm.next_id();
3147 mm.lines.push(format!("[{}] **AE: C** - new\n", id));
3148 let node = Node {
3150 id,
3151 raw_title: "AE: C".to_string(),
3152 description: "new".to_string(),
3153 references: vec![],
3154 line_index: mm.lines.len() - 1,
3155 };
3156 mm.by_id.insert(id, mm.nodes.len());
3157 mm.nodes.push(node);
3158
3159 mm.save()?;
3160
3161 let content = std::fs::read_to_string(file.path())?;
3162 assert!(content.contains("AE: C"));
3163 temp.close()?;
3164 Ok(())
3165 }
3166
3167 #[test]
3168 fn test_lint_syntax_and_duplicates_and_orphan() -> Result<()> {
3169 let temp = assert_fs::TempDir::new()?;
3170 let file = temp.child("MINDMAP.md");
3171 file.write_str("[bad] not a node\n[1] **AE: A** - base\n[1] **AE: Adup** - dup\n[2] **AE: Orphan** - lonely\n")?;
3172
3173 let mm = Mindmap::load(file.path().to_path_buf())?;
3174 let warnings = cmd_lint(&mm)?;
3175 let joined = warnings.join("\n");
3177 assert!(joined.contains("Syntax"));
3178 assert!(joined.contains("Duplicate ID"));
3179
3180 let orphans = cmd_orphans(&mm, false)?;
3182 let joined_o = orphans.join("\n");
3183 assert!(joined_o.contains("2"));
3185
3186 temp.close()?;
3187 Ok(())
3188 }
3189
3190 #[test]
3191 fn test_put_and_patch_basic() -> Result<()> {
3192 let temp = assert_fs::TempDir::new()?;
3193 let file = temp.child("MINDMAP.md");
3194 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
3195
3196 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3197 cmd_patch(&mut mm, 1, Some("AE"), Some("OneNew"), None, false)?;
3199 assert_eq!(mm.get_node(1).unwrap().raw_title, "AE: OneNew");
3200
3201 let new_line = "[2] **DR: Replaced** - replaced desc [1]";
3203 cmd_put(&mut mm, 2, new_line, false)?;
3204 assert_eq!(mm.get_node(2).unwrap().raw_title, "DR: Replaced");
3205 assert_eq!(
3206 mm.get_node(2).unwrap().references,
3207 vec![Reference::Internal(1)]
3208 );
3209
3210 temp.close()?;
3211 Ok(())
3212 }
3213
3214 #[test]
3215 fn test_cmd_show() -> Result<()> {
3216 let temp = assert_fs::TempDir::new()?;
3217 let file = temp.child("MINDMAP.md");
3218 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
3219 let mm = Mindmap::load(file.path().to_path_buf())?;
3220 let out = cmd_show(&mm, 1);
3221 assert!(out.contains("[1] **AE: One**"));
3222 assert!(out.contains("Referred to by: [2]"));
3223 temp.close()?;
3224 Ok(())
3225 }
3226
3227 #[test]
3228 fn test_cmd_refs() -> Result<()> {
3229 let temp = assert_fs::TempDir::new()?;
3230 let file = temp.child("MINDMAP.md");
3231 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
3232 let mm = Mindmap::load(file.path().to_path_buf())?;
3233 let refs = cmd_refs(&mm, 1);
3234 assert_eq!(refs.len(), 1);
3235 assert!(refs[0].contains("[2] **AE: Two**"));
3236 temp.close()?;
3237 Ok(())
3238 }
3239
3240 #[test]
3241 fn test_cmd_links() -> Result<()> {
3242 let temp = assert_fs::TempDir::new()?;
3243 let file = temp.child("MINDMAP.md");
3244 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
3245 let mm = Mindmap::load(file.path().to_path_buf())?;
3246 let links = cmd_links(&mm, 2);
3247 assert_eq!(links, Some(vec![Reference::Internal(1)]));
3248 temp.close()?;
3249 Ok(())
3250 }
3251
3252 #[test]
3253 fn test_cmd_search() -> Result<()> {
3254 let temp = assert_fs::TempDir::new()?;
3255 let file = temp.child("MINDMAP.md");
3256 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
3257 let mm = Mindmap::load(file.path().to_path_buf())?;
3258 let results = cmd_list(&mm, None, Some("first"), false, false, false);
3260 assert_eq!(results.len(), 1);
3261 assert!(results[0].contains("[1] **AE: One**"));
3262 temp.close()?;
3263 Ok(())
3264 }
3265
3266 #[test]
3267 fn test_search_list_grep_equivalence() -> Result<()> {
3268 let temp = assert_fs::TempDir::new()?;
3270 let file = temp.child("MINDMAP.md");
3271 file.write_str("[1] **AE: One** - first node\n[2] **WF: Two** - second node\n[3] **DR: Three** - third\n")?;
3272 let mm = Mindmap::load(file.path().to_path_buf())?;
3273
3274 let search_results = cmd_list(&mm, None, Some("node"), false, false, false);
3276 let list_grep_results = cmd_list(&mm, None, Some("node"), false, false, false);
3277 assert_eq!(search_results, list_grep_results);
3278 assert_eq!(search_results.len(), 2);
3279
3280 temp.close()?;
3281 Ok(())
3282 }
3283
3284 #[test]
3285 fn test_cmd_add() -> Result<()> {
3286 let temp = assert_fs::TempDir::new()?;
3287 let file = temp.child("MINDMAP.md");
3288 file.write_str("[1] **AE: One** - first\n")?;
3289 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3290 let id = cmd_add(&mut mm, "AE", "Two", "second")?;
3291 assert_eq!(id, 2);
3292 assert_eq!(mm.nodes.len(), 2);
3293 let node = mm.get_node(2).unwrap();
3294 assert_eq!(node.raw_title, "AE: Two");
3295 temp.close()?;
3296 Ok(())
3297 }
3298
3299 #[test]
3300 fn test_cmd_deprecate() -> Result<()> {
3301 let temp = assert_fs::TempDir::new()?;
3302 let file = temp.child("MINDMAP.md");
3303 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
3304 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3305 cmd_deprecate(&mut mm, 1, 2)?;
3306 let node = mm.get_node(1).unwrap();
3307 assert!(node.raw_title.starts_with("[DEPRECATED → 2]"));
3308 temp.close()?;
3309 Ok(())
3310 }
3311
3312 #[test]
3313 fn test_cmd_verify() -> Result<()> {
3314 let temp = assert_fs::TempDir::new()?;
3315 let file = temp.child("MINDMAP.md");
3316 file.write_str("[1] **AE: One** - first\n")?;
3317 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3318 cmd_verify(&mut mm, 1)?;
3319 let node = mm.get_node(1).unwrap();
3320 assert!(node.description.contains("(verify"));
3321 temp.close()?;
3322 Ok(())
3323 }
3324
3325 #[test]
3326 fn test_cmd_show_non_existing() -> Result<()> {
3327 let temp = assert_fs::TempDir::new()?;
3328 let file = temp.child("MINDMAP.md");
3329 file.write_str("[1] **AE: One** - first\n")?;
3330 let mm = Mindmap::load(file.path().to_path_buf())?;
3331 let out = cmd_show(&mm, 99);
3332 assert_eq!(out, "Node [99] not found");
3333 temp.close()?;
3334 Ok(())
3335 }
3336
3337 #[test]
3338 fn test_cmd_refs_non_existing() -> Result<()> {
3339 let temp = assert_fs::TempDir::new()?;
3340 let file = temp.child("MINDMAP.md");
3341 file.write_str("[1] **AE: One** - first\n")?;
3342 let mm = Mindmap::load(file.path().to_path_buf())?;
3343 let refs = cmd_refs(&mm, 99);
3344 assert_eq!(refs.len(), 0);
3345 temp.close()?;
3346 Ok(())
3347 }
3348
3349 #[test]
3350 fn test_cmd_links_non_existing() -> Result<()> {
3351 let temp = assert_fs::TempDir::new()?;
3352 let file = temp.child("MINDMAP.md");
3353 file.write_str("[1] **AE: One** - first\n")?;
3354 let mm = Mindmap::load(file.path().to_path_buf())?;
3355 let links = cmd_links(&mm, 99);
3356 assert_eq!(links, None);
3357 temp.close()?;
3358 Ok(())
3359 }
3360
3361 #[test]
3362 fn test_cmd_put_non_existing() -> Result<()> {
3363 let temp = assert_fs::TempDir::new()?;
3364 let file = temp.child("MINDMAP.md");
3365 file.write_str("[1] **AE: One** - first\n")?;
3366 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3367 let err = cmd_put(&mut mm, 99, "[99] **AE: New** - new", false).unwrap_err();
3368 assert!(format!("{}", err).contains("Node [99] not found"));
3369 temp.close()?;
3370 Ok(())
3371 }
3372
3373 #[test]
3374 fn test_cmd_patch_non_existing() -> Result<()> {
3375 let temp = assert_fs::TempDir::new()?;
3376 let file = temp.child("MINDMAP.md");
3377 file.write_str("[1] **AE: One** - first\n")?;
3378 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3379 let err = cmd_patch(&mut mm, 99, None, Some("New"), None, false).unwrap_err();
3380 assert!(format!("{}", err).contains("Node [99] not found"));
3381 temp.close()?;
3382 Ok(())
3383 }
3384
3385 #[test]
3386 fn test_load_from_reader() -> Result<()> {
3387 use std::io::Cursor;
3388 let content = "[1] **AE: One** - first\n";
3389 let reader = Cursor::new(content);
3390 let path = PathBuf::from("-");
3391 let mm = Mindmap::load_from_reader(reader, path)?;
3392 assert_eq!(mm.nodes.len(), 1);
3393 assert_eq!(mm.nodes[0].id, 1);
3394 Ok(())
3395 }
3396
3397 #[test]
3398 fn test_next_id() -> Result<()> {
3399 let temp = assert_fs::TempDir::new()?;
3400 let file = temp.child("MINDMAP.md");
3401 file.write_str("[1] **AE: One** - first\n[3] **AE: Three** - third\n")?;
3402 let mm = Mindmap::load(file.path().to_path_buf())?;
3403 assert_eq!(mm.next_id(), 4);
3404 temp.close()?;
3405 Ok(())
3406 }
3407
3408 #[test]
3409 fn test_get_node() -> Result<()> {
3410 let temp = assert_fs::TempDir::new()?;
3411 let file = temp.child("MINDMAP.md");
3412 file.write_str("[1] **AE: One** - first\n")?;
3413 let mm = Mindmap::load(file.path().to_path_buf())?;
3414 let node = mm.get_node(1).unwrap();
3415 assert_eq!(node.id, 1);
3416 assert!(mm.get_node(99).is_none());
3417 temp.close()?;
3418 Ok(())
3419 }
3420
3421 #[test]
3422 fn test_cmd_orphans() -> Result<()> {
3423 let temp = assert_fs::TempDir::new()?;
3424 let file = temp.child("MINDMAP.md");
3425 file.write_str("[1] **AE: One** - first\n[2] **AE: Orphan** - lonely\n")?;
3426 let mm = Mindmap::load(file.path().to_path_buf())?;
3427 let orphans = cmd_orphans(&mm, false)?;
3428 assert_eq!(orphans, vec!["1".to_string(), "2".to_string()]);
3429 temp.close()?;
3430 Ok(())
3431 }
3432
3433 #[test]
3434 fn test_cmd_graph() -> Result<()> {
3435 let temp = assert_fs::TempDir::new()?;
3436 let file = temp.child("MINDMAP.md");
3437 file.write_str(
3438 "[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n[3] **AE: Three** - also [1]\n",
3439 )?;
3440 let mm = Mindmap::load(file.path().to_path_buf())?;
3441 let dot = cmd_graph(&mm, 1)?;
3442 assert!(dot.contains("digraph {"));
3443 assert!(dot.contains("1 [label=\"1: AE: One\"]"));
3444 assert!(dot.contains("2 [label=\"2: AE: Two\"]"));
3445 assert!(dot.contains("3 [label=\"3: AE: Three\"]"));
3446 assert!(dot.contains("2 -> 1;"));
3447 assert!(dot.contains("3 -> 1;"));
3448 temp.close()?;
3449 Ok(())
3450 }
3451
3452 #[test]
3453 fn test_save_stdin_path() -> Result<()> {
3454 let temp = assert_fs::TempDir::new()?;
3455 let file = temp.child("MINDMAP.md");
3456 file.write_str("[1] **AE: One** - first\n")?;
3457 let mut mm = Mindmap::load_from_reader(
3458 std::io::Cursor::new("[1] **AE: One** - first\n"),
3459 PathBuf::from("-"),
3460 )?;
3461 let err = mm.save().unwrap_err();
3462 assert!(format!("{}", err).contains("Cannot save"));
3463 temp.close()?;
3464 Ok(())
3465 }
3466
3467 #[test]
3468 fn test_extract_refs_from_str() {
3469 assert_eq!(
3470 extract_refs_from_str("no refs", None),
3471 vec![] as Vec<Reference>
3472 );
3473 assert_eq!(
3474 extract_refs_from_str("[1] and [2]", None),
3475 vec![Reference::Internal(1), Reference::Internal(2)]
3476 );
3477 assert_eq!(
3478 extract_refs_from_str("[1] and [1]", Some(1)),
3479 vec![] as Vec<Reference>
3480 ); assert_eq!(
3482 extract_refs_from_str("[abc] invalid [123]", None),
3483 vec![Reference::Internal(123)]
3484 );
3485 assert_eq!(
3486 extract_refs_from_str("[234](./file.md)", None),
3487 vec![Reference::External(234, "./file.md".to_string())]
3488 );
3489 }
3490
3491 #[test]
3492 fn test_normalize_adjacent_nodes() -> Result<()> {
3493 let temp = assert_fs::TempDir::new()?;
3494 let file = temp.child("MINDMAP.md");
3495 file.write_str("[1] **AE: A** - a\n[2] **AE: B** - b\n")?;
3496
3497 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3498 mm.save()?;
3499
3500 let content = std::fs::read_to_string(file.path())?;
3501 assert_eq!(content, "[1] **AE: A** - a\n\n[2] **AE: B** - b\n");
3502 assert_eq!(mm.get_node(2).unwrap().line_index, 2);
3504 temp.close()?;
3505 Ok(())
3506 }
3507
3508 #[test]
3509 fn test_normalize_idempotent() -> Result<()> {
3510 let temp = assert_fs::TempDir::new()?;
3511 let file = temp.child("MINDMAP.md");
3512 file.write_str("[1] **AE: A** - a\n[2] **AE: B** - b\n")?;
3513
3514 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3515 mm.normalize_spacing()?;
3516 let snapshot = mm.lines.clone();
3517 mm.normalize_spacing()?;
3518 assert_eq!(mm.lines, snapshot);
3519 temp.close()?;
3520 Ok(())
3521 }
3522
3523 #[test]
3524 fn test_preserve_non_node_lines() -> Result<()> {
3525 let temp = assert_fs::TempDir::new()?;
3526 let file = temp.child("MINDMAP.md");
3527 file.write_str("[1] **AE: A** - a\nHeader line\n[2] **AE: B** - b\n")?;
3528
3529 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3530 mm.save()?;
3531
3532 let content = std::fs::read_to_string(file.path())?;
3533 assert_eq!(
3535 content,
3536 "[1] **AE: A** - a\nHeader line\n[2] **AE: B** - b\n"
3537 );
3538 temp.close()?;
3539 Ok(())
3540 }
3541
3542 #[test]
3543 fn test_lint_fix_spacing() -> Result<()> {
3544 let temp = assert_fs::TempDir::new()?;
3545 let file = temp.child("MINDMAP.md");
3546 file.write_str("[1] **AE: A** - a\n[2] **AE: B** - b\n")?;
3547
3548 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3549 let report = mm.apply_fixes()?;
3550 assert!(!report.spacing.is_empty());
3551 assert_eq!(report.title_fixes.len(), 0);
3552 mm.save()?;
3553
3554 let content = std::fs::read_to_string(file.path())?;
3555 assert_eq!(content, "[1] **AE: A** - a\n\n[2] **AE: B** - b\n");
3556 temp.close()?;
3557 Ok(())
3558 }
3559
3560 #[test]
3561 fn test_lint_fix_duplicated_type() -> Result<()> {
3562 let temp = assert_fs::TempDir::new()?;
3563 let file = temp.child("MINDMAP.md");
3564 file.write_str("[1] **AE: AE: Auth** - desc\n")?;
3565
3566 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3567 let report = mm.apply_fixes()?;
3568 assert_eq!(report.title_fixes.len(), 1);
3569 assert_eq!(report.title_fixes[0].new, "AE: Auth");
3570 mm.save()?;
3571
3572 let content = std::fs::read_to_string(file.path())?;
3573 assert!(content.contains("[1] **AE: Auth** - desc"));
3574 temp.close()?;
3575 Ok(())
3576 }
3577
3578 #[test]
3579 fn test_lint_fix_combined() -> Result<()> {
3580 let temp = assert_fs::TempDir::new()?;
3581 let file = temp.child("MINDMAP.md");
3582 file.write_str("[1] **WF: WF: Workflow** - first\n[2] **AE: Auth** - second\n")?;
3583
3584 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3585 let report = mm.apply_fixes()?;
3586 assert!(!report.spacing.is_empty());
3587 assert_eq!(report.title_fixes.len(), 1);
3588 assert_eq!(report.title_fixes[0].id, 1);
3589 assert_eq!(report.title_fixes[0].new, "WF: Workflow");
3590 mm.save()?;
3591
3592 let content = std::fs::read_to_string(file.path())?;
3593 assert!(content.contains("[1] **WF: Workflow** - first"));
3594 assert!(content.contains("\n\n[2] **AE: Auth** - second"));
3595 temp.close()?;
3596 Ok(())
3597 }
3598
3599 #[test]
3600 fn test_lint_fix_idempotent() -> Result<()> {
3601 let temp = assert_fs::TempDir::new()?;
3602 let file = temp.child("MINDMAP.md");
3603 file.write_str("[1] **AE: AE: A** - a\n[2] **AE: B** - b\n")?;
3604
3605 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3606 let report1 = mm.apply_fixes()?;
3607 assert!(report1.any_changes());
3608
3609 let report2 = mm.apply_fixes()?;
3611 assert!(!report2.any_changes());
3612 temp.close()?;
3613 Ok(())
3614 }
3615
3616 #[test]
3617 fn test_lint_fix_collapse_multiple_blanks() -> Result<()> {
3618 let temp = assert_fs::TempDir::new()?;
3619 let file = temp.child("MINDMAP.md");
3620 file.write_str("[1] **AE: A** - a\n\n\n[2] **AE: B** - b\n")?;
3621
3622 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3623 let report = mm.apply_fixes()?;
3624 assert!(!report.spacing.is_empty());
3625 mm.save()?;
3626
3627 let content = std::fs::read_to_string(file.path())?;
3628 assert_eq!(content, "[1] **AE: A** - a\n\n[2] **AE: B** - b\n");
3630 temp.close()?;
3631 Ok(())
3632 }
3633
3634 #[test]
3635 fn test_batch_op_parse_line_add() -> Result<()> {
3636 let line = "add --type WF --title Test --desc desc";
3637 let op = parse_batch_op_line(line)?;
3638 match op {
3639 BatchOp::Add {
3640 type_prefix,
3641 title,
3642 desc,
3643 } => {
3644 assert_eq!(type_prefix, "WF");
3645 assert_eq!(title, "Test");
3646 assert_eq!(desc, "desc");
3647 }
3648 _ => panic!("Expected Add op"),
3649 }
3650 Ok(())
3651 }
3652
3653 #[test]
3654 fn test_batch_op_parse_line_patch() -> Result<()> {
3655 let line = "patch 1 --title NewTitle";
3656 let op = parse_batch_op_line(line)?;
3657 match op {
3658 BatchOp::Patch {
3659 id,
3660 title,
3661 type_prefix,
3662 desc,
3663 } => {
3664 assert_eq!(id, 1);
3665 assert_eq!(title, Some("NewTitle".to_string()));
3666 assert_eq!(type_prefix, None);
3667 assert_eq!(desc, None);
3668 }
3669 _ => panic!("Expected Patch op"),
3670 }
3671 Ok(())
3672 }
3673
3674 #[test]
3675 fn test_batch_op_parse_line_delete() -> Result<()> {
3676 let line = "delete 5 --force";
3677 let op = parse_batch_op_line(line)?;
3678 match op {
3679 BatchOp::Delete { id, force } => {
3680 assert_eq!(id, 5);
3681 assert!(force);
3682 }
3683 _ => panic!("Expected Delete op"),
3684 }
3685 Ok(())
3686 }
3687
3688 #[test]
3689 fn test_batch_hash_concurrency_check() -> Result<()> {
3690 let content1 = "hello world";
3692 let content2 = "hello world";
3693 let content3 = "hello world!";
3694
3695 let hash1 = blake3_hash(content1.as_bytes());
3696 let hash2 = blake3_hash(content2.as_bytes());
3697 let hash3 = blake3_hash(content3.as_bytes());
3698
3699 assert_eq!(hash1, hash2); assert_ne!(hash1, hash3); Ok(())
3702 }
3703
3704 #[test]
3705 fn test_batch_simple_add() -> Result<()> {
3706 let temp = assert_fs::TempDir::new()?;
3707 let file = temp.child("MINDMAP.md");
3708 file.write_str("[1] **AE: A** - a\n")?;
3709
3710 let batch_input = r#"add --type WF --title Work --desc "do work""#;
3712 let ops = vec![parse_batch_op_line(batch_input)?];
3713
3714 let mut mm = Mindmap::load(file.path().to_path_buf())?;
3715 for op in ops {
3716 match op {
3717 BatchOp::Add {
3718 type_prefix,
3719 title,
3720 desc,
3721 } => {
3722 cmd_add(&mut mm, &type_prefix, &title, &desc)?;
3723 }
3724 _ => {}
3725 }
3726 }
3727 mm.save()?;
3728
3729 let content = std::fs::read_to_string(file.path())?;
3730 assert!(content.contains("WF: Work") && content.contains("do work"));
3731 temp.close()?;
3732 Ok(())
3733 }
3734}