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
129 Orphans,
131
132 Graph { id: u32 },
134}
135
136#[derive(Debug, Clone)]
137pub struct Node {
138 pub id: u32,
139 pub raw_title: String,
140 pub description: String,
141 pub references: Vec<Reference>,
142 pub line_index: usize,
143}
144
145#[derive(Debug, Clone, PartialEq, serde::Serialize)]
146pub enum Reference {
147 Internal(u32),
148 External(u32, String),
149}
150
151pub struct Mindmap {
152 pub path: PathBuf,
153 pub lines: Vec<String>,
154 pub nodes: Vec<Node>,
155 pub by_id: HashMap<u32, usize>,
156}
157
158impl Mindmap {
159 pub fn load(path: PathBuf) -> Result<Self> {
160 let content = fs::read_to_string(&path)
162 .with_context(|| format!("Failed to read file {}", path.display()))?;
163 Self::from_string(content, path)
164 }
165
166 pub fn load_from_reader<R: Read>(mut reader: R, path: PathBuf) -> Result<Self> {
169 let mut content = String::new();
170 reader.read_to_string(&mut content)?;
171 Self::from_string(content, path)
172 }
173
174 fn from_string(content: String, path: PathBuf) -> Result<Self> {
175 let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
176
177 let mut nodes = Vec::new();
178 let mut by_id = HashMap::new();
179
180 for (i, line) in lines.iter().enumerate() {
181 if let Ok(node) = parse_node_line(line, i) {
182 if by_id.contains_key(&node.id) {
183 eprintln!("Warning: duplicate node id {} at line {}", node.id, i + 1);
184 }
185 by_id.insert(node.id, nodes.len());
186 nodes.push(node);
187 }
188 }
189
190 Ok(Mindmap {
191 path,
192 lines,
193 nodes,
194 by_id,
195 })
196 }
197
198 pub fn save(&self) -> Result<()> {
199 if self.path.as_os_str() == "-" {
201 return Err(anyhow::anyhow!(
202 "Cannot save: mindmap was loaded from stdin ('-'); use --file <path> to save changes"
203 ));
204 }
205
206 let dir = self
208 .path
209 .parent()
210 .map(|p| p.to_path_buf())
211 .unwrap_or_else(|| PathBuf::from("."));
212 let mut tmp = tempfile::NamedTempFile::new_in(&dir)
213 .with_context(|| format!("Failed to create temp file in {}", dir.display()))?;
214 let content = self.lines.join("\n") + "\n";
215 use std::io::Write;
216 tmp.write_all(content.as_bytes())?;
217 tmp.flush()?;
218 tmp.persist(&self.path)
219 .with_context(|| format!("Failed to persist temp file to {}", self.path.display()))?;
220 Ok(())
221 }
222
223 pub fn next_id(&self) -> u32 {
224 self.by_id.keys().max().copied().unwrap_or(0) + 1
225 }
226
227 pub fn get_node(&self, id: u32) -> Option<&Node> {
228 self.by_id.get(&id).map(|&idx| &self.nodes[idx])
229 }
230}
231
232pub fn parse_node_line(line: &str, line_index: usize) -> Result<Node> {
235 let trimmed = line.trim_start();
237 if !trimmed.starts_with('[') {
238 return Err(anyhow::anyhow!("Line does not match node format"));
239 }
240
241 let end_bracket = match trimmed.find(']') {
243 Some(pos) => pos,
244 None => return Err(anyhow::anyhow!("Line does not match node format")),
245 };
246
247 let id_str = &trimmed[1..end_bracket];
248 let id: u32 = id_str.parse()?;
249
250 let mut pos = end_bracket + 1;
252 let chars = trimmed.as_bytes();
253 if chars.get(pos).map(|b| *b as char) == Some(' ') {
254 pos += 1;
255 } else {
256 return Err(anyhow::anyhow!("Line does not match node format"));
257 }
258
259 if trimmed.get(pos..pos + 2) != Some("**") {
261 return Err(anyhow::anyhow!("Line does not match node format"));
262 }
263 pos += 2;
264
265 let rem = &trimmed[pos..];
267 let title_rel_end = match rem.find("**") {
268 Some(p) => p,
269 None => return Err(anyhow::anyhow!("Line does not match node format")),
270 };
271 let title = rem[..title_rel_end].to_string();
272 pos += title_rel_end + 2; if trimmed.get(pos..pos + 3) != Some(" - ") {
276 return Err(anyhow::anyhow!("Line does not match node format"));
277 }
278 pos += 3;
279
280 let description = trimmed[pos..].to_string();
281
282 let references = extract_refs_from_str(&description, Some(id));
284
285 Ok(Node {
286 id,
287 raw_title: title,
288 description,
289 references,
290 line_index,
291 })
292}
293
294fn extract_refs_from_str(s: &str, skip_self: Option<u32>) -> Vec<Reference> {
297 let mut refs = Vec::new();
298 let mut i = 0usize;
299 while i < s.len() {
300 if let Some(rel) = s[i..].find('[') {
302 let start = i + rel;
303 if let Some(rel_end) = s[start..].find(']') {
304 let end = start + rel_end;
305 let idslice = &s[start + 1..end];
306 if !idslice.is_empty()
307 && idslice.chars().all(|c| c.is_ascii_digit())
308 && let Ok(rid) = idslice.parse::<u32>()
309 && Some(rid) != skip_self
310 {
311 let after = &s[end..];
313 if after.starts_with("](") {
314 if let Some(paren_end) = after.find(')') {
316 let path_start = end + 2; let path_end = end + paren_end;
318 let path = &s[path_start..path_end];
319 refs.push(Reference::External(rid, path.to_string()));
320 i = path_end + 1;
321 continue;
322 }
323 }
324 refs.push(Reference::Internal(rid));
326 }
327 i = end + 1;
328 continue;
329 } else {
330 break; }
332 } else {
333 break;
334 }
335 }
336 refs
337}
338
339pub fn cmd_show(mm: &Mindmap, id: u32) -> String {
342 if let Some(node) = mm.get_node(id) {
343 let mut out = format!(
344 "[{}] **{}** - {}",
345 node.id, node.raw_title, node.description
346 );
347
348 let mut inbound = Vec::new();
350 for n in &mm.nodes {
351 if n.references
352 .iter()
353 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
354 {
355 inbound.push(n.id);
356 }
357 }
358 if !inbound.is_empty() {
359 out.push_str(&format!("\nReferred to by: {:?}", inbound));
360 }
361 out
362 } else {
363 format!("Node {} not found", id)
364 }
365}
366
367pub fn cmd_list(mm: &Mindmap, type_filter: Option<&str>, grep: Option<&str>) -> Vec<String> {
368 let mut res = Vec::new();
369 for n in &mm.nodes {
370 if let Some(tf) = type_filter
371 && !n.raw_title.starts_with(&format!("{}:", tf))
372 {
373 continue;
374 }
375 if let Some(q) = grep {
376 let qlc = q.to_lowercase();
377 if !n.raw_title.to_lowercase().contains(&qlc)
378 && !n.description.to_lowercase().contains(&qlc)
379 {
380 continue;
381 }
382 }
383 res.push(format!(
384 "[{}] **{}** - {}",
385 n.id, n.raw_title, n.description
386 ));
387 }
388 res
389}
390
391pub fn cmd_refs(mm: &Mindmap, id: u32) -> Vec<String> {
392 let mut out = Vec::new();
393 for n in &mm.nodes {
394 if n.references
395 .iter()
396 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
397 {
398 out.push(format!(
399 "[{}] **{}** - {}",
400 n.id, n.raw_title, n.description
401 ));
402 }
403 }
404 out
405}
406
407pub fn cmd_links(mm: &Mindmap, id: u32) -> Option<Vec<Reference>> {
408 mm.get_node(id).map(|n| n.references.clone())
409}
410
411pub fn cmd_search(mm: &Mindmap, query: &str) -> Vec<String> {
412 let qlc = query.to_lowercase();
413 let mut out = Vec::new();
414 for n in &mm.nodes {
415 if n.raw_title.to_lowercase().contains(&qlc) || n.description.to_lowercase().contains(&qlc)
416 {
417 out.push(format!(
418 "[{}] **{}** - {}",
419 n.id, n.raw_title, n.description
420 ));
421 }
422 }
423 out
424}
425
426pub fn cmd_add(mm: &mut Mindmap, type_prefix: &str, title: &str, desc: &str) -> Result<u32> {
427 let id = mm.next_id();
428 let full_title = format!("{}: {}", type_prefix, title);
429 let line = format!("[{}] **{}** - {}", id, full_title, desc);
430
431 mm.lines.push(line.clone());
432
433 let line_index = mm.lines.len() - 1;
434 let references = extract_refs_from_str(desc, Some(id));
435
436 let node = Node {
437 id,
438 raw_title: full_title,
439 description: desc.to_string(),
440 references,
441 line_index,
442 };
443 mm.by_id.insert(id, mm.nodes.len());
444 mm.nodes.push(node);
445
446 Ok(id)
447}
448
449pub fn cmd_add_editor(mm: &mut Mindmap, editor: &str, strict: bool) -> Result<u32> {
450 if !atty::is(atty::Stream::Stdin) {
452 return Err(anyhow::anyhow!(
453 "add via editor requires an interactive terminal"
454 ));
455 }
456
457 let id = mm.next_id();
458 let template = format!("[{}] **TYPE: Title** - description", id);
459
460 let mut tmp = tempfile::NamedTempFile::new()
462 .with_context(|| "Failed to create temp file for add editor")?;
463 use std::io::Write;
464 writeln!(tmp, "{}", template)?;
465 tmp.flush()?;
466
467 let status = std::process::Command::new(editor)
469 .arg(tmp.path())
470 .status()
471 .with_context(|| "Failed to launch editor")?;
472 if !status.success() {
473 return Err(anyhow::anyhow!("Editor exited with non-zero status"));
474 }
475
476 let edited = std::fs::read_to_string(tmp.path())?;
478 let nonempty: Vec<&str> = edited
479 .lines()
480 .map(|l| l.trim())
481 .filter(|l| !l.is_empty())
482 .collect();
483 if nonempty.is_empty() {
484 return Err(anyhow::anyhow!("No content written in editor"));
485 }
486 if nonempty.len() > 1 {
487 return Err(anyhow::anyhow!(
488 "Expected exactly one node line in editor; found multiple lines"
489 ));
490 }
491 let line = nonempty[0];
492
493 let parsed = parse_node_line(line, mm.lines.len())?;
495 if parsed.id != id {
496 return Err(anyhow::anyhow!(format!(
497 "Added line id changed; expected [{}]",
498 id
499 )));
500 }
501
502 if strict {
503 for r in &parsed.references {
504 if let Reference::Internal(iid) = r
505 && !mm.by_id.contains_key(iid) {
506 return Err(anyhow::anyhow!(format!(
507 "ADD strict: reference to missing node {}",
508 iid
509 )));
510 }
511 }
512 }
513
514 mm.lines.push(line.to_string());
516 let line_index = mm.lines.len() - 1;
517 let node = Node {
518 id: parsed.id,
519 raw_title: parsed.raw_title,
520 description: parsed.description,
521 references: parsed.references,
522 line_index,
523 };
524 mm.by_id.insert(id, mm.nodes.len());
525 mm.nodes.push(node);
526
527 Ok(id)
528}
529
530pub fn cmd_deprecate(mm: &mut Mindmap, id: u32, to: u32) -> Result<()> {
531 let idx = *mm
532 .by_id
533 .get(&id)
534 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
535
536 if !mm.by_id.contains_key(&to) {
537 eprintln!(
538 "Warning: target node {} does not exist (still updating title)",
539 to
540 );
541 }
542
543 let node = &mut mm.nodes[idx];
544 if !node.raw_title.starts_with("[DEPRECATED") {
545 node.raw_title = format!("[DEPRECATED → {}] {}", to, node.raw_title);
546 mm.lines[node.line_index] = format!(
547 "[{}] **{}** - {}",
548 node.id, node.raw_title, node.description
549 );
550 }
551
552 Ok(())
553}
554
555pub fn cmd_verify(mm: &mut Mindmap, id: u32) -> Result<()> {
556 let idx = *mm
557 .by_id
558 .get(&id)
559 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
560 let node = &mut mm.nodes[idx];
561
562 let tag = format!("(verify {})", chrono::Local::now().format("%Y-%m-%d"));
563 if !node.description.contains("(verify ") {
564 if node.description.is_empty() {
565 node.description = tag.clone();
566 } else {
567 node.description = format!("{} {}", node.description, tag);
568 }
569 mm.lines[node.line_index] = format!(
570 "[{}] **{}** - {}",
571 node.id, node.raw_title, node.description
572 );
573 }
574 Ok(())
575}
576
577pub fn cmd_edit(mm: &mut Mindmap, id: u32, editor: &str) -> Result<()> {
578 let idx = *mm
579 .by_id
580 .get(&id)
581 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
582 let node = &mm.nodes[idx];
583
584 let mut tmp =
586 tempfile::NamedTempFile::new().with_context(|| "Failed to create temp file for editing")?;
587 use std::io::Write;
588 writeln!(
589 tmp,
590 "[{}] **{}** - {}",
591 node.id, node.raw_title, node.description
592 )?;
593 tmp.flush()?;
594
595 let status = std::process::Command::new(editor)
597 .arg(tmp.path())
598 .status()
599 .with_context(|| "Failed to launch editor")?;
600 if !status.success() {
601 return Err(anyhow::anyhow!("Editor exited with non-zero status"));
602 }
603
604 let edited = std::fs::read_to_string(tmp.path())?;
606 let edited_line = edited.lines().next().unwrap_or("").trim();
607
608 let parsed = parse_node_line(edited_line, node.line_index)?;
610 if parsed.id != id {
611 return Err(anyhow::anyhow!("Cannot change node ID"));
612 }
613
614 mm.lines[node.line_index] = edited_line.to_string();
616 let new_title = parsed.raw_title;
617 let new_desc = parsed.description;
618 let new_refs = parsed.references;
619
620 let node_mut = &mut mm.nodes[idx];
622 node_mut.raw_title = new_title;
623 node_mut.description = new_desc;
624 node_mut.references = new_refs;
625
626 Ok(())
627}
628
629pub fn cmd_put(mm: &mut Mindmap, id: u32, line: &str, strict: bool) -> Result<()> {
630 let idx = *mm
632 .by_id
633 .get(&id)
634 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
635
636 let parsed = parse_node_line(line, mm.nodes[idx].line_index)?;
637 if parsed.id != id {
638 return Err(anyhow::anyhow!("PUT line id does not match target id"));
639 }
640
641 if strict {
643 for r in &parsed.references {
644 if let Reference::Internal(iid) = r
645 && !mm.by_id.contains_key(iid) {
646 return Err(anyhow::anyhow!(format!(
647 "PUT strict: reference to missing node {}",
648 iid
649 )));
650 }
651 }
652 }
653
654 mm.lines[mm.nodes[idx].line_index] = line.to_string();
656 let node_mut = &mut mm.nodes[idx];
657 node_mut.raw_title = parsed.raw_title;
658 node_mut.description = parsed.description;
659 node_mut.references = parsed.references;
660
661 Ok(())
662}
663
664pub fn cmd_patch(
665 mm: &mut Mindmap,
666 id: u32,
667 typ: Option<&str>,
668 title: Option<&str>,
669 desc: Option<&str>,
670 strict: bool,
671) -> Result<()> {
672 let idx = *mm
673 .by_id
674 .get(&id)
675 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
676 let node = &mm.nodes[idx];
677
678 let mut existing_type: Option<&str> = None;
680 let mut existing_title = node.raw_title.as_str();
681 if let Some(pos) = node.raw_title.find(':') {
682 existing_type = Some(node.raw_title[..pos].trim());
683 existing_title = node.raw_title[pos + 1..].trim();
684 }
685
686 let new_type = typ.unwrap_or(existing_type.unwrap_or(""));
687 let new_title = title.unwrap_or(existing_title);
688 let new_desc = desc.unwrap_or(&node.description);
689
690 let new_raw_title = if new_type.is_empty() {
692 new_title.to_string()
693 } else {
694 format!("{}: {}", new_type, new_title)
695 };
696
697 let new_line = format!("[{}] **{}** - {}", id, new_raw_title, new_desc);
698
699 let parsed = parse_node_line(&new_line, node.line_index)?;
701 if parsed.id != id {
702 return Err(anyhow::anyhow!("Patch resulted in different id"));
703 }
704
705 if strict {
706 for r in &parsed.references {
707 if let Reference::Internal(iid) = r
708 && !mm.by_id.contains_key(iid) {
709 return Err(anyhow::anyhow!(format!(
710 "PATCH strict: reference to missing node {}",
711 iid
712 )));
713 }
714 }
715 }
716
717 mm.lines[node.line_index] = new_line;
719 let node_mut = &mut mm.nodes[idx];
720 node_mut.raw_title = parsed.raw_title;
721 node_mut.description = parsed.description;
722 node_mut.references = parsed.references;
723
724 Ok(())
725}
726
727pub fn cmd_delete(mm: &mut Mindmap, id: u32, force: bool) -> Result<()> {
728 let idx = *mm
730 .by_id
731 .get(&id)
732 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
733
734 let mut incoming_from = Vec::new();
736 for n in &mm.nodes {
737 if n.references
738 .iter()
739 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
740 {
741 incoming_from.push(n.id);
742 }
743 }
744 if !incoming_from.is_empty() && !force {
745 return Err(anyhow::anyhow!(format!(
746 "Node {} is referenced by {:?}; use --force to delete",
747 id, incoming_from
748 )));
749 }
750
751 let line_idx = mm.nodes[idx].line_index;
753 mm.lines.remove(line_idx);
754
755 mm.nodes.remove(idx);
757
758 mm.by_id.clear();
760 for (i, node) in mm.nodes.iter_mut().enumerate() {
761 if node.line_index > line_idx {
763 node.line_index -= 1;
764 }
765 mm.by_id.insert(node.id, i);
766 }
767
768 Ok(())
769}
770
771pub fn cmd_lint(mm: &Mindmap) -> Result<Vec<String>> {
772 let mut warnings = Vec::new();
773
774 for (i, line) in mm.lines.iter().enumerate() {
776 let trimmed = line.trim_start();
777 if trimmed.starts_with('[') && parse_node_line(trimmed, i).is_err() {
778 warnings.push(format!(
779 "Syntax: line {} starts with '[' but does not match node format",
780 i + 1
781 ));
782 }
783 }
784
785 let mut id_map: HashMap<u32, Vec<usize>> = HashMap::new();
787 for (i, line) in mm.lines.iter().enumerate() {
788 if let Ok(node) = parse_node_line(line, i) {
789 id_map.entry(node.id).or_default().push(i + 1);
790 }
791 }
792 for (id, locations) in &id_map {
793 if locations.len() > 1 {
794 warnings.push(format!(
795 "Duplicate ID: node {} appears on lines {:?}",
796 id, locations
797 ));
798 }
799 }
800
801 for n in &mm.nodes {
803 for r in &n.references {
804 match r {
805 Reference::Internal(iid) => {
806 if !mm.by_id.contains_key(iid) {
807 warnings.push(format!(
808 "Missing ref: node {} references missing node {}",
809 n.id, iid
810 ));
811 }
812 }
813 Reference::External(eid, file) => {
814 if !std::path::Path::new(file).exists() {
815 warnings.push(format!(
816 "Missing file: node {} references {} in missing file {}",
817 n.id, eid, file
818 ));
819 }
820 }
821 }
822 }
823 }
824
825 if warnings.is_empty() {
826 Ok(vec!["Lint OK".to_string()])
827 } else {
828 Ok(warnings)
829 }
830}
831
832pub fn cmd_orphans(mm: &Mindmap) -> Result<Vec<String>> {
833 let mut warnings = Vec::new();
834
835 let mut incoming: HashMap<u32, usize> = HashMap::new();
837 for n in &mm.nodes {
838 incoming.entry(n.id).or_insert(0);
839 }
840 for n in &mm.nodes {
841 for r in &n.references {
842 if let Reference::Internal(iid) = r
843 && incoming.contains_key(iid) {
844 *incoming.entry(*iid).or_insert(0) += 1;
845 }
846 }
847 }
848 for n in &mm.nodes {
849 let inc = incoming.get(&n.id).copied().unwrap_or(0);
850 let out = n.references.len();
851 let title_up = n.raw_title.to_uppercase();
852 if inc == 0 && out == 0 && !title_up.starts_with("META") {
853 warnings.push(format!("{}", n.id));
854 }
855 }
856
857 if warnings.is_empty() {
858 Ok(vec!["No orphans".to_string()])
859 } else {
860 Ok(warnings)
861 }
862}
863
864pub fn cmd_graph(mm: &Mindmap, id: u32) -> Result<String> {
865 if !mm.by_id.contains_key(&id) {
866 return Err(anyhow::anyhow!(format!("Node {} not found", id)));
867 }
868
869 let mut nodes = std::collections::HashSet::new();
871 nodes.insert(id);
872
873 if let Some(node) = mm.get_node(id) {
875 for r in &node.references {
876 if let Reference::Internal(rid) = r {
877 nodes.insert(*rid);
878 }
879 }
880 }
881
882 for n in &mm.nodes {
884 for r in &n.references {
885 if let Reference::Internal(rid) = r
886 && *rid == id {
887 nodes.insert(n.id);
888 }
889 }
890 }
891
892 let mut dot = String::new();
894 dot.push_str("digraph {\n");
895 dot.push_str(" rankdir=LR;\n");
896
897 for &nid in &nodes {
899 if let Some(node) = mm.get_node(nid) {
900 let label = format!("{}: {}", node.id, node.raw_title.replace("\"", "\\\""));
901 dot.push_str(&format!(" {} [label=\"{}\"];\n", nid, label));
902 }
903 }
904
905 for &nid in &nodes {
907 if let Some(node) = mm.get_node(nid) {
908 for r in &node.references {
909 if let Reference::Internal(rid) = r
910 && nodes.contains(rid) {
911 dot.push_str(&format!(" {} -> {};\n", nid, rid));
912 }
913 }
914 }
915 }
916
917 dot.push_str("}\n");
918 Ok(dot)
919}
920
921pub fn run(cli: Cli) -> Result<()> {
924 let path = cli.file.unwrap_or_else(|| PathBuf::from("MINDMAP.md"));
925
926 let mut mm = if path.as_os_str() == "-" {
928 Mindmap::load_from_reader(std::io::stdin(), path.clone())?
929 } else {
930 Mindmap::load(path.clone())?
931 };
932
933 let interactive = atty::is(atty::Stream::Stdout);
935 let env_override = std::env::var("MINDMAP_PRETTY").ok();
936 let pretty_enabled = match env_override.as_deref() {
937 Some("0") => false,
938 Some("1") => true,
939 _ => interactive,
940 } && matches!(cli.output, OutputFormat::Default);
941
942 let printer: Option<Box<dyn ui::Printer>> = if matches!(cli.output, OutputFormat::Default) {
943 if pretty_enabled {
944 Some(Box::new(crate::ui::PrettyPrinter::new()?))
945 } else {
946 Some(Box::new(crate::ui::PlainPrinter::new()?))
947 }
948 } else {
949 None
950 };
951
952 let cannot_write_err = |cmd_name: &str| -> anyhow::Error {
954 anyhow::anyhow!(format!(
955 "Cannot {}: mindmap was loaded from stdin ('-'); use --file <path> to save changes",
956 cmd_name
957 ))
958 };
959
960 match cli.command {
961 Commands::Show { id } => match mm.get_node(id) {
962 Some(node) => {
963 if matches!(cli.output, OutputFormat::Json) {
964 let obj = serde_json::json!({
965 "command": "show",
966 "node": {
967 "id": node.id,
968 "raw_title": node.raw_title,
969 "description": node.description,
970 "references": node.references,
971 "line_index": node.line_index,
972 }
973 });
974 println!("{}", serde_json::to_string_pretty(&obj)?);
975 } else {
976 let mut inbound = Vec::new();
978 for n in &mm.nodes {
979 if n.references
980 .iter()
981 .any(|r| matches!(r, Reference::Internal(iid) if *iid == id))
982 {
983 inbound.push(n.id);
984 }
985 }
986
987 if let Some(p) = &printer {
988 p.show(node, &inbound, &node.references)?;
989 } else {
990 println!(
991 "[{}] **{}** - {}",
992 node.id, node.raw_title, node.description
993 );
994 if !inbound.is_empty() {
995 eprintln!("Referred to by: {:?}", inbound);
996 }
997 }
998 }
999 }
1000 None => return Err(anyhow::anyhow!(format!("Node {} not found", id))),
1001 },
1002 Commands::List { r#type, grep } => {
1003 let items = cmd_list(&mm, r#type.as_deref(), grep.as_deref());
1004 if matches!(cli.output, OutputFormat::Json) {
1005 let arr: Vec<_> = items
1006 .into_iter()
1007 .map(|line| serde_json::json!({"line": line}))
1008 .collect();
1009 let obj = serde_json::json!({"command": "list", "items": arr});
1010 println!("{}", serde_json::to_string_pretty(&obj)?);
1011 } else if let Some(p) = &printer {
1012 p.list(&items)?;
1013 } else {
1014 for it in items {
1015 println!("{}", it);
1016 }
1017 }
1018 }
1019 Commands::Refs { id } => {
1020 let items = cmd_refs(&mm, id);
1021 if matches!(cli.output, OutputFormat::Json) {
1022 let obj = serde_json::json!({"command": "refs", "items": items});
1023 println!("{}", serde_json::to_string_pretty(&obj)?);
1024 } else if let Some(p) = &printer {
1025 p.refs(&items)?;
1026 } else {
1027 for it in items {
1028 println!("{}", it);
1029 }
1030 }
1031 }
1032 Commands::Links { id } => match cmd_links(&mm, id) {
1033 Some(v) => {
1034 if matches!(cli.output, OutputFormat::Json) {
1035 let obj = serde_json::json!({"command": "links", "id": id, "links": v});
1036 println!("{}", serde_json::to_string_pretty(&obj)?);
1037 } else if let Some(p) = &printer {
1038 p.links(id, &v)?;
1039 } else {
1040 println!("Node [{}] references: {:?}", id, v);
1041 }
1042 }
1043 None => return Err(anyhow::anyhow!(format!("Node [{}] not found", id))),
1044 },
1045 Commands::Search { query } => {
1046 let items = cmd_search(&mm, &query);
1047 if matches!(cli.output, OutputFormat::Json) {
1048 let obj = serde_json::json!({"command": "search", "query": query, "items": items});
1049 println!("{}", serde_json::to_string_pretty(&obj)?);
1050 } else if let Some(p) = &printer {
1051 p.search(&items)?;
1052 } else {
1053 for it in items {
1054 println!("{}", it);
1055 }
1056 }
1057 }
1058 Commands::Add {
1059 r#type,
1060 title,
1061 desc,
1062 strict,
1063 } => {
1064 if mm.path.as_os_str() == "-" {
1065 return Err(cannot_write_err("add"));
1066 }
1067 match (r#type.as_deref(), title.as_deref(), desc.as_deref()) {
1068 (Some(tp), Some(tt), Some(dd)) => {
1069 let id = cmd_add(&mut mm, tp, tt, dd)?;
1070 mm.save()?;
1071 if matches!(cli.output, OutputFormat::Json)
1072 && let Some(node) = mm.get_node(id)
1073 {
1074 let obj = serde_json::json!({"command": "add", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1075 println!("{}", serde_json::to_string_pretty(&obj)?);
1076 }
1077 eprintln!("Added node [{}]", id);
1078 }
1079 (None, None, None) => {
1080 if !atty::is(atty::Stream::Stdin) {
1082 return Err(anyhow::anyhow!(
1083 "add via editor requires an interactive terminal"
1084 ));
1085 }
1086 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
1087 let id = cmd_add_editor(&mut mm, &editor, strict)?;
1088 mm.save()?;
1089 if matches!(cli.output, OutputFormat::Json)
1090 && let Some(node) = mm.get_node(id)
1091 {
1092 let obj = serde_json::json!({"command": "add", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1093 println!("{}", serde_json::to_string_pretty(&obj)?);
1094 }
1095 eprintln!("Added node [{}]", id);
1096 }
1097 _ => {
1098 return Err(anyhow::anyhow!(
1099 "add requires either all of --type,--title,--desc or none (editor)"
1100 ));
1101 }
1102 }
1103 }
1104 Commands::Deprecate { id, to } => {
1105 if mm.path.as_os_str() == "-" {
1106 return Err(cannot_write_err("deprecate"));
1107 }
1108 cmd_deprecate(&mut mm, id, to)?;
1109 mm.save()?;
1110 if matches!(cli.output, OutputFormat::Json)
1111 && let Some(node) = mm.get_node(id)
1112 {
1113 let obj = serde_json::json!({"command": "deprecate", "node": {"id": node.id, "raw_title": node.raw_title}});
1114 println!("{}", serde_json::to_string_pretty(&obj)?);
1115 }
1116 eprintln!("Deprecated node [{}] → [{}]", id, to);
1117 }
1118 Commands::Edit { id } => {
1119 if mm.path.as_os_str() == "-" {
1120 return Err(cannot_write_err("edit"));
1121 }
1122 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
1123 cmd_edit(&mut mm, id, &editor)?;
1124 mm.save()?;
1125 if matches!(cli.output, OutputFormat::Json)
1126 && let Some(node) = mm.get_node(id)
1127 {
1128 let obj = serde_json::json!({"command": "edit", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1129 println!("{}", serde_json::to_string_pretty(&obj)?);
1130 }
1131 eprintln!("Edited node [{}]", id);
1132 }
1133 Commands::Patch {
1134 id,
1135 r#type,
1136 title,
1137 desc,
1138 strict,
1139 } => {
1140 if mm.path.as_os_str() == "-" {
1141 return Err(cannot_write_err("patch"));
1142 }
1143 cmd_patch(
1144 &mut mm,
1145 id,
1146 r#type.as_deref(),
1147 title.as_deref(),
1148 desc.as_deref(),
1149 strict,
1150 )?;
1151 mm.save()?;
1152 if matches!(cli.output, OutputFormat::Json)
1153 && let Some(node) = mm.get_node(id)
1154 {
1155 let obj = serde_json::json!({"command": "patch", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1156 println!("{}", serde_json::to_string_pretty(&obj)?);
1157 }
1158 eprintln!("Patched node [{}]", id);
1159 }
1160 Commands::Put { id, line, strict } => {
1161 if mm.path.as_os_str() == "-" {
1162 return Err(cannot_write_err("put"));
1163 }
1164 cmd_put(&mut mm, id, &line, strict)?;
1165 mm.save()?;
1166 if matches!(cli.output, OutputFormat::Json)
1167 && let Some(node) = mm.get_node(id)
1168 {
1169 let obj = serde_json::json!({"command": "put", "node": {"id": node.id, "raw_title": node.raw_title, "description": node.description, "references": node.references}});
1170 println!("{}", serde_json::to_string_pretty(&obj)?);
1171 }
1172 eprintln!("Put node [{}]", id);
1173 }
1174 Commands::Verify { id } => {
1175 if mm.path.as_os_str() == "-" {
1176 return Err(cannot_write_err("verify"));
1177 }
1178 cmd_verify(&mut mm, id)?;
1179 mm.save()?;
1180 if matches!(cli.output, OutputFormat::Json)
1181 && let Some(node) = mm.get_node(id)
1182 {
1183 let obj = serde_json::json!({"command": "verify", "node": {"id": node.id, "description": node.description}});
1184 println!("{}", serde_json::to_string_pretty(&obj)?);
1185 }
1186 eprintln!("Marked node [{}] for verification", id);
1187 }
1188 Commands::Delete { id, force } => {
1189 if mm.path.as_os_str() == "-" {
1190 return Err(cannot_write_err("delete"));
1191 }
1192 cmd_delete(&mut mm, id, force)?;
1193 mm.save()?;
1194 if matches!(cli.output, OutputFormat::Json) {
1195 let obj = serde_json::json!({"command": "delete", "deleted": id});
1196 println!("{}", serde_json::to_string_pretty(&obj)?);
1197 }
1198 eprintln!("Deleted node [{}]", id);
1199 }
1200 Commands::Lint => {
1201 let res = cmd_lint(&mm)?;
1202 if matches!(cli.output, OutputFormat::Json) {
1203 let obj = serde_json::json!({"command": "lint", "warnings": res});
1204 println!("{}", serde_json::to_string_pretty(&obj)?);
1205 } else {
1206 for r in res {
1207 eprintln!("{}", r);
1208 }
1209 }
1210 }
1211 Commands::Orphans => {
1212 let res = cmd_orphans(&mm)?;
1213 if matches!(cli.output, OutputFormat::Json) {
1214 let obj = serde_json::json!({"command": "orphans", "orphans": res});
1215 println!("{}", serde_json::to_string_pretty(&obj)?);
1216 } else if let Some(p) = &printer {
1217 p.orphans(&res)?;
1218 } else {
1219 for r in res {
1220 eprintln!("{}", r);
1221 }
1222 }
1223 }
1224 Commands::Graph { id } => {
1225 let dot = cmd_graph(&mm, id)?;
1226 println!("{}", dot);
1227 }
1228 }
1229
1230 Ok(())
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236 use assert_fs::prelude::*;
1237
1238 #[test]
1239 fn test_parse_nodes() -> Result<()> {
1240 let temp = assert_fs::TempDir::new()?;
1241 let file = temp.child("MINDMAP.md");
1242 file.write_str(
1243 "Header line\n[1] **AE: A** - refers to [2]\nSome note\n[2] **AE: B** - base\n",
1244 )?;
1245
1246 let mm = Mindmap::load(file.path().to_path_buf())?;
1247 assert_eq!(mm.nodes.len(), 2);
1248 assert!(mm.by_id.contains_key(&1));
1249 assert!(mm.by_id.contains_key(&2));
1250 let n1 = mm.get_node(1).unwrap();
1251 assert_eq!(n1.references, vec![Reference::Internal(2)]);
1252 temp.close()?;
1253 Ok(())
1254 }
1255
1256 #[test]
1257 fn test_save_atomic() -> Result<()> {
1258 let temp = assert_fs::TempDir::new()?;
1259 let file = temp.child("MINDMAP.md");
1260 file.write_str("[1] **AE: A** - base\n")?;
1261
1262 let mut mm = Mindmap::load(file.path().to_path_buf())?;
1263 let id = mm.next_id();
1265 mm.lines.push(format!("[{}] **AE: C** - new\n", id));
1266 let node = Node {
1268 id,
1269 raw_title: "AE: C".to_string(),
1270 description: "new".to_string(),
1271 references: vec![],
1272 line_index: mm.lines.len() - 1,
1273 };
1274 mm.by_id.insert(id, mm.nodes.len());
1275 mm.nodes.push(node);
1276
1277 mm.save()?;
1278
1279 let content = std::fs::read_to_string(file.path())?;
1280 assert!(content.contains("AE: C"));
1281 temp.close()?;
1282 Ok(())
1283 }
1284
1285 #[test]
1286 fn test_lint_syntax_and_duplicates_and_orphan() -> Result<()> {
1287 let temp = assert_fs::TempDir::new()?;
1288 let file = temp.child("MINDMAP.md");
1289 file.write_str("[bad] not a node\n[1] **AE: A** - base\n[1] **AE: Adup** - dup\n[2] **AE: Orphan** - lonely\n")?;
1290
1291 let mm = Mindmap::load(file.path().to_path_buf())?;
1292 let warnings = cmd_lint(&mm)?;
1293 let joined = warnings.join("\n");
1295 assert!(joined.contains("Syntax"));
1296 assert!(joined.contains("Duplicate ID"));
1297
1298 let orphans = cmd_orphans(&mm)?;
1300 let joined_o = orphans.join("\n");
1301 assert!(joined_o.contains("2"));
1303
1304 temp.close()?;
1305 Ok(())
1306 }
1307
1308 #[test]
1309 fn test_put_and_patch_basic() -> Result<()> {
1310 let temp = assert_fs::TempDir::new()?;
1311 let file = temp.child("MINDMAP.md");
1312 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
1313
1314 let mut mm = Mindmap::load(file.path().to_path_buf())?;
1315 cmd_patch(&mut mm, 1, Some("AE"), Some("OneNew"), None, false)?;
1317 assert_eq!(mm.get_node(1).unwrap().raw_title, "AE: OneNew");
1318
1319 let new_line = "[2] **DR: Replaced** - replaced desc [1]";
1321 cmd_put(&mut mm, 2, new_line, false)?;
1322 assert_eq!(mm.get_node(2).unwrap().raw_title, "DR: Replaced");
1323 assert_eq!(
1324 mm.get_node(2).unwrap().references,
1325 vec![Reference::Internal(1)]
1326 );
1327
1328 temp.close()?;
1329 Ok(())
1330 }
1331
1332 #[test]
1333 fn test_cmd_show() -> Result<()> {
1334 let temp = assert_fs::TempDir::new()?;
1335 let file = temp.child("MINDMAP.md");
1336 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
1337 let mm = Mindmap::load(file.path().to_path_buf())?;
1338 let out = cmd_show(&mm, 1);
1339 assert!(out.contains("[1] **AE: One**"));
1340 assert!(out.contains("Referred to by: [2]"));
1341 temp.close()?;
1342 Ok(())
1343 }
1344
1345 #[test]
1346 fn test_cmd_refs() -> Result<()> {
1347 let temp = assert_fs::TempDir::new()?;
1348 let file = temp.child("MINDMAP.md");
1349 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
1350 let mm = Mindmap::load(file.path().to_path_buf())?;
1351 let refs = cmd_refs(&mm, 1);
1352 assert_eq!(refs.len(), 1);
1353 assert!(refs[0].contains("[2] **AE: Two**"));
1354 temp.close()?;
1355 Ok(())
1356 }
1357
1358 #[test]
1359 fn test_cmd_links() -> Result<()> {
1360 let temp = assert_fs::TempDir::new()?;
1361 let file = temp.child("MINDMAP.md");
1362 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n")?;
1363 let mm = Mindmap::load(file.path().to_path_buf())?;
1364 let links = cmd_links(&mm, 2);
1365 assert_eq!(links, Some(vec![Reference::Internal(1)]));
1366 temp.close()?;
1367 Ok(())
1368 }
1369
1370 #[test]
1371 fn test_cmd_search() -> Result<()> {
1372 let temp = assert_fs::TempDir::new()?;
1373 let file = temp.child("MINDMAP.md");
1374 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
1375 let mm = Mindmap::load(file.path().to_path_buf())?;
1376 let results = cmd_search(&mm, "first");
1377 assert_eq!(results.len(), 1);
1378 assert!(results[0].contains("[1] **AE: One**"));
1379 temp.close()?;
1380 Ok(())
1381 }
1382
1383 #[test]
1384 fn test_cmd_add() -> Result<()> {
1385 let temp = assert_fs::TempDir::new()?;
1386 let file = temp.child("MINDMAP.md");
1387 file.write_str("[1] **AE: One** - first\n")?;
1388 let mut mm = Mindmap::load(file.path().to_path_buf())?;
1389 let id = cmd_add(&mut mm, "AE", "Two", "second")?;
1390 assert_eq!(id, 2);
1391 assert_eq!(mm.nodes.len(), 2);
1392 let node = mm.get_node(2).unwrap();
1393 assert_eq!(node.raw_title, "AE: Two");
1394 temp.close()?;
1395 Ok(())
1396 }
1397
1398 #[test]
1399 fn test_cmd_deprecate() -> Result<()> {
1400 let temp = assert_fs::TempDir::new()?;
1401 let file = temp.child("MINDMAP.md");
1402 file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
1403 let mut mm = Mindmap::load(file.path().to_path_buf())?;
1404 cmd_deprecate(&mut mm, 1, 2)?;
1405 let node = mm.get_node(1).unwrap();
1406 assert!(node.raw_title.starts_with("[DEPRECATED → 2]"));
1407 temp.close()?;
1408 Ok(())
1409 }
1410
1411 #[test]
1412 fn test_cmd_verify() -> Result<()> {
1413 let temp = assert_fs::TempDir::new()?;
1414 let file = temp.child("MINDMAP.md");
1415 file.write_str("[1] **AE: One** - first\n")?;
1416 let mut mm = Mindmap::load(file.path().to_path_buf())?;
1417 cmd_verify(&mut mm, 1)?;
1418 let node = mm.get_node(1).unwrap();
1419 assert!(node.description.contains("(verify"));
1420 temp.close()?;
1421 Ok(())
1422 }
1423
1424 #[test]
1425 fn test_cmd_show_non_existing() -> Result<()> {
1426 let temp = assert_fs::TempDir::new()?;
1427 let file = temp.child("MINDMAP.md");
1428 file.write_str("[1] **AE: One** - first\n")?;
1429 let mm = Mindmap::load(file.path().to_path_buf())?;
1430 let out = cmd_show(&mm, 99);
1431 assert_eq!(out, "Node 99 not found");
1432 temp.close()?;
1433 Ok(())
1434 }
1435
1436 #[test]
1437 fn test_cmd_refs_non_existing() -> Result<()> {
1438 let temp = assert_fs::TempDir::new()?;
1439 let file = temp.child("MINDMAP.md");
1440 file.write_str("[1] **AE: One** - first\n")?;
1441 let mm = Mindmap::load(file.path().to_path_buf())?;
1442 let refs = cmd_refs(&mm, 99);
1443 assert_eq!(refs.len(), 0);
1444 temp.close()?;
1445 Ok(())
1446 }
1447
1448 #[test]
1449 fn test_cmd_links_non_existing() -> Result<()> {
1450 let temp = assert_fs::TempDir::new()?;
1451 let file = temp.child("MINDMAP.md");
1452 file.write_str("[1] **AE: One** - first\n")?;
1453 let mm = Mindmap::load(file.path().to_path_buf())?;
1454 let links = cmd_links(&mm, 99);
1455 assert_eq!(links, None);
1456 temp.close()?;
1457 Ok(())
1458 }
1459
1460 #[test]
1461 fn test_cmd_put_non_existing() -> Result<()> {
1462 let temp = assert_fs::TempDir::new()?;
1463 let file = temp.child("MINDMAP.md");
1464 file.write_str("[1] **AE: One** - first\n")?;
1465 let mut mm = Mindmap::load(file.path().to_path_buf())?;
1466 let err = cmd_put(&mut mm, 99, "[99] **AE: New** - new", false).unwrap_err();
1467 assert!(format!("{}", err).contains("Node 99 not found"));
1468 temp.close()?;
1469 Ok(())
1470 }
1471
1472 #[test]
1473 fn test_cmd_patch_non_existing() -> Result<()> {
1474 let temp = assert_fs::TempDir::new()?;
1475 let file = temp.child("MINDMAP.md");
1476 file.write_str("[1] **AE: One** - first\n")?;
1477 let mut mm = Mindmap::load(file.path().to_path_buf())?;
1478 let err = cmd_patch(&mut mm, 99, None, Some("New"), None, false).unwrap_err();
1479 assert!(format!("{}", err).contains("Node 99 not found"));
1480 temp.close()?;
1481 Ok(())
1482 }
1483
1484 #[test]
1485 fn test_load_from_reader() -> Result<()> {
1486 use std::io::Cursor;
1487 let content = "[1] **AE: One** - first\n";
1488 let reader = Cursor::new(content);
1489 let path = PathBuf::from("-");
1490 let mm = Mindmap::load_from_reader(reader, path)?;
1491 assert_eq!(mm.nodes.len(), 1);
1492 assert_eq!(mm.nodes[0].id, 1);
1493 Ok(())
1494 }
1495
1496 #[test]
1497 fn test_next_id() -> Result<()> {
1498 let temp = assert_fs::TempDir::new()?;
1499 let file = temp.child("MINDMAP.md");
1500 file.write_str("[1] **AE: One** - first\n[3] **AE: Three** - third\n")?;
1501 let mm = Mindmap::load(file.path().to_path_buf())?;
1502 assert_eq!(mm.next_id(), 4);
1503 temp.close()?;
1504 Ok(())
1505 }
1506
1507 #[test]
1508 fn test_get_node() -> Result<()> {
1509 let temp = assert_fs::TempDir::new()?;
1510 let file = temp.child("MINDMAP.md");
1511 file.write_str("[1] **AE: One** - first\n")?;
1512 let mm = Mindmap::load(file.path().to_path_buf())?;
1513 let node = mm.get_node(1).unwrap();
1514 assert_eq!(node.id, 1);
1515 assert!(mm.get_node(99).is_none());
1516 temp.close()?;
1517 Ok(())
1518 }
1519
1520 #[test]
1521 fn test_cmd_orphans() -> Result<()> {
1522 let temp = assert_fs::TempDir::new()?;
1523 let file = temp.child("MINDMAP.md");
1524 file.write_str("[1] **AE: One** - first\n[2] **AE: Orphan** - lonely\n")?;
1525 let mm = Mindmap::load(file.path().to_path_buf())?;
1526 let orphans = cmd_orphans(&mm)?;
1527 assert_eq!(orphans, vec!["1".to_string(), "2".to_string()]);
1528 temp.close()?;
1529 Ok(())
1530 }
1531
1532 #[test]
1533 fn test_cmd_graph() -> Result<()> {
1534 let temp = assert_fs::TempDir::new()?;
1535 let file = temp.child("MINDMAP.md");
1536 file.write_str(
1537 "[1] **AE: One** - first\n[2] **AE: Two** - refers [1]\n[3] **AE: Three** - also [1]\n",
1538 )?;
1539 let mm = Mindmap::load(file.path().to_path_buf())?;
1540 let dot = cmd_graph(&mm, 1)?;
1541 assert!(dot.contains("digraph {"));
1542 assert!(dot.contains("1 [label=\"1: AE: One\"]"));
1543 assert!(dot.contains("2 [label=\"2: AE: Two\"]"));
1544 assert!(dot.contains("3 [label=\"3: AE: Three\"]"));
1545 assert!(dot.contains("2 -> 1;"));
1546 assert!(dot.contains("3 -> 1;"));
1547 temp.close()?;
1548 Ok(())
1549 }
1550
1551 #[test]
1552 fn test_save_stdin_path() -> Result<()> {
1553 let temp = assert_fs::TempDir::new()?;
1554 let file = temp.child("MINDMAP.md");
1555 file.write_str("[1] **AE: One** - first\n")?;
1556 let mm = Mindmap::load_from_reader(
1557 std::io::Cursor::new("[1] **AE: One** - first\n"),
1558 PathBuf::from("-"),
1559 )?;
1560 let err = mm.save().unwrap_err();
1561 assert!(format!("{}", err).contains("Cannot save"));
1562 temp.close()?;
1563 Ok(())
1564 }
1565
1566 #[test]
1567 fn test_extract_refs_from_str() {
1568 assert_eq!(
1569 extract_refs_from_str("no refs", None),
1570 vec![] as Vec<Reference>
1571 );
1572 assert_eq!(
1573 extract_refs_from_str("[1] and [2]", None),
1574 vec![Reference::Internal(1), Reference::Internal(2)]
1575 );
1576 assert_eq!(
1577 extract_refs_from_str("[1] and [1]", Some(1)),
1578 vec![] as Vec<Reference>
1579 ); assert_eq!(
1581 extract_refs_from_str("[abc] invalid [123]", None),
1582 vec![Reference::Internal(123)]
1583 );
1584 assert_eq!(
1585 extract_refs_from_str("[234](./file.md)", None),
1586 vec![Reference::External(234, "./file.md".to_string())]
1587 );
1588 }
1589}