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