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