Skip to main content

mindmap_cli/
lib.rs

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