Skip to main content

mindmap_cli/
lib.rs

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