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