1use anyhow::{Context, Result};
2use clap::{Parser, Subcommand};
3use std::{collections::HashMap, fs, io::Read, path::PathBuf};
4
5mod ui;
6
7#[derive(clap::ValueEnum, Clone)]
8pub enum OutputFormat {
9 Default,
10 Json,
11}
12
13#[derive(Parser)]
14#[command(name = "mindmap")]
15#[command(about = "CLI tool for working with MINDMAP files")]
16#[command(
17 long_about = r#"mindmap-cli — small CLI for inspecting and safely editing one-line MINDMAP files (default: ./MINDMAP.md).
18One-node-per-line format: [N] **Title** - description with [N] references. IDs must be stable numeric values.
19
20EXAMPLES:
21 mindmap-cli show 10
22 mindmap-cli list --type AE --grep auth
23 mindmap-cli add --type AE --title "AuthService" --desc "Handles auth [12]"
24 mindmap-cli edit 12 # opens $EDITOR for an atomic, validated edit
25 mindmap-cli patch 12 --title "AuthSvc" --desc "Updated desc" # partial update (PATCH)
26 mindmap-cli put 12 --line "[31] **WF: Example** - Full line text [12]" # full-line replace (PUT)
27 mindmap-cli graph 10 | dot -Tpng > graph.png # generate neighborhood graph
28 mindmap-cli lint
29 mindmap-cli batch --input - --dry-run <<EOF # atomic batch from stdin
30 add --type WF --title "New Workflow" --desc "Steps here"
31 patch 15 --title "Updated Workflow"
32 delete 19
33 EOF
34
35Notes:
36 - Default file: ./MINDMAP.md (override with --file)
37 - 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 `-`.
38 - Use the EDITOR env var to control the editor used by 'edit'
39"#
40)]
41pub struct Cli {
42 #[arg(global = true, short, long)]
44 pub file: Option<PathBuf>,
45
46 #[arg(global = true, long, value_enum, default_value_t = OutputFormat::Default)]
48 pub output: OutputFormat,
49
50 #[command(subcommand)]
51 pub command: Commands,
52}
53
54#[derive(Subcommand)]
55pub enum Commands {
56 Show { id: u32 },
58
59 List {
61 #[arg(long)]
62 r#type: Option<String>,
63 #[arg(long)]
64 grep: Option<String>,
65 },
66
67 Refs { id: u32 },
69
70 Links { id: u32 },
72
73 Search { query: String },
75
76 Add {
78 #[arg(long)]
79 r#type: Option<String>,
80 #[arg(long)]
81 title: Option<String>,
82 #[arg(long)]
83 desc: Option<String>,
84 #[arg(long)]
86 strict: bool,
87 },
88
89 Deprecate {
91 id: u32,
92 #[arg(long)]
93 to: u32,
94 },
95
96 Edit { id: u32 },
98
99 Patch {
101 id: u32,
102 #[arg(long)]
103 r#type: Option<String>,
104 #[arg(long)]
105 title: Option<String>,
106 #[arg(long)]
107 desc: Option<String>,
108 #[arg(long)]
109 strict: bool,
110 },
111
112 Put {
114 id: u32,
115 #[arg(long)]
116 line: String,
117 #[arg(long)]
118 strict: bool,
119 },
120
121 Verify { id: u32 },
123
124 Delete {
126 id: u32,
127 #[arg(long)]
128 force: bool,
129 },
130
131 Lint {
133 #[arg(long)]
135 fix: bool,
136 },
137
138 Orphans,
140
141 Graph { id: u32 },
143
144 Prime,
146
147 Batch {
149 #[arg(long)]
151 input: Option<PathBuf>,
152 #[arg(long, default_value = "lines")]
154 format: String,
155 #[arg(long)]
157 dry_run: bool,
158 #[arg(long)]
160 fix: bool,
161 },
162}
163
164#[derive(Debug, Clone)]
165pub struct Node {
166 pub id: u32,
167 pub raw_title: String,
168 pub description: String,
169 pub references: Vec<Reference>,
170 pub line_index: usize,
171}
172
173#[derive(Debug, Clone, PartialEq, serde::Serialize)]
174pub enum Reference {
175 Internal(u32),
176 External(u32, String),
177}
178
179pub struct Mindmap {
180 pub path: PathBuf,
181 pub lines: Vec<String>,
182 pub nodes: Vec<Node>,
183 pub by_id: HashMap<u32, usize>,
184}
185
186impl Mindmap {
187 pub fn load(path: PathBuf) -> Result<Self> {
188 let content = fs::read_to_string(&path)
190 .with_context(|| format!("Failed to read file {}", path.display()))?;
191 Self::from_string(content, path)
192 }
193
194 pub fn load_from_reader<R: Read>(mut reader: R, path: PathBuf) -> Result<Self> {
197 let mut content = String::new();
198 reader.read_to_string(&mut content)?;
199 Self::from_string(content, path)
200 }
201
202 fn from_string(content: String, path: PathBuf) -> Result<Self> {
203 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
204
205 let mut nodes = Vec::new();
206 let mut by_id = HashMap::new();
207
208 for (i, line) in lines.iter().enumerate() {
209 if let Ok(node) = parse_node_line(line, i) {
210 if by_id.contains_key(&node.id) {
211 eprintln!("Warning: duplicate node id {} at line {}", node.id, i + 1);
212 }
213 by_id.insert(node.id, nodes.len());
214 nodes.push(node);
215 }
216 }
217
218 Ok(Mindmap {
219 path,
220 lines,
221 nodes,
222 by_id,
223 })
224 }
225
226 pub fn save(&mut self) -> Result<()> {
227 if self.path.as_os_str() == "-" {
229 return Err(anyhow::anyhow!(
230 "Cannot save: mindmap was loaded from stdin ('-'); use --file <path> to save changes"
231 ));
232 }
233
234 self.normalize_spacing()?;
237
238 let dir = self
240 .path
241 .parent()
242 .map(|p| p.to_path_buf())
243 .unwrap_or_else(|| PathBuf::from("."));
244 let mut tmp = tempfile::NamedTempFile::new_in(&dir)
245 .with_context(|| format!("Failed to create temp file in {}", dir.display()))?;
246 let content = self.lines.join("\n") + "\n";
247 use std::io::Write;
248 tmp.write_all(content.as_bytes())?;
249 tmp.flush()?;
250 tmp.persist(&self.path)
251 .with_context(|| format!("Failed to persist temp file to {}", self.path.display()))?;
252 Ok(())
253 }
254
255 pub fn next_id(&self) -> u32 {
256 self.by_id.keys().max().copied().unwrap_or(0) + 1
257 }
258
259 pub fn get_node(&self, id: u32) -> Option<&Node> {
260 self.by_id.get(&id).map(|&idx| &self.nodes[idx])
261 }
262
263 pub fn normalize_spacing(&mut self) -> Result<()> {
267 if self.lines.is_empty() {
269 return Ok(());
270 }
271
272 let orig = self.lines.clone();
273 let mut new_lines: Vec<String> = Vec::new();
274
275 for i in 0..orig.len() {
276 let line = orig[i].clone();
277 new_lines.push(line.clone());
278
279 if parse_node_line(&line, i).is_ok()
283 && i + 1 < orig.len()
284 && parse_node_line(&orig[i + 1], i + 1).is_ok()
285 {
286 new_lines.push(String::new());
287 }
288 }
289
290 if new_lines == orig {
292 return Ok(());
293 }
294
295 let content = new_lines.join("\n") + "\n";
297 let normalized_mm = Mindmap::from_string(content, self.path.clone())?;
298 self.lines = normalized_mm.lines;
299 self.nodes = normalized_mm.nodes;
300 self.by_id = normalized_mm.by_id;
301
302 Ok(())
303 }
304
305 pub fn apply_fixes(&mut self) -> Result<FixReport> {
308 let mut report = FixReport::default();
309
310 if self.lines.is_empty() {
312 return Ok(report);
313 }
314
315 let orig = self.lines.clone();
316 let mut new_lines: Vec<String> = Vec::new();
317 let mut i = 0usize;
318 while i < orig.len() {
319 let line = orig[i].clone();
320 new_lines.push(line.clone());
321
322 if parse_node_line(&line, i).is_ok() {
324 let mut j = i + 1;
325 while j < orig.len() && orig[j].trim().is_empty() {
327 j += 1;
328 }
329
330 if j < orig.len() && parse_node_line(&orig[j], j).is_ok() {
332 if j == i + 1 {
333 new_lines.push(String::new());
335 report.spacing.push(i + 1);
336 } else if j > i + 2 {
337 new_lines.push(String::new());
339 report.spacing.push(i + 1);
340 }
341 i = j;
342 continue;
343 }
344 }
345 i += 1;
346 }
347
348 if !report.spacing.is_empty() {
350 let content = new_lines.join("\n") + "\n";
351 let normalized_mm = Mindmap::from_string(content, self.path.clone())?;
352 self.lines = normalized_mm.lines;
353 self.nodes = normalized_mm.nodes;
354 self.by_id = normalized_mm.by_id;
355 }
356
357 let mut changed = false;
359 let mut new_lines = self.lines.clone();
360 for node in &self.nodes {
361 if let Some(colon_pos) = node.raw_title.find(':') {
362 let leading_type = node.raw_title[..colon_pos].trim();
363 let after_colon = node.raw_title[colon_pos + 1..].trim_start();
364
365 if after_colon.starts_with(&format!("{}:", leading_type)) {
367 let after_dup = after_colon[leading_type.len() + 1..].trim_start();
369 let new_raw = if after_dup.is_empty() {
370 leading_type.to_string()
371 } else {
372 format!("{}: {}", leading_type, after_dup)
373 };
374
375 report.title_fixes.push(TitleFix {
376 id: node.id,
377 old: node.raw_title.clone(),
378 new: new_raw.clone(),
379 });
380
381 new_lines[node.line_index] =
383 format!("[{}] **{}** - {}", node.id, new_raw, node.description);
384 changed = true;
385 }
386 }
387 }
388
389 if changed {
390 let content = new_lines.join("\n") + "\n";
391 let normalized_mm = Mindmap::from_string(content, self.path.clone())?;
392 self.lines = normalized_mm.lines;
393 self.nodes = normalized_mm.nodes;
394 self.by_id = normalized_mm.by_id;
395 }
396
397 Ok(report)
398 }
399}
400
401pub fn parse_node_line(line: &str, line_index: usize) -> Result<Node> {
404 let trimmed = line.trim_start();
406 if !trimmed.starts_with('[') {
407 return Err(anyhow::anyhow!("Line does not match node format"));
408 }
409
410 let end_bracket = match trimmed.find(']') {
412 Some(pos) => pos,
413 None => return Err(anyhow::anyhow!("Line does not match node format")),
414 };
415
416 let id_str = &trimmed[1..end_bracket];
417 let id: u32 = id_str.parse()?;
418
419 let mut pos = end_bracket + 1;
421 let chars = trimmed.as_bytes();
422 if chars.get(pos).map(|b| *b as char) == Some(' ') {
423 pos += 1;
424 } else {
425 return Err(anyhow::anyhow!("Line does not match node format"));
426 }
427
428 if trimmed.get(pos..pos + 2) != Some("**") {
430 return Err(anyhow::anyhow!("Line does not match node format"));
431 }
432 pos += 2;
433
434 let rem = &trimmed[pos..];
436 let title_rel_end = match rem.find("**") {
437 Some(p) => p,
438 None => return Err(anyhow::anyhow!("Line does not match node format")),
439 };
440 let title = rem[..title_rel_end].to_string();
441 pos += title_rel_end + 2; if trimmed.get(pos..pos + 3) != Some(" - ") {
445 return Err(anyhow::anyhow!("Line does not match node format"));
446 }
447 pos += 3;
448
449 let description = trimmed[pos..].to_string();
450
451 let references = extract_refs_from_str(&description, Some(id));
453
454 Ok(Node {
455 id,
456 raw_title: title,
457 description,
458 references,
459 line_index,
460 })
461}
462
463fn extract_refs_from_str(s: &str, skip_self: Option<u32>) -> Vec<Reference> {
466 let mut refs = Vec::new();
467 let mut i = 0usize;
468 while i < s.len() {
469 if let Some(rel) = s[i..].find('[') {
471 let start = i + rel;
472 if let Some(rel_end) = s[start..].find(']') {
473 let end = start + rel_end;
474 let idslice = &s[start + 1..end];
475 if !idslice.is_empty()
476 && idslice.chars().all(|c| c.is_ascii_digit())
477 && let Ok(rid) = idslice.parse::<u32>()
478 && Some(rid) != skip_self
479 {
480 let after = &s[end..];
482 if after.starts_with("](") {
483 if let Some(paren_end) = after.find(')') {
485 let path_start = end + 2; let path_end = end + paren_end;
487 let path = &s[path_start..path_end];
488 refs.push(Reference::External(rid, path.to_string()));
489 i = path_end + 1;
490 continue;
491 }
492 }
493 refs.push(Reference::Internal(rid));
495 }
496 i = end + 1;
497 continue;
498 } else {
499 break; }
501 } else {
502 break;
503 }
504 }
505 refs
506}
507
508pub fn cmd_show(mm: &Mindmap, id: u32) -> String {
511 if let Some(node) = mm.get_node(id) {
512 let mut out = format!(
513 "[{}] **{}** - {}",
514 node.id, node.raw_title, node.description
515 );
516
517 let mut inbound = Vec::new();
519 for n in &mm.nodes {
520 if n.references
521 .iter()
522 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
523 {
524 inbound.push(n.id);
525 }
526 }
527 if !inbound.is_empty() {
528 out.push_str(&format!("\nReferred to by: {:?}", inbound));
529 }
530 out
531 } else {
532 format!("Node {} not found", id)
533 }
534}
535
536pub fn cmd_list(mm: &Mindmap, type_filter: Option<&str>, grep: Option<&str>) -> Vec<String> {
537 let mut res = Vec::new();
538 for n in &mm.nodes {
539 if let Some(tf) = type_filter
540 && !n.raw_title.starts_with(&format!("{}:", tf))
541 {
542 continue;
543 }
544 if let Some(q) = grep {
545 let qlc = q.to_lowercase();
546 if !n.raw_title.to_lowercase().contains(&qlc)
547 && !n.description.to_lowercase().contains(&qlc)
548 {
549 continue;
550 }
551 }
552 res.push(format!(
553 "[{}] **{}** - {}",
554 n.id, n.raw_title, n.description
555 ));
556 }
557 res
558}
559
560pub fn cmd_refs(mm: &Mindmap, id: u32) -> Vec<String> {
561 let mut out = Vec::new();
562 for n in &mm.nodes {
563 if n.references
564 .iter()
565 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
566 {
567 out.push(format!(
568 "[{}] **{}** - {}",
569 n.id, n.raw_title, n.description
570 ));
571 }
572 }
573 out
574}
575
576pub fn cmd_links(mm: &Mindmap, id: u32) -> Option<Vec<Reference>> {
577 mm.get_node(id).map(|n| n.references.clone())
578}
579
580pub fn cmd_search(mm: &Mindmap, query: &str) -> Vec<String> {
581 let qlc = query.to_lowercase();
582 let mut out = Vec::new();
583 for n in &mm.nodes {
584 if n.raw_title.to_lowercase().contains(&qlc) || n.description.to_lowercase().contains(&qlc)
585 {
586 out.push(format!(
587 "[{}] **{}** - {}",
588 n.id, n.raw_title, n.description
589 ));
590 }
591 }
592 out
593}
594
595pub fn cmd_add(mm: &mut Mindmap, type_prefix: &str, title: &str, desc: &str) -> Result<u32> {
596 let id = mm.next_id();
597 let full_title = format!("{}: {}", type_prefix, title);
598 let line = format!("[{}] **{}** - {}", id, full_title, desc);
599
600 mm.lines.push(line.clone());
601
602 let line_index = mm.lines.len() - 1;
603 let references = extract_refs_from_str(desc, Some(id));
604
605 let node = Node {
606 id,
607 raw_title: full_title,
608 description: desc.to_string(),
609 references,
610 line_index,
611 };
612 mm.by_id.insert(id, mm.nodes.len());
613 mm.nodes.push(node);
614
615 Ok(id)
616}
617
618pub fn cmd_add_editor(mm: &mut Mindmap, editor: &str, strict: bool) -> Result<u32> {
619 if !atty::is(atty::Stream::Stdin) {
621 return Err(anyhow::anyhow!(
622 "add via editor requires an interactive terminal"
623 ));
624 }
625
626 let id = mm.next_id();
627 let template = format!("[{}] **TYPE: Title** - description", id);
628
629 let mut tmp = tempfile::NamedTempFile::new()
631 .with_context(|| "Failed to create temp file for add editor")?;
632 use std::io::Write;
633 writeln!(tmp, "{}", template)?;
634 tmp.flush()?;
635
636 let status = std::process::Command::new(editor)
638 .arg(tmp.path())
639 .status()
640 .with_context(|| "Failed to launch editor")?;
641 if !status.success() {
642 return Err(anyhow::anyhow!("Editor exited with non-zero status"));
643 }
644
645 let edited = std::fs::read_to_string(tmp.path())?;
647 let nonempty: Vec<&str> = edited
648 .lines()
649 .map(|l| l.trim())
650 .filter(|l| !l.is_empty())
651 .collect();
652 if nonempty.is_empty() {
653 return Err(anyhow::anyhow!("No content written in editor"));
654 }
655 if nonempty.len() > 1 {
656 return Err(anyhow::anyhow!(
657 "Expected exactly one node line in editor; found multiple lines"
658 ));
659 }
660 let line = nonempty[0];
661
662 let parsed = parse_node_line(line, mm.lines.len())?;
664 if parsed.id != id {
665 return Err(anyhow::anyhow!(format!(
666 "Added line id changed; expected [{}]",
667 id
668 )));
669 }
670
671 if strict {
672 for r in &parsed.references {
673 if let Reference::Internal(iid) = r
674 && !mm.by_id.contains_key(iid)
675 {
676 return Err(anyhow::anyhow!(format!(
677 "ADD strict: reference to missing node {}",
678 iid
679 )));
680 }
681 }
682 }
683
684 mm.lines.push(line.to_string());
686 let line_index = mm.lines.len() - 1;
687 let node = Node {
688 id: parsed.id,
689 raw_title: parsed.raw_title,
690 description: parsed.description,
691 references: parsed.references,
692 line_index,
693 };
694 mm.by_id.insert(id, mm.nodes.len());
695 mm.nodes.push(node);
696
697 Ok(id)
698}
699
700pub fn cmd_deprecate(mm: &mut Mindmap, id: u32, to: u32) -> Result<()> {
701 let idx = *mm
702 .by_id
703 .get(&id)
704 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
705
706 if !mm.by_id.contains_key(&to) {
707 eprintln!(
708 "Warning: target node {} does not exist (still updating title)",
709 to
710 );
711 }
712
713 let node = &mut mm.nodes[idx];
714 if !node.raw_title.starts_with("[DEPRECATED") {
715 node.raw_title = format!("[DEPRECATED → {}] {}", to, node.raw_title);
716 mm.lines[node.line_index] = format!(
717 "[{}] **{}** - {}",
718 node.id, node.raw_title, node.description
719 );
720 }
721
722 Ok(())
723}
724
725pub fn cmd_verify(mm: &mut Mindmap, id: u32) -> Result<()> {
726 let idx = *mm
727 .by_id
728 .get(&id)
729 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
730 let node = &mut mm.nodes[idx];
731
732 let tag = format!("(verify {})", chrono::Local::now().format("%Y-%m-%d"));
733 if !node.description.contains("(verify ") {
734 if node.description.is_empty() {
735 node.description = tag.clone();
736 } else {
737 node.description = format!("{} {}", node.description, tag);
738 }
739 mm.lines[node.line_index] = format!(
740 "[{}] **{}** - {}",
741 node.id, node.raw_title, node.description
742 );
743 }
744 Ok(())
745}
746
747pub fn cmd_edit(mm: &mut Mindmap, id: u32, editor: &str) -> Result<()> {
748 let idx = *mm
749 .by_id
750 .get(&id)
751 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
752 let node = &mm.nodes[idx];
753
754 let mut tmp =
756 tempfile::NamedTempFile::new().with_context(|| "Failed to create temp file for editing")?;
757 use std::io::Write;
758 writeln!(
759 tmp,
760 "[{}] **{}** - {}",
761 node.id, node.raw_title, node.description
762 )?;
763 tmp.flush()?;
764
765 let status = std::process::Command::new(editor)
767 .arg(tmp.path())
768 .status()
769 .with_context(|| "Failed to launch editor")?;
770 if !status.success() {
771 return Err(anyhow::anyhow!("Editor exited with non-zero status"));
772 }
773
774 let edited = std::fs::read_to_string(tmp.path())?;
776 let edited_line = edited.lines().next().unwrap_or("").trim();
777
778 let parsed = parse_node_line(edited_line, node.line_index)?;
780 if parsed.id != id {
781 return Err(anyhow::anyhow!("Cannot change node ID"));
782 }
783
784 mm.lines[node.line_index] = edited_line.to_string();
786 let new_title = parsed.raw_title;
787 let new_desc = parsed.description;
788 let new_refs = parsed.references;
789
790 let node_mut = &mut mm.nodes[idx];
792 node_mut.raw_title = new_title;
793 node_mut.description = new_desc;
794 node_mut.references = new_refs;
795
796 Ok(())
797}
798
799pub fn cmd_put(mm: &mut Mindmap, id: u32, line: &str, strict: bool) -> Result<()> {
800 let idx = *mm
802 .by_id
803 .get(&id)
804 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
805
806 let parsed = parse_node_line(line, mm.nodes[idx].line_index)?;
807 if parsed.id != id {
808 return Err(anyhow::anyhow!("PUT line id does not match target id"));
809 }
810
811 if strict {
813 for r in &parsed.references {
814 if let Reference::Internal(iid) = r
815 && !mm.by_id.contains_key(iid)
816 {
817 return Err(anyhow::anyhow!(format!(
818 "PUT strict: reference to missing node {}",
819 iid
820 )));
821 }
822 }
823 }
824
825 mm.lines[mm.nodes[idx].line_index] = line.to_string();
827 let node_mut = &mut mm.nodes[idx];
828 node_mut.raw_title = parsed.raw_title;
829 node_mut.description = parsed.description;
830 node_mut.references = parsed.references;
831
832 Ok(())
833}
834
835pub fn cmd_patch(
836 mm: &mut Mindmap,
837 id: u32,
838 typ: Option<&str>,
839 title: Option<&str>,
840 desc: Option<&str>,
841 strict: bool,
842) -> Result<()> {
843 let idx = *mm
844 .by_id
845 .get(&id)
846 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
847 let node = &mm.nodes[idx];
848
849 let mut existing_type: Option<&str> = None;
851 let mut existing_title = node.raw_title.as_str();
852 if let Some(pos) = node.raw_title.find(':') {
853 existing_type = Some(node.raw_title[..pos].trim());
854 existing_title = node.raw_title[pos + 1..].trim();
855 }
856
857 let new_type = typ.unwrap_or(existing_type.unwrap_or(""));
858 let new_title = title.unwrap_or(existing_title);
859 let new_desc = desc.unwrap_or(&node.description);
860
861 let new_raw_title = if new_type.is_empty() {
863 new_title.to_string()
864 } else {
865 format!("{}: {}", new_type, new_title)
866 };
867
868 let new_line = format!("[{}] **{}** - {}", id, new_raw_title, new_desc);
869
870 let parsed = parse_node_line(&new_line, node.line_index)?;
872 if parsed.id != id {
873 return Err(anyhow::anyhow!("Patch resulted in different id"));
874 }
875
876 if strict {
877 for r in &parsed.references {
878 if let Reference::Internal(iid) = r
879 && !mm.by_id.contains_key(iid)
880 {
881 return Err(anyhow::anyhow!(format!(
882 "PATCH strict: reference to missing node {}",
883 iid
884 )));
885 }
886 }
887 }
888
889 mm.lines[node.line_index] = new_line;
891 let node_mut = &mut mm.nodes[idx];
892 node_mut.raw_title = parsed.raw_title;
893 node_mut.description = parsed.description;
894 node_mut.references = parsed.references;
895
896 Ok(())
897}
898
899pub fn cmd_delete(mm: &mut Mindmap, id: u32, force: bool) -> Result<()> {
900 let idx = *mm
902 .by_id
903 .get(&id)
904 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
905
906 let mut incoming_from = Vec::new();
908 for n in &mm.nodes {
909 if n.references
910 .iter()
911 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
912 {
913 incoming_from.push(n.id);
914 }
915 }
916 if !incoming_from.is_empty() && !force {
917 return Err(anyhow::anyhow!(format!(
918 "Node {} is referenced by {:?}; use --force to delete",
919 id, incoming_from
920 )));
921 }
922
923 let line_idx = mm.nodes[idx].line_index;
925 mm.lines.remove(line_idx);
926
927 mm.nodes.remove(idx);
929
930 mm.by_id.clear();
932 for (i, node) in mm.nodes.iter_mut().enumerate() {
933 if node.line_index > line_idx {
935 node.line_index -= 1;
936 }
937 mm.by_id.insert(node.id, i);
938 }
939
940 Ok(())
941}
942
943pub fn cmd_lint(mm: &Mindmap) -> Result<Vec<String>> {
944 let mut warnings = Vec::new();
945
946 for (i, line) in mm.lines.iter().enumerate() {
948 let trimmed = line.trim_start();
949 if trimmed.starts_with('[') && parse_node_line(trimmed, i).is_err() {
950 warnings.push(format!(
951 "Syntax: line {} starts with '[' but does not match node format",
952 i + 1
953 ));
954 }
955 }
956
957 let mut id_map: HashMap<u32, Vec<usize>> = HashMap::new();
959 for (i, line) in mm.lines.iter().enumerate() {
960 if let Ok(node) = parse_node_line(line, i) {
961 id_map.entry(node.id).or_default().push(i + 1);
962 }
963 }
964 for (id, locations) in &id_map {
965 if locations.len() > 1 {
966 warnings.push(format!(
967 "Duplicate ID: node {} appears on lines {:?}",
968 id, locations
969 ));
970 }
971 }
972
973 for n in &mm.nodes {
975 for r in &n.references {
976 match r {
977 Reference::Internal(iid) => {
978 if !mm.by_id.contains_key(iid) {
979 warnings.push(format!(
980 "Missing ref: node {} references missing node {}",
981 n.id, iid
982 ));
983 }
984 }
985 Reference::External(eid, file) => {
986 if !std::path::Path::new(file).exists() {
987 warnings.push(format!(
988 "Missing file: node {} references {} in missing file {}",
989 n.id, eid, file
990 ));
991 }
992 }
993 }
994 }
995 }
996
997 if warnings.is_empty() {
998 Ok(vec!["Lint OK".to_string()])
999 } else {
1000 Ok(warnings)
1001 }
1002}
1003
1004pub fn cmd_orphans(mm: &Mindmap) -> Result<Vec<String>> {
1005 let mut warnings = Vec::new();
1006
1007 let mut incoming: HashMap<u32, usize> = HashMap::new();
1009 for n in &mm.nodes {
1010 incoming.entry(n.id).or_insert(0);
1011 }
1012 for n in &mm.nodes {
1013 for r in &n.references {
1014 if let Reference::Internal(iid) = r
1015 && incoming.contains_key(iid)
1016 {
1017 *incoming.entry(*iid).or_insert(0) += 1;
1018 }
1019 }
1020 }
1021 for n in &mm.nodes {
1022 let inc = incoming.get(&n.id).copied().unwrap_or(0);
1023 let out = n.references.len();
1024 let title_up = n.raw_title.to_uppercase();
1025 if inc == 0 && out == 0 && !title_up.starts_with("META") {
1026 warnings.push(format!("{}", n.id));
1027 }
1028 }
1029
1030 if warnings.is_empty() {
1031 Ok(vec!["No orphans".to_string()])
1032 } else {
1033 Ok(warnings)
1034 }
1035}
1036
1037pub fn cmd_graph(mm: &Mindmap, id: u32) -> Result<String> {
1038 if !mm.by_id.contains_key(&id) {
1039 return Err(anyhow::anyhow!(format!("Node {} not found", id)));
1040 }
1041
1042 let mut nodes = std::collections::HashSet::new();
1044 nodes.insert(id);
1045
1046 if let Some(node) = mm.get_node(id) {
1048 for r in &node.references {
1049 if let Reference::Internal(rid) = r {
1050 nodes.insert(*rid);
1051 }
1052 }
1053 }
1054
1055 for n in &mm.nodes {
1057 for r in &n.references {
1058 if let Reference::Internal(rid) = r
1059 && *rid == id
1060 {
1061 nodes.insert(n.id);
1062 }
1063 }
1064 }
1065
1066 let mut dot = String::new();
1068 dot.push_str("digraph {\n");
1069 dot.push_str(" rankdir=LR;\n");
1070
1071 for &nid in &nodes {
1073 if let Some(node) = mm.get_node(nid) {
1074 let label = format!("{}: {}", node.id, node.raw_title.replace("\"", "\\\""));
1075 dot.push_str(&format!(" {} [label=\"{}\"];\n", nid, label));
1076 }
1077 }
1078
1079 for &nid in &nodes {
1081 if let Some(node) = mm.get_node(nid) {
1082 for r in &node.references {
1083 if let Reference::Internal(rid) = r
1084 && nodes.contains(rid)
1085 {
1086 dot.push_str(&format!(" {} -> {};\n", nid, rid));
1087 }
1088 }
1089 }
1090 }
1091
1092 dot.push_str("}\n");
1093 Ok(dot)
1094}
1095
1096fn blake3_hash(content: &[u8]) -> String {
1098 blake3::hash(content).to_hex().to_string()
1099}
1100
1101#[derive(Debug, Clone)]
1102enum BatchOp {
1103 Add {
1104 type_prefix: String,
1105 title: String,
1106 desc: String,
1107 },
1108 Patch {
1109 id: u32,
1110 type_prefix: Option<String>,
1111 title: Option<String>,
1112 desc: Option<String>,
1113 },
1114 Put {
1115 id: u32,
1116 line: String,
1117 },
1118 Delete {
1119 id: u32,
1120 force: bool,
1121 },
1122 Deprecate {
1123 id: u32,
1124 to: u32,
1125 },
1126 Verify {
1127 id: u32,
1128 },
1129}
1130
1131#[derive(Debug, Clone, serde::Serialize)]
1132pub struct BatchResult {
1133 pub total_ops: usize,
1134 pub applied: usize,
1135 pub added_ids: Vec<u32>,
1136 pub patched_ids: Vec<u32>,
1137 pub deleted_ids: Vec<u32>,
1138 pub warnings: Vec<String>,
1139}
1140
1141fn parse_batch_op_json(val: &serde_json::Value) -> Result<BatchOp> {
1143 let obj = val
1144 .as_object()
1145 .ok_or_else(|| anyhow::anyhow!("Op must be a JSON object"))?;
1146 let op_type = obj
1147 .get("op")
1148 .and_then(|v| v.as_str())
1149 .ok_or_else(|| anyhow::anyhow!("Missing 'op' field"))?;
1150
1151 match op_type {
1152 "add" => {
1153 let type_prefix = obj
1154 .get("type")
1155 .and_then(|v| v.as_str())
1156 .ok_or_else(|| anyhow::anyhow!("add: missing 'type' field"))?
1157 .to_string();
1158 let title = obj
1159 .get("title")
1160 .and_then(|v| v.as_str())
1161 .ok_or_else(|| anyhow::anyhow!("add: missing 'title' field"))?
1162 .to_string();
1163 let desc = obj
1164 .get("desc")
1165 .and_then(|v| v.as_str())
1166 .ok_or_else(|| anyhow::anyhow!("add: missing 'desc' field"))?
1167 .to_string();
1168 Ok(BatchOp::Add {
1169 type_prefix,
1170 title,
1171 desc,
1172 })
1173 }
1174 "patch" => {
1175 let id = obj
1176 .get("id")
1177 .and_then(|v| v.as_u64())
1178 .ok_or_else(|| anyhow::anyhow!("patch: missing 'id' field"))?
1179 as u32;
1180 let type_prefix = obj.get("type").and_then(|v| v.as_str()).map(String::from);
1181 let title = obj.get("title").and_then(|v| v.as_str()).map(String::from);
1182 let desc = obj.get("desc").and_then(|v| v.as_str()).map(String::from);
1183 Ok(BatchOp::Patch {
1184 id,
1185 type_prefix,
1186 title,
1187 desc,
1188 })
1189 }
1190 "put" => {
1191 let id = obj
1192 .get("id")
1193 .and_then(|v| v.as_u64())
1194 .ok_or_else(|| anyhow::anyhow!("put: missing 'id' field"))?
1195 as u32;
1196 let line = obj
1197 .get("line")
1198 .and_then(|v| v.as_str())
1199 .ok_or_else(|| anyhow::anyhow!("put: missing 'line' field"))?
1200 .to_string();
1201 Ok(BatchOp::Put { id, line })
1202 }
1203 "delete" => {
1204 let id = obj
1205 .get("id")
1206 .and_then(|v| v.as_u64())
1207 .ok_or_else(|| anyhow::anyhow!("delete: missing 'id' field"))?
1208 as u32;
1209 let force = obj.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
1210 Ok(BatchOp::Delete { id, force })
1211 }
1212 "deprecate" => {
1213 let id = obj
1214 .get("id")
1215 .and_then(|v| v.as_u64())
1216 .ok_or_else(|| anyhow::anyhow!("deprecate: missing 'id' field"))?
1217 as u32;
1218 let to = obj
1219 .get("to")
1220 .and_then(|v| v.as_u64())
1221 .ok_or_else(|| anyhow::anyhow!("deprecate: missing 'to' field"))?
1222 as u32;
1223 Ok(BatchOp::Deprecate { id, to })
1224 }
1225 "verify" => {
1226 let id = obj
1227 .get("id")
1228 .and_then(|v| v.as_u64())
1229 .ok_or_else(|| anyhow::anyhow!("verify: missing 'id' field"))?
1230 as u32;
1231 Ok(BatchOp::Verify { id })
1232 }
1233 other => Err(anyhow::anyhow!("Unknown op type: {}", other)),
1234 }
1235}
1236
1237fn parse_batch_op_line(line: &str) -> Result<BatchOp> {
1239 use shell_words;
1240
1241 let parts = shell_words::split(line)?;
1242 if parts.is_empty() {
1243 return Err(anyhow::anyhow!("Empty operation line"));
1244 }
1245
1246 match parts[0].as_str() {
1247 "add" => {
1248 let mut type_prefix = String::new();
1249 let mut title = String::new();
1250 let mut desc = String::new();
1251 let mut i = 1;
1252 while i < parts.len() {
1253 match parts[i].as_str() {
1254 "--type" => {
1255 i += 1;
1256 type_prefix = parts
1257 .get(i)
1258 .ok_or_else(|| anyhow::anyhow!("add: --type requires value"))?
1259 .clone();
1260 }
1261 "--title" => {
1262 i += 1;
1263 title = parts
1264 .get(i)
1265 .ok_or_else(|| anyhow::anyhow!("add: --title requires value"))?
1266 .clone();
1267 }
1268 "--desc" => {
1269 i += 1;
1270 desc = parts
1271 .get(i)
1272 .ok_or_else(|| anyhow::anyhow!("add: --desc requires value"))?
1273 .clone();
1274 }
1275 _ => {}
1276 }
1277 i += 1;
1278 }
1279 if type_prefix.is_empty() || title.is_empty() || desc.is_empty() {
1280 return Err(anyhow::anyhow!("add: requires --type, --title, --desc"));
1281 }
1282 Ok(BatchOp::Add {
1283 type_prefix,
1284 title,
1285 desc,
1286 })
1287 }
1288 "patch" => {
1289 let id: u32 = parts
1290 .get(1)
1291 .ok_or_else(|| anyhow::anyhow!("patch: missing id"))?
1292 .parse()?;
1293 let mut type_prefix: Option<String> = None;
1294 let mut title: Option<String> = None;
1295 let mut desc: Option<String> = None;
1296 let mut i = 2;
1297 while i < parts.len() {
1298 match parts[i].as_str() {
1299 "--type" => {
1300 i += 1;
1301 type_prefix = Some(
1302 parts
1303 .get(i)
1304 .ok_or_else(|| anyhow::anyhow!("patch: --type requires value"))?
1305 .clone(),
1306 );
1307 }
1308 "--title" => {
1309 i += 1;
1310 title = Some(
1311 parts
1312 .get(i)
1313 .ok_or_else(|| anyhow::anyhow!("patch: --title requires value"))?
1314 .clone(),
1315 );
1316 }
1317 "--desc" => {
1318 i += 1;
1319 desc = Some(
1320 parts
1321 .get(i)
1322 .ok_or_else(|| anyhow::anyhow!("patch: --desc requires value"))?
1323 .clone(),
1324 );
1325 }
1326 _ => {}
1327 }
1328 i += 1;
1329 }
1330 Ok(BatchOp::Patch {
1331 id,
1332 type_prefix,
1333 title,
1334 desc,
1335 })
1336 }
1337 "put" => {
1338 let id: u32 = parts
1339 .get(1)
1340 .ok_or_else(|| anyhow::anyhow!("put: missing id"))?
1341 .parse()?;
1342 let mut line = String::new();
1343 let mut i = 2;
1344 while i < parts.len() {
1345 if parts[i] == "--line" {
1346 i += 1;
1347 line = parts
1348 .get(i)
1349 .ok_or_else(|| anyhow::anyhow!("put: --line requires value"))?
1350 .clone();
1351 break;
1352 }
1353 i += 1;
1354 }
1355 if line.is_empty() {
1356 return Err(anyhow::anyhow!("put: requires --line"));
1357 }
1358 Ok(BatchOp::Put { id, line })
1359 }
1360 "delete" => {
1361 let id: u32 = parts
1362 .get(1)
1363 .ok_or_else(|| anyhow::anyhow!("delete: missing id"))?
1364 .parse()?;
1365 let force = parts.contains(&"--force".to_string());
1366 Ok(BatchOp::Delete { id, force })
1367 }
1368 "deprecate" => {
1369 let id: u32 = parts
1370 .get(1)
1371 .ok_or_else(|| anyhow::anyhow!("deprecate: missing id"))?
1372 .parse()?;
1373 let mut to: Option<u32> = None;
1374 let mut i = 2;
1375 while i < parts.len() {
1376 if parts[i] == "--to" {
1377 i += 1;
1378 to = Some(
1379 parts
1380 .get(i)
1381 .ok_or_else(|| anyhow::anyhow!("deprecate: --to requires value"))?
1382 .parse()?,
1383 );
1384 break;
1385 }
1386 i += 1;
1387 }
1388 let to = to.ok_or_else(|| anyhow::anyhow!("deprecate: requires --to"))?;
1389 Ok(BatchOp::Deprecate { id, to })
1390 }
1391 "verify" => {
1392 let id: u32 = parts
1393 .get(1)
1394 .ok_or_else(|| anyhow::anyhow!("verify: missing id"))?
1395 .parse()?;
1396 Ok(BatchOp::Verify { id })
1397 }
1398 other => Err(anyhow::anyhow!("Unknown batch command: {}", other)),
1399 }
1400}
1401
1402pub fn run(cli: Cli) -> Result<()> {
1405 let path = cli.file.unwrap_or_else(|| PathBuf::from("MINDMAP.md"));
1406
1407 let mut mm = if path.as_os_str() == "-" {
1409 Mindmap::load_from_reader(std::io::stdin(), path.clone())?
1410 } else {
1411 Mindmap::load(path.clone())?
1412 };
1413
1414 let interactive = atty::is(atty::Stream::Stdout);
1416 let env_override = std::env::var("MINDMAP_PRETTY").ok();
1417 let pretty_enabled = match env_override.as_deref() {
1418 Some("0") => false,
1419 Some("1") => true,
1420 _ => interactive,
1421 } && matches!(cli.output, OutputFormat::Default);
1422
1423 let printer: Option<Box<dyn ui::Printer>> = if matches!(cli.output, OutputFormat::Default) {
1424 if pretty_enabled {
1425 Some(Box::new(crate::ui::PrettyPrinter::new()?))
1426 } else {
1427 Some(Box::new(crate::ui::PlainPrinter::new()?))
1428 }
1429 } else {
1430 None
1431 };
1432
1433 let cannot_write_err = |cmd_name: &str| -> anyhow::Error {
1435 anyhow::anyhow!(format!(
1436 "Cannot {}: mindmap was loaded from stdin ('-'); use --file <path> to save changes",
1437 cmd_name
1438 ))
1439 };
1440
1441 match cli.command {
1442 Commands::Show { id } => match mm.get_node(id) {
1443 Some(node) => {
1444 if matches!(cli.output, OutputFormat::Json) {
1445 let obj = serde_json::json!({
1446 "command": "show",
1447 "node": {
1448 "id": node.id,
1449 "raw_title": node.raw_title,
1450 "description": node.description,
1451 "references": node.references,
1452 "line_index": node.line_index,
1453 }
1454 });
1455 println!("{}", serde_json::to_string_pretty(&obj)?);
1456 } else {
1457 let mut inbound = Vec::new();
1459 for n in &mm.nodes {
1460 if n.references
1461 .iter()
1462 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
1463 {
1464 inbound.push(n.id);
1465 }
1466 }
1467
1468 if let Some(p) = &printer {
1469 p.show(node, &inbound, &node.references)?;
1470 } else {
1471 println!(
1472 "[{}] **{}** - {}",
1473 node.id, node.raw_title, node.description
1474 );
1475 if !inbound.is_empty() {
1476 eprintln!("Referred to by: {:?}", inbound);
1477 }
1478 }
1479 }
1480 }
1481 None => return Err(anyhow::anyhow!(format!("Node {} not found", id))),
1482 },
1483 Commands::List { r#type, grep } => {
1484 let items = cmd_list(&mm, r#type.as_deref(), grep.as_deref());
1485 if matches!(cli.output, OutputFormat::Json) {
1486 let arr: Vec<_> = items
1487 .into_iter()
1488 .map(|line| serde_json::json!({"line": line}))
1489 .collect();
1490 let obj = serde_json::json!({"command": "list", "items": arr});
1491 println!("{}", serde_json::to_string_pretty(&obj)?);
1492 } else if let Some(p) = &printer {
1493 p.list(&items)?;
1494 } else {
1495 for it in items {
1496 println!("{}", it);
1497 }
1498 }
1499 }
1500 Commands::Refs { id } => {
1501 let items = cmd_refs(&mm, id);
1502 if matches!(cli.output, OutputFormat::Json) {
1503 let obj = serde_json::json!({"command": "refs", "items": items});
1504 println!("{}", serde_json::to_string_pretty(&obj)?);
1505 } else if let Some(p) = &printer {
1506 p.refs(&items)?;
1507 } else {
1508 for it in items {
1509 println!("{}", it);
1510 }
1511 }
1512 }
1513 Commands::Links { id } => match cmd_links(&mm, id) {
1514 Some(v) => {
1515 if matches!(cli.output, OutputFormat::Json) {
1516 let obj = serde_json::json!({"command": "links", "id": id, "links": v});
1517 println!("{}", serde_json::to_string_pretty(&obj)?);
1518 } else if let Some(p) = &printer {
1519 p.links(id, &v)?;
1520 } else {
1521 println!("Node [{}] references: {:?}", id, v);
1522 }
1523 }
1524 None => return Err(anyhow::anyhow!(format!("Node [{}] not found", id))),
1525 },
1526 Commands::Search { query } => {
1527 let items = cmd_search(&mm, &query);
1528 if matches!(cli.output, OutputFormat::Json) {
1529 let obj = serde_json::json!({"command": "search", "query": query, "items": items});
1530 println!("{}", serde_json::to_string_pretty(&obj)?);
1531 } else if let Some(p) = &printer {
1532 p.search(&items)?;
1533 } else {
1534 for it in items {
1535 println!("{}", it);
1536 }
1537 }
1538 }
1539 Commands::Add {
1540 r#type,
1541 title,
1542 desc,
1543 strict,
1544 } => {
1545 if mm.path.as_os_str() == "-" {
1546 return Err(cannot_write_err("add"));
1547 }
1548 match (r#type.as_deref(), title.as_deref(), desc.as_deref()) {
1549 (Some(tp), Some(tt), Some(dd)) => {
1550 let id = cmd_add(&mut mm, tp, tt, dd)?;
1551 mm.save()?;
1552 if matches!(cli.output, OutputFormat::Json)
1553 && let Some(node) = mm.get_node(id)
1554 {
1555 let obj = serde_json::json!({"command": "add", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1556 println!("{}", serde_json::to_string_pretty(&obj)?);
1557 }
1558 eprintln!("Added node [{}]", id);
1559 }
1560 (None, None, None) => {
1561 if !atty::is(atty::Stream::Stdin) {
1563 return Err(anyhow::anyhow!(
1564 "add via editor requires an interactive terminal"
1565 ));
1566 }
1567 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
1568 let id = cmd_add_editor(&mut mm, &editor, strict)?;
1569 mm.save()?;
1570 if matches!(cli.output, OutputFormat::Json)
1571 && let Some(node) = mm.get_node(id)
1572 {
1573 let obj = serde_json::json!({"command": "add", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1574 println!("{}", serde_json::to_string_pretty(&obj)?);
1575 }
1576 eprintln!("Added node [{}]", id);
1577 }
1578 _ => {
1579 return Err(anyhow::anyhow!(
1580 "add requires either all of --type,--title,--desc or none (editor)"
1581 ));
1582 }
1583 }
1584 }
1585 Commands::Deprecate { id, to } => {
1586 if mm.path.as_os_str() == "-" {
1587 return Err(cannot_write_err("deprecate"));
1588 }
1589 cmd_deprecate(&mut mm, id, to)?;
1590 mm.save()?;
1591 if matches!(cli.output, OutputFormat::Json)
1592 && let Some(node) = mm.get_node(id)
1593 {
1594 let obj = serde_json::json!({"command": "deprecate", "node": {"id": node.id, "raw_title": node.raw_title}});
1595 println!("{}", serde_json::to_string_pretty(&obj)?);
1596 }
1597 eprintln!("Deprecated node [{}] → [{}]", id, to);
1598 }
1599 Commands::Edit { id } => {
1600 if mm.path.as_os_str() == "-" {
1601 return Err(cannot_write_err("edit"));
1602 }
1603 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
1604 cmd_edit(&mut mm, id, &editor)?;
1605 mm.save()?;
1606 if matches!(cli.output, OutputFormat::Json)
1607 && let Some(node) = mm.get_node(id)
1608 {
1609 let obj = serde_json::json!({"command": "edit", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1610 println!("{}", serde_json::to_string_pretty(&obj)?);
1611 }
1612 eprintln!("Edited node [{}]", id);
1613 }
1614 Commands::Patch {
1615 id,
1616 r#type,
1617 title,
1618 desc,
1619 strict,
1620 } => {
1621 if mm.path.as_os_str() == "-" {
1622 return Err(cannot_write_err("patch"));
1623 }
1624 cmd_patch(
1625 &mut mm,
1626 id,
1627 r#type.as_deref(),
1628 title.as_deref(),
1629 desc.as_deref(),
1630 strict,
1631 )?;
1632 mm.save()?;
1633 if matches!(cli.output, OutputFormat::Json)
1634 && let Some(node) = mm.get_node(id)
1635 {
1636 let obj = serde_json::json!({"command": "patch", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1637 println!("{}", serde_json::to_string_pretty(&obj)?);
1638 }
1639 eprintln!("Patched node [{}]", id);
1640 }
1641 Commands::Put { id, line, strict } => {
1642 if mm.path.as_os_str() == "-" {
1643 return Err(cannot_write_err("put"));
1644 }
1645 cmd_put(&mut mm, id, &line, strict)?;
1646 mm.save()?;
1647 if matches!(cli.output, OutputFormat::Json)
1648 && let Some(node) = mm.get_node(id)
1649 {
1650 let obj = serde_json::json!({"command": "put", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1651 println!("{}", serde_json::to_string_pretty(&obj)?);
1652 }
1653 eprintln!("Put node [{}]", id);
1654 }
1655 Commands::Verify { id } => {
1656 if mm.path.as_os_str() == "-" {
1657 return Err(cannot_write_err("verify"));
1658 }
1659 cmd_verify(&mut mm, id)?;
1660 mm.save()?;
1661 if matches!(cli.output, OutputFormat::Json)
1662 && let Some(node) = mm.get_node(id)
1663 {
1664 let obj = serde_json::json!({"command": "verify", "node": {"id": node.id, "description": node.description}});
1665 println!("{}", serde_json::to_string_pretty(&obj)?);
1666 }
1667 eprintln!("Marked node [{}] for verification", id);
1668 }
1669 Commands::Delete { id, force } => {
1670 if mm.path.as_os_str() == "-" {
1671 return Err(cannot_write_err("delete"));
1672 }
1673 cmd_delete(&mut mm, id, force)?;
1674 mm.save()?;
1675 if matches!(cli.output, OutputFormat::Json) {
1676 let obj = serde_json::json!({"command": "delete", "deleted": id});
1677 println!("{}", serde_json::to_string_pretty(&obj)?);
1678 }
1679 eprintln!("Deleted node [{}]", id);
1680 }
1681 Commands::Lint { fix } => {
1682 if fix {
1683 if mm.path.as_os_str() == "-" {
1684 return Err(cannot_write_err("lint --fix"));
1685 }
1686
1687 let report = mm.apply_fixes()?;
1689 if report.any_changes() {
1690 mm.save()?;
1691 }
1692
1693 if matches!(cli.output, OutputFormat::Json) {
1694 let obj = serde_json::json!({"command": "lint", "fixed": report.any_changes(), "fixes": report});
1695 println!("{}", serde_json::to_string_pretty(&obj)?);
1696 } else {
1697 if !report.spacing.is_empty() {
1698 eprintln!(
1699 "Fixed spacing: inserted {} blank lines",
1700 report.spacing.len()
1701 );
1702 }
1703 for tf in &report.title_fixes {
1704 eprintln!(
1705 "Fixed title for node {}: '{}' -> '{}'",
1706 tf.id, tf.old, tf.new
1707 );
1708 }
1709 if !report.any_changes() {
1710 eprintln!("No fixes necessary");
1711 }
1712
1713 let res = cmd_lint(&mm)?;
1715 for r in res {
1716 eprintln!("{}", r);
1717 }
1718 }
1719 } else {
1720 let res = cmd_lint(&mm)?;
1721 if matches!(cli.output, OutputFormat::Json) {
1722 let obj = serde_json::json!({"command": "lint", "warnings": res});
1723 println!("{}", serde_json::to_string_pretty(&obj)?);
1724 } else {
1725 for r in res {
1726 eprintln!("{}", r);
1727 }
1728 }
1729 }
1730 }
1731 Commands::Orphans => {
1732 let res = cmd_orphans(&mm)?;
1733 if matches!(cli.output, OutputFormat::Json) {
1734 let obj = serde_json::json!({"command": "orphans", "orphans": res});
1735 println!("{}", serde_json::to_string_pretty(&obj)?);
1736 } else if let Some(p) = &printer {
1737 p.orphans(&res)?;
1738 } else {
1739 for r in res {
1740 eprintln!("{}", r);
1741 }
1742 }
1743 }
1744 Commands::Graph { id } => {
1745 let dot = cmd_graph(&mm, id)?;
1746 println!("{}", dot);
1747 }
1748 Commands::Prime => {
1749 use clap::CommandFactory;
1751 use std::path::Path;
1752
1753 let mut cmd = Cli::command();
1754 let mut buf: Vec<u8> = Vec::new();
1756 cmd.write_long_help(&mut buf)?;
1757 let help_str = String::from_utf8(buf)?;
1758
1759 let protocol_path = mm
1761 .path
1762 .parent()
1763 .map(|p| p.to_path_buf())
1764 .unwrap_or_else(|| PathBuf::from("."))
1765 .join("PROTOCOL_MINDMAP.md");
1766
1767 let protocol = if Path::new(&protocol_path).exists() {
1768 match fs::read_to_string(&protocol_path) {
1769 Ok(s) => Some(s),
1770 Err(e) => {
1771 eprintln!("Warning: failed to read {}: {}", protocol_path.display(), e);
1772 None
1773 }
1774 }
1775 } else {
1776 None
1777 };
1778
1779 let items = cmd_list(&mm, None, None);
1780
1781 if matches!(cli.output, OutputFormat::Json) {
1782 let arr: Vec<_> = items
1783 .into_iter()
1784 .map(|line| serde_json::json!({"line": line}))
1785 .collect();
1786 let mut obj =
1787 serde_json::json!({"command": "prime", "help": help_str, "items": arr});
1788 if let Some(proto) = protocol {
1789 obj["protocol"] = serde_json::json!(proto);
1790 }
1791 println!("{}", serde_json::to_string_pretty(&obj)?);
1792 } else {
1793 println!("{}", help_str);
1795
1796 if let Some(proto) = protocol {
1798 eprintln!("--- PROTOCOL_MINDMAP.md ---");
1799 println!("{}", proto);
1800 eprintln!("--- end protocol ---");
1801 }
1802
1803 if let Some(p) = &printer {
1805 p.list(&items)?;
1806 } else {
1807 for it in items {
1808 println!("{}", it);
1809 }
1810 }
1811 }
1812 }
1813 Commands::Batch {
1814 input,
1815 format,
1816 dry_run,
1817 fix,
1818 } => {
1819 if path.as_os_str() == "-" {
1821 return Err(anyhow::anyhow!(
1822 "Cannot batch: mindmap was loaded from stdin ('-'); use --file <path> to save changes"
1823 ));
1824 }
1825
1826 let base_content = fs::read_to_string(&path)
1828 .with_context(|| format!("Failed to read base file {}", path.display()))?;
1829 let base_hash = blake3_hash(base_content.as_bytes());
1830
1831 let mut buf = String::new();
1833 match input {
1834 Some(p) if p.as_os_str() == "-" => {
1835 std::io::stdin().read_to_string(&mut buf)?;
1836 }
1837 Some(p) => {
1838 buf = std::fs::read_to_string(p)?;
1839 }
1840 None => {
1841 std::io::stdin().read_to_string(&mut buf)?;
1842 }
1843 }
1844
1845 let mut ops: Vec<BatchOp> = Vec::new();
1847 if format == "json" {
1848 let arr = serde_json::from_str::<Vec<serde_json::Value>>(&buf)?;
1850 for (i, val) in arr.iter().enumerate() {
1851 match parse_batch_op_json(val) {
1852 Ok(op) => ops.push(op),
1853 Err(e) => {
1854 return Err(anyhow::anyhow!("Failed to parse batch op {}: {}", i, e));
1855 }
1856 }
1857 }
1858 } else {
1859 for (i, line) in buf.lines().enumerate() {
1861 let line = line.trim();
1862 if line.is_empty() || line.starts_with('#') {
1863 continue;
1864 }
1865 match parse_batch_op_line(line) {
1866 Ok(op) => ops.push(op),
1867 Err(e) => {
1868 return Err(anyhow::anyhow!(
1869 "Failed to parse batch line {}: {}",
1870 i + 1,
1871 e
1872 ));
1873 }
1874 }
1875 }
1876 }
1877
1878 let mut mm_clone = Mindmap::from_string(base_content.clone(), path.clone())?;
1880
1881 let mut result = BatchResult {
1883 total_ops: ops.len(),
1884 applied: 0,
1885 added_ids: Vec::new(),
1886 patched_ids: Vec::new(),
1887 deleted_ids: Vec::new(),
1888 warnings: Vec::new(),
1889 };
1890
1891 for (i, op) in ops.iter().enumerate() {
1892 match op {
1893 BatchOp::Add {
1894 type_prefix,
1895 title,
1896 desc,
1897 } => match cmd_add(&mut mm_clone, type_prefix, title, desc) {
1898 Ok(id) => {
1899 result.added_ids.push(id);
1900 result.applied += 1;
1901 }
1902 Err(e) => {
1903 return Err(anyhow::anyhow!("Op {}: add failed: {}", i, e));
1904 }
1905 },
1906 BatchOp::Patch {
1907 id,
1908 type_prefix,
1909 title,
1910 desc,
1911 } => {
1912 match cmd_patch(
1913 &mut mm_clone,
1914 *id,
1915 type_prefix.as_deref(),
1916 title.as_deref(),
1917 desc.as_deref(),
1918 false,
1919 ) {
1920 Ok(_) => {
1921 result.patched_ids.push(*id);
1922 result.applied += 1;
1923 }
1924 Err(e) => {
1925 return Err(anyhow::anyhow!("Op {}: patch failed: {}", i, e));
1926 }
1927 }
1928 }
1929 BatchOp::Put { id, line } => match cmd_put(&mut mm_clone, *id, line, false) {
1930 Ok(_) => {
1931 result.patched_ids.push(*id);
1932 result.applied += 1;
1933 }
1934 Err(e) => {
1935 return Err(anyhow::anyhow!("Op {}: put failed: {}", i, e));
1936 }
1937 },
1938 BatchOp::Delete { id, force } => match cmd_delete(&mut mm_clone, *id, *force) {
1939 Ok(_) => {
1940 result.deleted_ids.push(*id);
1941 result.applied += 1;
1942 }
1943 Err(e) => {
1944 return Err(anyhow::anyhow!("Op {}: delete failed: {}", i, e));
1945 }
1946 },
1947 BatchOp::Deprecate { id, to } => match cmd_deprecate(&mut mm_clone, *id, *to) {
1948 Ok(_) => {
1949 result.patched_ids.push(*id);
1950 result.applied += 1;
1951 }
1952 Err(e) => {
1953 return Err(anyhow::anyhow!("Op {}: deprecate failed: {}", i, e));
1954 }
1955 },
1956 BatchOp::Verify { id } => match cmd_verify(&mut mm_clone, *id) {
1957 Ok(_) => {
1958 result.patched_ids.push(*id);
1959 result.applied += 1;
1960 }
1961 Err(e) => {
1962 return Err(anyhow::anyhow!("Op {}: verify failed: {}", i, e));
1963 }
1964 },
1965 }
1966 }
1967
1968 if fix {
1970 match mm_clone.apply_fixes() {
1971 Ok(report) => {
1972 if !report.spacing.is_empty() {
1973 result.warnings.push(format!(
1974 "Auto-fixed: inserted {} spacing lines",
1975 report.spacing.len()
1976 ));
1977 }
1978 for tf in &report.title_fixes {
1979 result.warnings.push(format!(
1980 "Auto-fixed title for node {}: '{}' -> '{}'",
1981 tf.id, tf.old, tf.new
1982 ));
1983 }
1984 }
1985 Err(e) => {
1986 return Err(anyhow::anyhow!("Failed to apply fixes: {}", e));
1987 }
1988 }
1989 }
1990
1991 match cmd_lint(&mm_clone) {
1993 Ok(warnings) => {
1994 result.warnings.extend(warnings);
1995 }
1996 Err(e) => {
1997 return Err(anyhow::anyhow!("Lint check failed: {}", e));
1998 }
1999 }
2000
2001 if dry_run {
2002 if matches!(cli.output, OutputFormat::Json) {
2004 let obj = serde_json::json!({
2005 "command": "batch",
2006 "dry_run": true,
2007 "result": result,
2008 "content": mm_clone.lines.join("\n") + "\n"
2009 });
2010 println!("{}", serde_json::to_string_pretty(&obj)?);
2011 } else {
2012 eprintln!("--- DRY RUN: No changes written ---");
2013 eprintln!(
2014 "Would apply {} operations: {} added, {} patched, {} deleted",
2015 result.applied,
2016 result.added_ids.len(),
2017 result.patched_ids.len(),
2018 result.deleted_ids.len()
2019 );
2020 if !result.warnings.is_empty() {
2021 eprintln!("Warnings:");
2022 for w in &result.warnings {
2023 eprintln!(" {}", w);
2024 }
2025 }
2026 println!("{}", mm_clone.lines.join("\n"));
2027 }
2028 } else {
2029 let current_content = fs::read_to_string(&path).with_context(|| {
2031 format!("Failed to re-read file before commit {}", path.display())
2032 })?;
2033 let current_hash = blake3_hash(current_content.as_bytes());
2034
2035 if current_hash != base_hash {
2036 return Err(anyhow::anyhow!(
2037 "Cannot commit batch: target file changed since batch began (hash mismatch).\n\
2038 Base hash: {}\n\
2039 Current hash: {}\n\
2040 The file was likely modified by another process. \
2041 Re-run begin your batch on the current file.",
2042 base_hash,
2043 current_hash
2044 ));
2045 }
2046
2047 mm_clone.save()?;
2049
2050 if matches!(cli.output, OutputFormat::Json) {
2051 let obj = serde_json::json!({
2052 "command": "batch",
2053 "dry_run": false,
2054 "result": result
2055 });
2056 println!("{}", serde_json::to_string_pretty(&obj)?);
2057 } else {
2058 eprintln!("Batch applied successfully: {} ops applied", result.applied);
2059 if !result.added_ids.is_empty() {
2060 eprintln!(" Added nodes: {:?}", result.added_ids);
2061 }
2062 if !result.patched_ids.is_empty() {
2063 eprintln!(" Patched nodes: {:?}", result.patched_ids);
2064 }
2065 if !result.deleted_ids.is_empty() {
2066 eprintln!(" Deleted nodes: {:?}", result.deleted_ids);
2067 }
2068 if !result.warnings.is_empty() {
2069 eprintln!("Warnings:");
2070 for w in &result.warnings {
2071 eprintln!(" {}", w);
2072 }
2073 }
2074 }
2075 }
2076 }
2077 }
2078
2079 Ok(())
2080}
2081
2082#[derive(Debug, Clone, serde::Serialize, Default)]
2083pub struct FixReport {
2084 pub spacing: Vec<usize>,
2085 pub title_fixes: Vec<TitleFix>,
2086}
2087
2088#[derive(Debug, Clone, serde::Serialize)]
2089pub struct TitleFix {
2090 pub id: u32,
2091 pub old: String,
2092 pub new: String,
2093}
2094
2095impl FixReport {
2096 pub fn any_changes(&self) -> bool {
2097 !self.spacing.is_empty() || !self.title_fixes.is_empty()
2098 }
2099}
2100
2101#[cfg(test)]
2102mod tests {
2103 use super::*;
2104 use assert_fs::prelude::*;
2105
2106 #[test]
2107 fn test_parse_nodes() -> Result<()> {
2108 let temp = assert_fs::TempDir::new()?;
2109 let file = temp.child("MINDMAP.md");
2110 file.write_str(
2111 "Header line\n[1] **AE: A** - refers to [2]\nSome note\n[2] **AE: B** - base\n",
2112 )?;
2113
2114 let mm = Mindmap::load(file.path().to_path_buf())?;
2115 assert_eq!(mm.nodes.len(), 2);
2116 assert!(mm.by_id.contains_key(&1));
2117 assert!(mm.by_id.contains_key(&2));
2118 let n1 = mm.get_node(1).unwrap();
2119 assert_eq!(n1.references, vec![Reference::Internal(2)]);
2120 temp.close()?;
2121 Ok(())
2122 }
2123
2124 #[test]
2125 fn test_save_atomic() -> Result<()> {
2126 let temp = assert_fs::TempDir::new()?;
2127 let file = temp.child("MINDMAP.md");
2128 file.write_str("[1] **AE: A** - base\n")?;
2129
2130 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2131 let id = mm.next_id();
2133 mm.lines.push(format!("[{}] **AE: C** - new\n", id));
2134 let node = Node {
2136 id,
2137 raw_title: "AE: C".to_string(),
2138 description: "new".to_string(),
2139 references: vec![],
2140 line_index: mm.lines.len() - 1,
2141 };
2142 mm.by_id.insert(id, mm.nodes.len());
2143 mm.nodes.push(node);
2144
2145 mm.save()?;
2146
2147 let content = std::fs::read_to_string(file.path())?;
2148 assert!(content.contains("AE: C"));
2149 temp.close()?;
2150 Ok(())
2151 }
2152
2153 #[test]
2154 fn test_lint_syntax_and_duplicates_and_orphan() -> Result<()> {
2155 let temp = assert_fs::TempDir::new()?;
2156 let file = temp.child("MINDMAP.md");
2157 file.write_str("[bad] not a node\n[1] **AE: A** - base\n[1] **AE: Adup** - dup\n[2] **AE: Orphan** - lonely\n")?;
2158
2159 let mm = Mindmap::load(file.path().to_path_buf())?;
2160 let warnings = cmd_lint(&mm)?;
2161 let joined = warnings.join("\n");
2163 assert!(joined.contains("Syntax"));
2164 assert!(joined.contains("Duplicate ID"));
2165
2166 let orphans = cmd_orphans(&mm)?;
2168 let joined_o = orphans.join("\n");
2169 assert!(joined_o.contains("2"));
2171
2172 temp.close()?;
2173 Ok(())
2174 }
2175
2176 #[test]
2177 fn test_put_and_patch_basic() -> Result<()> {
2178 let temp = assert_fs::TempDir::new()?;
2179 let file = temp.child("MINDMAP.md");
2180 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
2181
2182 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2183 cmd_patch(&mut mm, 1, Some("AE"), Some("OneNew"), None, false)?;
2185 assert_eq!(mm.get_node(1).unwrap().raw_title, "AE: OneNew");
2186
2187 let new_line = "[2] **DR: Replaced** - replaced desc [1]";
2189 cmd_put(&mut mm, 2, new_line, false)?;
2190 assert_eq!(mm.get_node(2).unwrap().raw_title, "DR: Replaced");
2191 assert_eq!(
2192 mm.get_node(2).unwrap().references,
2193 vec![Reference::Internal(1)]
2194 );
2195
2196 temp.close()?;
2197 Ok(())
2198 }
2199
2200 #[test]
2201 fn test_cmd_show() -> Result<()> {
2202 let temp = assert_fs::TempDir::new()?;
2203 let file = temp.child("MINDMAP.md");
2204 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
2205 let mm = Mindmap::load(file.path().to_path_buf())?;
2206 let out = cmd_show(&mm, 1);
2207 assert!(out.contains("[1] **AE: One**"));
2208 assert!(out.contains("Referred to by: [2]"));
2209 temp.close()?;
2210 Ok(())
2211 }
2212
2213 #[test]
2214 fn test_cmd_refs() -> Result<()> {
2215 let temp = assert_fs::TempDir::new()?;
2216 let file = temp.child("MINDMAP.md");
2217 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
2218 let mm = Mindmap::load(file.path().to_path_buf())?;
2219 let refs = cmd_refs(&mm, 1);
2220 assert_eq!(refs.len(), 1);
2221 assert!(refs[0].contains("[2] **AE: Two**"));
2222 temp.close()?;
2223 Ok(())
2224 }
2225
2226 #[test]
2227 fn test_cmd_links() -> Result<()> {
2228 let temp = assert_fs::TempDir::new()?;
2229 let file = temp.child("MINDMAP.md");
2230 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
2231 let mm = Mindmap::load(file.path().to_path_buf())?;
2232 let links = cmd_links(&mm, 2);
2233 assert_eq!(links, Some(vec![Reference::Internal(1)]));
2234 temp.close()?;
2235 Ok(())
2236 }
2237
2238 #[test]
2239 fn test_cmd_search() -> Result<()> {
2240 let temp = assert_fs::TempDir::new()?;
2241 let file = temp.child("MINDMAP.md");
2242 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
2243 let mm = Mindmap::load(file.path().to_path_buf())?;
2244 let results = cmd_search(&mm, "first");
2245 assert_eq!(results.len(), 1);
2246 assert!(results[0].contains("[1] **AE: One**"));
2247 temp.close()?;
2248 Ok(())
2249 }
2250
2251 #[test]
2252 fn test_cmd_add() -> Result<()> {
2253 let temp = assert_fs::TempDir::new()?;
2254 let file = temp.child("MINDMAP.md");
2255 file.write_str("[1] **AE: One** - first\n")?;
2256 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2257 let id = cmd_add(&mut mm, "AE", "Two", "second")?;
2258 assert_eq!(id, 2);
2259 assert_eq!(mm.nodes.len(), 2);
2260 let node = mm.get_node(2).unwrap();
2261 assert_eq!(node.raw_title, "AE: Two");
2262 temp.close()?;
2263 Ok(())
2264 }
2265
2266 #[test]
2267 fn test_cmd_deprecate() -> Result<()> {
2268 let temp = assert_fs::TempDir::new()?;
2269 let file = temp.child("MINDMAP.md");
2270 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
2271 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2272 cmd_deprecate(&mut mm, 1, 2)?;
2273 let node = mm.get_node(1).unwrap();
2274 assert!(node.raw_title.starts_with("[DEPRECATED → 2]"));
2275 temp.close()?;
2276 Ok(())
2277 }
2278
2279 #[test]
2280 fn test_cmd_verify() -> Result<()> {
2281 let temp = assert_fs::TempDir::new()?;
2282 let file = temp.child("MINDMAP.md");
2283 file.write_str("[1] **AE: One** - first\n")?;
2284 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2285 cmd_verify(&mut mm, 1)?;
2286 let node = mm.get_node(1).unwrap();
2287 assert!(node.description.contains("(verify"));
2288 temp.close()?;
2289 Ok(())
2290 }
2291
2292 #[test]
2293 fn test_cmd_show_non_existing() -> Result<()> {
2294 let temp = assert_fs::TempDir::new()?;
2295 let file = temp.child("MINDMAP.md");
2296 file.write_str("[1] **AE: One** - first\n")?;
2297 let mm = Mindmap::load(file.path().to_path_buf())?;
2298 let out = cmd_show(&mm, 99);
2299 assert_eq!(out, "Node 99 not found");
2300 temp.close()?;
2301 Ok(())
2302 }
2303
2304 #[test]
2305 fn test_cmd_refs_non_existing() -> Result<()> {
2306 let temp = assert_fs::TempDir::new()?;
2307 let file = temp.child("MINDMAP.md");
2308 file.write_str("[1] **AE: One** - first\n")?;
2309 let mm = Mindmap::load(file.path().to_path_buf())?;
2310 let refs = cmd_refs(&mm, 99);
2311 assert_eq!(refs.len(), 0);
2312 temp.close()?;
2313 Ok(())
2314 }
2315
2316 #[test]
2317 fn test_cmd_links_non_existing() -> Result<()> {
2318 let temp = assert_fs::TempDir::new()?;
2319 let file = temp.child("MINDMAP.md");
2320 file.write_str("[1] **AE: One** - first\n")?;
2321 let mm = Mindmap::load(file.path().to_path_buf())?;
2322 let links = cmd_links(&mm, 99);
2323 assert_eq!(links, None);
2324 temp.close()?;
2325 Ok(())
2326 }
2327
2328 #[test]
2329 fn test_cmd_put_non_existing() -> Result<()> {
2330 let temp = assert_fs::TempDir::new()?;
2331 let file = temp.child("MINDMAP.md");
2332 file.write_str("[1] **AE: One** - first\n")?;
2333 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2334 let err = cmd_put(&mut mm, 99, "[99] **AE: New** - new", false).unwrap_err();
2335 assert!(format!("{}", err).contains("Node 99 not found"));
2336 temp.close()?;
2337 Ok(())
2338 }
2339
2340 #[test]
2341 fn test_cmd_patch_non_existing() -> Result<()> {
2342 let temp = assert_fs::TempDir::new()?;
2343 let file = temp.child("MINDMAP.md");
2344 file.write_str("[1] **AE: One** - first\n")?;
2345 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2346 let err = cmd_patch(&mut mm, 99, None, Some("New"), None, false).unwrap_err();
2347 assert!(format!("{}", err).contains("Node 99 not found"));
2348 temp.close()?;
2349 Ok(())
2350 }
2351
2352 #[test]
2353 fn test_load_from_reader() -> Result<()> {
2354 use std::io::Cursor;
2355 let content = "[1] **AE: One** - first\n";
2356 let reader = Cursor::new(content);
2357 let path = PathBuf::from("-");
2358 let mm = Mindmap::load_from_reader(reader, path)?;
2359 assert_eq!(mm.nodes.len(), 1);
2360 assert_eq!(mm.nodes[0].id, 1);
2361 Ok(())
2362 }
2363
2364 #[test]
2365 fn test_next_id() -> Result<()> {
2366 let temp = assert_fs::TempDir::new()?;
2367 let file = temp.child("MINDMAP.md");
2368 file.write_str("[1] **AE: One** - first\n[3] **AE: Three** - third\n")?;
2369 let mm = Mindmap::load(file.path().to_path_buf())?;
2370 assert_eq!(mm.next_id(), 4);
2371 temp.close()?;
2372 Ok(())
2373 }
2374
2375 #[test]
2376 fn test_get_node() -> Result<()> {
2377 let temp = assert_fs::TempDir::new()?;
2378 let file = temp.child("MINDMAP.md");
2379 file.write_str("[1] **AE: One** - first\n")?;
2380 let mm = Mindmap::load(file.path().to_path_buf())?;
2381 let node = mm.get_node(1).unwrap();
2382 assert_eq!(node.id, 1);
2383 assert!(mm.get_node(99).is_none());
2384 temp.close()?;
2385 Ok(())
2386 }
2387
2388 #[test]
2389 fn test_cmd_orphans() -> Result<()> {
2390 let temp = assert_fs::TempDir::new()?;
2391 let file = temp.child("MINDMAP.md");
2392 file.write_str("[1] **AE: One** - first\n[2] **AE: Orphan** - lonely\n")?;
2393 let mm = Mindmap::load(file.path().to_path_buf())?;
2394 let orphans = cmd_orphans(&mm)?;
2395 assert_eq!(orphans, vec!["1".to_string(), "2".to_string()]);
2396 temp.close()?;
2397 Ok(())
2398 }
2399
2400 #[test]
2401 fn test_cmd_graph() -> Result<()> {
2402 let temp = assert_fs::TempDir::new()?;
2403 let file = temp.child("MINDMAP.md");
2404 file.write_str(
2405 "[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n[3] **AE: Three** - also [1]\n",
2406 )?;
2407 let mm = Mindmap::load(file.path().to_path_buf())?;
2408 let dot = cmd_graph(&mm, 1)?;
2409 assert!(dot.contains("digraph {"));
2410 assert!(dot.contains("1 [label=\"1: AE: One\"]"));
2411 assert!(dot.contains("2 [label=\"2: AE: Two\"]"));
2412 assert!(dot.contains("3 [label=\"3: AE: Three\"]"));
2413 assert!(dot.contains("2 -> 1;"));
2414 assert!(dot.contains("3 -> 1;"));
2415 temp.close()?;
2416 Ok(())
2417 }
2418
2419 #[test]
2420 fn test_save_stdin_path() -> Result<()> {
2421 let temp = assert_fs::TempDir::new()?;
2422 let file = temp.child("MINDMAP.md");
2423 file.write_str("[1] **AE: One** - first\n")?;
2424 let mut mm = Mindmap::load_from_reader(
2425 std::io::Cursor::new("[1] **AE: One** - first\n"),
2426 PathBuf::from("-"),
2427 )?;
2428 let err = mm.save().unwrap_err();
2429 assert!(format!("{}", err).contains("Cannot save"));
2430 temp.close()?;
2431 Ok(())
2432 }
2433
2434 #[test]
2435 fn test_extract_refs_from_str() {
2436 assert_eq!(
2437 extract_refs_from_str("no refs", None),
2438 vec![] as Vec<Reference>
2439 );
2440 assert_eq!(
2441 extract_refs_from_str("[1] and [2]", None),
2442 vec![Reference::Internal(1), Reference::Internal(2)]
2443 );
2444 assert_eq!(
2445 extract_refs_from_str("[1] and [1]", Some(1)),
2446 vec![] as Vec<Reference>
2447 ); assert_eq!(
2449 extract_refs_from_str("[abc] invalid [123]", None),
2450 vec![Reference::Internal(123)]
2451 );
2452 assert_eq!(
2453 extract_refs_from_str("[234](./file.md)", None),
2454 vec![Reference::External(234, "./file.md".to_string())]
2455 );
2456 }
2457
2458 #[test]
2459 fn test_normalize_adjacent_nodes() -> Result<()> {
2460 let temp = assert_fs::TempDir::new()?;
2461 let file = temp.child("MINDMAP.md");
2462 file.write_str("[1] **AE: A** - a\n[2] **AE: B** - b\n")?;
2463
2464 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2465 mm.save()?;
2466
2467 let content = std::fs::read_to_string(file.path())?;
2468 assert_eq!(content, "[1] **AE: A** - a\n\n[2] **AE: B** - b\n");
2469 assert_eq!(mm.get_node(2).unwrap().line_index, 2);
2471 temp.close()?;
2472 Ok(())
2473 }
2474
2475 #[test]
2476 fn test_normalize_idempotent() -> Result<()> {
2477 let temp = assert_fs::TempDir::new()?;
2478 let file = temp.child("MINDMAP.md");
2479 file.write_str("[1] **AE: A** - a\n[2] **AE: B** - b\n")?;
2480
2481 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2482 mm.normalize_spacing()?;
2483 let snapshot = mm.lines.clone();
2484 mm.normalize_spacing()?;
2485 assert_eq!(mm.lines, snapshot);
2486 temp.close()?;
2487 Ok(())
2488 }
2489
2490 #[test]
2491 fn test_preserve_non_node_lines() -> Result<()> {
2492 let temp = assert_fs::TempDir::new()?;
2493 let file = temp.child("MINDMAP.md");
2494 file.write_str("[1] **AE: A** - a\nHeader line\n[2] **AE: B** - b\n")?;
2495
2496 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2497 mm.save()?;
2498
2499 let content = std::fs::read_to_string(file.path())?;
2500 assert_eq!(
2502 content,
2503 "[1] **AE: A** - a\nHeader line\n[2] **AE: B** - b\n"
2504 );
2505 temp.close()?;
2506 Ok(())
2507 }
2508
2509 #[test]
2510 fn test_lint_fix_spacing() -> Result<()> {
2511 let temp = assert_fs::TempDir::new()?;
2512 let file = temp.child("MINDMAP.md");
2513 file.write_str("[1] **AE: A** - a\n[2] **AE: B** - b\n")?;
2514
2515 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2516 let report = mm.apply_fixes()?;
2517 assert!(!report.spacing.is_empty());
2518 assert_eq!(report.title_fixes.len(), 0);
2519 mm.save()?;
2520
2521 let content = std::fs::read_to_string(file.path())?;
2522 assert_eq!(content, "[1] **AE: A** - a\n\n[2] **AE: B** - b\n");
2523 temp.close()?;
2524 Ok(())
2525 }
2526
2527 #[test]
2528 fn test_lint_fix_duplicated_type() -> Result<()> {
2529 let temp = assert_fs::TempDir::new()?;
2530 let file = temp.child("MINDMAP.md");
2531 file.write_str("[1] **AE: AE: Auth** - desc\n")?;
2532
2533 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2534 let report = mm.apply_fixes()?;
2535 assert_eq!(report.title_fixes.len(), 1);
2536 assert_eq!(report.title_fixes[0].new, "AE: Auth");
2537 mm.save()?;
2538
2539 let content = std::fs::read_to_string(file.path())?;
2540 assert!(content.contains("[1] **AE: Auth** - desc"));
2541 temp.close()?;
2542 Ok(())
2543 }
2544
2545 #[test]
2546 fn test_lint_fix_combined() -> Result<()> {
2547 let temp = assert_fs::TempDir::new()?;
2548 let file = temp.child("MINDMAP.md");
2549 file.write_str("[1] **WF: WF: Workflow** - first\n[2] **AE: Auth** - second\n")?;
2550
2551 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2552 let report = mm.apply_fixes()?;
2553 assert!(!report.spacing.is_empty());
2554 assert_eq!(report.title_fixes.len(), 1);
2555 assert_eq!(report.title_fixes[0].id, 1);
2556 assert_eq!(report.title_fixes[0].new, "WF: Workflow");
2557 mm.save()?;
2558
2559 let content = std::fs::read_to_string(file.path())?;
2560 assert!(content.contains("[1] **WF: Workflow** - first"));
2561 assert!(content.contains("\n\n[2] **AE: Auth** - second"));
2562 temp.close()?;
2563 Ok(())
2564 }
2565
2566 #[test]
2567 fn test_lint_fix_idempotent() -> Result<()> {
2568 let temp = assert_fs::TempDir::new()?;
2569 let file = temp.child("MINDMAP.md");
2570 file.write_str("[1] **AE: AE: A** - a\n[2] **AE: B** - b\n")?;
2571
2572 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2573 let report1 = mm.apply_fixes()?;
2574 assert!(report1.any_changes());
2575
2576 let report2 = mm.apply_fixes()?;
2578 assert!(!report2.any_changes());
2579 temp.close()?;
2580 Ok(())
2581 }
2582
2583 #[test]
2584 fn test_lint_fix_collapse_multiple_blanks() -> Result<()> {
2585 let temp = assert_fs::TempDir::new()?;
2586 let file = temp.child("MINDMAP.md");
2587 file.write_str("[1] **AE: A** - a\n\n\n[2] **AE: B** - b\n")?;
2588
2589 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2590 let report = mm.apply_fixes()?;
2591 assert!(!report.spacing.is_empty());
2592 mm.save()?;
2593
2594 let content = std::fs::read_to_string(file.path())?;
2595 assert_eq!(content, "[1] **AE: A** - a\n\n[2] **AE: B** - b\n");
2597 temp.close()?;
2598 Ok(())
2599 }
2600
2601 #[test]
2602 fn test_batch_op_parse_line_add() -> Result<()> {
2603 let line = "add --type WF --title Test --desc desc";
2604 let op = parse_batch_op_line(line)?;
2605 match op {
2606 BatchOp::Add {
2607 type_prefix,
2608 title,
2609 desc,
2610 } => {
2611 assert_eq!(type_prefix, "WF");
2612 assert_eq!(title, "Test");
2613 assert_eq!(desc, "desc");
2614 }
2615 _ => panic!("Expected Add op"),
2616 }
2617 Ok(())
2618 }
2619
2620 #[test]
2621 fn test_batch_op_parse_line_patch() -> Result<()> {
2622 let line = "patch 1 --title NewTitle";
2623 let op = parse_batch_op_line(line)?;
2624 match op {
2625 BatchOp::Patch {
2626 id,
2627 title,
2628 type_prefix,
2629 desc,
2630 } => {
2631 assert_eq!(id, 1);
2632 assert_eq!(title, Some("NewTitle".to_string()));
2633 assert_eq!(type_prefix, None);
2634 assert_eq!(desc, None);
2635 }
2636 _ => panic!("Expected Patch op"),
2637 }
2638 Ok(())
2639 }
2640
2641 #[test]
2642 fn test_batch_op_parse_line_delete() -> Result<()> {
2643 let line = "delete 5 --force";
2644 let op = parse_batch_op_line(line)?;
2645 match op {
2646 BatchOp::Delete { id, force } => {
2647 assert_eq!(id, 5);
2648 assert!(force);
2649 }
2650 _ => panic!("Expected Delete op"),
2651 }
2652 Ok(())
2653 }
2654
2655 #[test]
2656 fn test_batch_hash_concurrency_check() -> Result<()> {
2657 let content1 = "hello world";
2659 let content2 = "hello world";
2660 let content3 = "hello world!";
2661
2662 let hash1 = blake3_hash(content1.as_bytes());
2663 let hash2 = blake3_hash(content2.as_bytes());
2664 let hash3 = blake3_hash(content3.as_bytes());
2665
2666 assert_eq!(hash1, hash2); assert_ne!(hash1, hash3); Ok(())
2669 }
2670
2671 #[test]
2672 fn test_batch_simple_add() -> Result<()> {
2673 let temp = assert_fs::TempDir::new()?;
2674 let file = temp.child("MINDMAP.md");
2675 file.write_str("[1] **AE: A** - a\n")?;
2676
2677 let batch_input = r#"add --type WF --title Work --desc "do work""#;
2679 let ops = vec![parse_batch_op_line(batch_input)?];
2680
2681 let mut mm = Mindmap::load(file.path().to_path_buf())?;
2682 for op in ops {
2683 match op {
2684 BatchOp::Add {
2685 type_prefix,
2686 title,
2687 desc,
2688 } => {
2689 cmd_add(&mut mm, &type_prefix, &title, &desc)?;
2690 }
2691 _ => {}
2692 }
2693 }
2694 mm.save()?;
2695
2696 let content = std::fs::read_to_string(file.path())?;
2697 assert!(content.contains("WF: Work") && content.contains("do work"));
2698 temp.close()?;
2699 Ok(())
2700 }
2701}