Skip to main content

mindmap_cli/
lib.rs

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    /// Path to MINDMAP file (defaults to ./MINDMAP.md)
38    #[arg(global = true, short, long)]
39    pub file: Option<PathBuf>,
40
41    /// Output format: default (human) or json
42    #[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 a node by ID
52    Show { id: u32 },
53
54    /// List nodes (optionally filtered)
55    List {
56        #[arg(long)]
57        r#type: Option<String>,
58        #[arg(long)]
59        grep: Option<String>,
60    },
61
62    /// Show nodes that reference the given ID
63    Refs { id: u32 },
64
65    /// Show nodes that the given ID references
66    Links { id: u32 },
67
68    /// Search nodes by substring
69    Search { query: String },
70
71    /// Add a new node
72    Add {
73        #[arg(long)]
74        r#type: Option<String>,
75        #[arg(long)]
76        title: Option<String>,
77        #[arg(long)]
78        desc: Option<String>,
79        /// When using editor flow, perform strict reference validation
80        #[arg(long)]
81        strict: bool,
82    },
83
84    /// Deprecate a node, redirecting to another
85    Deprecate {
86        id: u32,
87        #[arg(long)]
88        to: u32,
89    },
90
91    /// Edit a node with $EDITOR
92    Edit { id: u32 },
93
94    /// Patch (partial update) a node: --type, --title, --desc
95    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 (full-line replace) a node: --line
108    Put {
109        id: u32,
110        #[arg(long)]
111        line: String,
112        #[arg(long)]
113        strict: bool,
114    },
115
116    /// Mark a node as needing verification (append verify tag)
117    Verify { id: u32 },
118
119    /// Delete a node by ID; use --force to remove even if referenced
120    Delete {
121        id: u32,
122        #[arg(long)]
123        force: bool,
124    },
125
126    /// Lint the mindmap for basic issues
127    Lint,
128
129    /// Show orphan nodes (no in & no out, excluding META)
130    Orphans,
131
132    /// Show graph neighborhood for a node (DOT format for Graphviz)
133    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        // load from file path
161        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    /// Load mindmap content from any reader (e.g., stdin). Provide a path placeholder (e.g. "-")
167    /// so that callers can detect that the source was non-writable (stdin).
168    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        // prevent persisting when loaded from stdin (path == "-")
200        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        // atomic write: write to a temp file in the same dir then persist
207        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
232// Helper: lightweight manual parser for the strict node format
233// Format: ^\[(\d+)\] \*\*(.+?)\*\* - (.*)$
234pub fn parse_node_line(line: &str, line_index: usize) -> Result<Node> {
235    // Fast path sanity checks
236    let trimmed = line.trim_start();
237    if !trimmed.starts_with('[') {
238        return Err(anyhow::anyhow!("Line does not match node format"));
239    }
240
241    // Find closing bracket for ID
242    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    // Expect a space after ']'
251    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    // Expect opening '**'
260    if trimmed.get(pos..pos + 2) != Some("**") {
261        return Err(anyhow::anyhow!("Line does not match node format"));
262    }
263    pos += 2;
264
265    // Find closing '**' for title
266    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; // skip closing '**'
273
274    // Expect ' - ' (space dash space)
275    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    // Extract references
283    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
294// Extract references of the form [123] or [234](./file.md) from a description string.
295// If skip_self is Some(id) then occurrences equal to that id are ignored.
296fn 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        // find next '['
301        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                    // check if followed by (path)
312                    let after = &s[end..];
313                    if after.starts_with("](") {
314                        // find closing )
315                        if let Some(paren_end) = after.find(')') {
316                            let path_start = end + 2; // after ]( 
317                            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                    // internal ref
325                    refs.push(Reference::Internal(rid));
326                }
327                i = end + 1;
328                continue;
329            } else {
330                break; // unmatched '['
331            }
332        } else {
333            break;
334        }
335    }
336    refs
337}
338
339// Command helpers
340
341pub 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        // inbound refs
349        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    // require interactive terminal for editor
451    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    // create temp file and write template
461    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    // launch editor
468    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    // read edited content and pick first non-empty line
477    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    // parse and validate
494    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    // apply: append line and node
515    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    // create temp file with the single node line
585    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    // launch editor
596    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    // read edited content
605    let edited = std::fs::read_to_string(tmp.path())?;
606    let edited_line = edited.lines().next().unwrap_or("").trim();
607
608    // parse and validate using manual parser
609    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    // all good: replace line in mm.lines and update node fields
615    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    // update node in-place
621    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    // full-line replace: parse provided line and enforce same id
631    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    // strict check for references
642    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    // apply
655    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    // split existing raw_title into optional type and title
679    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    // build raw title: if type is empty, omit prefix
691    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    // validate
700    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    // apply
718    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    // find node index
729    let idx = *mm
730        .by_id
731        .get(&id)
732        .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
733
734    // check incoming references
735    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    // remove the line from lines
752    let line_idx = mm.nodes[idx].line_index;
753    mm.lines.remove(line_idx);
754
755    // remove node from nodes vector
756    mm.nodes.remove(idx);
757
758    // rebuild by_id and fix line_index for nodes after removed line
759    mm.by_id.clear();
760    for (i, node) in mm.nodes.iter_mut().enumerate() {
761        // if node was after removed line, decrement its line_index
762        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    // 1) Syntax: lines starting with '[' but not matching node format
775    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    // 2) Duplicate IDs: scan lines for node ids
786    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    // 3) Missing references
802    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    // Orphans: nodes with no in and no out, excluding META:*
836    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    // Collect 1-hop neighborhood: self, direct references (out), and nodes that reference self (in)
870    let mut nodes = std::collections::HashSet::new();
871    nodes.insert(id);
872
873    // Outgoing: references from self
874    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    // Incoming: nodes that reference self
883    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    // Generate DOT
893    let mut dot = String::new();
894    dot.push_str("digraph {\n");
895    dot.push_str("  rankdir=LR;\n");
896
897    // Add nodes
898    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    // Add edges: from each node to its references, if both in neighborhood
906    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
921// mod ui;
922
923pub fn run(cli: Cli) -> Result<()> {
924    let path = cli.file.unwrap_or_else(|| PathBuf::from("MINDMAP.md"));
925
926    // If user passed '-' use stdin as source
927    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    // determine whether to use pretty output (interactive + default format)
934    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    // helper to reject mutating commands when mm.path == '-'
953    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                    // compute inbound refs
977                    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                    // editor flow
1081                    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        // append a node line
1264        let id = mm.next_id();
1265        mm.lines.push(format!("[{}] **AE: C** - new\n", id));
1266        // reflect node
1267        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        // Expect at least syntax and duplicate warnings from lint
1294        let joined = warnings.join("\n");
1295        assert!(joined.contains("Syntax"));
1296        assert!(joined.contains("Duplicate ID"));
1297
1298        // Orphan detection is now a separate command; verify orphans via cmd_orphans()
1299        let orphans = cmd_orphans(&mm)?;
1300        let joined_o = orphans.join("\n");
1301        // expect node id 2 to be reported as orphan
1302        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        // patch title only for node 1
1316        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        // put full line for node 2
1320        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        ); // skip self
1580        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}