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