Skip to main content

mindmap_cli/
lib.rs

1use anyhow::{Context, Result};
2use regex::Regex;
3use std::{collections::HashMap, fs, io::Read, path::PathBuf};
4
5#[derive(Debug, Clone)]
6pub struct Node {
7    pub id: u32,
8    pub raw_title: String,
9    pub description: String,
10    pub references: Vec<u32>,
11    pub line_index: usize,
12}
13
14#[derive(Debug)]
15pub struct Mindmap {
16    pub path: PathBuf,
17    pub lines: Vec<String>,
18    pub nodes: Vec<Node>,
19    pub by_id: HashMap<u32, usize>,
20}
21
22impl Mindmap {
23    pub fn load(path: PathBuf) -> Result<Self> {
24        // load from file path
25        let content = fs::read_to_string(&path)
26            .with_context(|| format!("Failed to read file {}", path.display()))?;
27        Self::from_string(content, path)
28    }
29
30    /// Load mindmap content from any reader (e.g., stdin). Provide a path placeholder (e.g. "-")
31    /// so that callers can detect that the source was non-writable (stdin).
32    pub fn load_from_reader<R: Read>(mut reader: R, path: PathBuf) -> Result<Self> {
33        let mut content = String::new();
34        reader.read_to_string(&mut content)?;
35        Self::from_string(content, path)
36    }
37
38    fn from_string(content: String, path: PathBuf) -> Result<Self> {
39        let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
40
41        let re = Regex::new(r#"^\[(\d+)\] \*\*(.+?)\*\* - (.*)$"#)?;
42        let ref_re = Regex::new(r#"\[(\d+)\]"#)?;
43
44        let mut nodes = Vec::new();
45        let mut by_id = HashMap::new();
46
47        for (i, line) in lines.iter().enumerate() {
48            if let Some(caps) = re.captures(line) {
49                let id: u32 = caps[1].parse()?;
50                let raw_title = caps[2].to_string();
51                let description = caps[3].to_string();
52
53                let mut references = Vec::new();
54                for rcaps in ref_re.captures_iter(&description) {
55                    if let Ok(rid) = rcaps[1].parse::<u32>()
56                        && rid != id
57                    {
58                        references.push(rid);
59                    }
60                }
61
62                let node = Node {
63                    id,
64                    raw_title,
65                    description,
66                    references,
67                    line_index: i,
68                };
69
70                if by_id.contains_key(&id) {
71                    eprintln!("Warning: duplicate node id {} at line {}", id, i + 1);
72                }
73                by_id.insert(id, nodes.len());
74                nodes.push(node);
75            }
76        }
77
78        Ok(Mindmap {
79            path,
80            lines,
81            nodes,
82            by_id,
83        })
84    }
85
86    pub fn save(&self) -> Result<()> {
87        // prevent persisting when loaded from stdin (path == "-")
88        if self.path.as_os_str() == "-" {
89            return Err(anyhow::anyhow!(
90                "Cannot save: mindmap was loaded from stdin ('-'); use --file <path> to save changes"
91            ));
92        }
93
94        // atomic write: write to a temp file in the same dir then persist
95        let dir = self
96            .path
97            .parent()
98            .map(|p| p.to_path_buf())
99            .unwrap_or_else(|| PathBuf::from("."));
100        let mut tmp = tempfile::NamedTempFile::new_in(&dir)
101            .with_context(|| format!("Failed to create temp file in {}", dir.display()))?;
102        let content = self.lines.join("\n") + "\n";
103        use std::io::Write;
104        tmp.write_all(content.as_bytes())?;
105        tmp.flush()?;
106        tmp.persist(&self.path)
107            .with_context(|| format!("Failed to persist temp file to {}", self.path.display()))?;
108        Ok(())
109    }
110
111    pub fn next_id(&self) -> u32 {
112        self.by_id.keys().max().copied().unwrap_or(0) + 1
113    }
114
115    pub fn get_node(&self, id: u32) -> Option<&Node> {
116        self.by_id.get(&id).map(|&idx| &self.nodes[idx])
117    }
118}
119
120// Command helpers
121
122pub fn parse_node_line(line: &str, line_index: usize) -> Result<Node> {
123    let re = Regex::new(r#"^\[(\d+)\] \*\*(.+?)\*\* - (.*)$"#)?;
124    let ref_re = Regex::new(r#"\[(\d+)\]"#)?;
125    let caps = re
126        .captures(line)
127        .ok_or_else(|| anyhow::anyhow!("Line does not match node format"))?;
128    let id: u32 = caps[1].parse()?;
129    let raw_title = caps[2].to_string();
130    let description = caps[3].to_string();
131    let mut references = Vec::new();
132    for rcaps in ref_re.captures_iter(&description) {
133        if let Ok(rid) = rcaps[1].parse::<u32>()
134            && rid != id
135        {
136            references.push(rid);
137        }
138    }
139    Ok(Node {
140        id,
141        raw_title,
142        description,
143        references,
144        line_index,
145    })
146}
147
148pub fn cmd_show(mm: &Mindmap, id: u32) -> String {
149    if let Some(node) = mm.get_node(id) {
150        let mut out = format!(
151            "[{}] **{}** - {}",
152            node.id, node.raw_title, node.description
153        );
154
155        // inbound refs
156        let mut inbound = Vec::new();
157        for n in &mm.nodes {
158            if n.references.contains(&id) {
159                inbound.push(n.id);
160            }
161        }
162        if !inbound.is_empty() {
163            out.push_str(&format!("\nReferred to by: {:?}", inbound));
164        }
165        out
166    } else {
167        format!("Node {} not found", id)
168    }
169}
170
171pub fn cmd_list(mm: &Mindmap, type_filter: Option<&str>, grep: Option<&str>) -> Vec<String> {
172    let mut res = Vec::new();
173    for n in &mm.nodes {
174        if let Some(tf) = type_filter
175            && !n.raw_title.starts_with(&format!("{}:", tf))
176        {
177            continue;
178        }
179        if let Some(q) = grep {
180            let qlc = q.to_lowercase();
181            if !n.raw_title.to_lowercase().contains(&qlc)
182                && !n.description.to_lowercase().contains(&qlc)
183            {
184                continue;
185            }
186        }
187        res.push(format!(
188            "[{}] **{}** - {}",
189            n.id, n.raw_title, n.description
190        ));
191    }
192    res
193}
194
195pub fn cmd_refs(mm: &Mindmap, id: u32) -> Vec<String> {
196    let mut out = Vec::new();
197    for n in &mm.nodes {
198        if n.references.contains(&id) {
199            out.push(format!(
200                "[{}] **{}** - {}",
201                n.id, n.raw_title, n.description
202            ));
203        }
204    }
205    out
206}
207
208pub fn cmd_links(mm: &Mindmap, id: u32) -> Option<Vec<u32>> {
209    mm.get_node(id).map(|n| n.references.clone())
210}
211
212pub fn cmd_search(mm: &Mindmap, query: &str) -> Vec<String> {
213    let qlc = query.to_lowercase();
214    let mut out = Vec::new();
215    for n in &mm.nodes {
216        if n.raw_title.to_lowercase().contains(&qlc) || n.description.to_lowercase().contains(&qlc)
217        {
218            out.push(format!(
219                "[{}] **{}** - {}",
220                n.id, n.raw_title, n.description
221            ));
222        }
223    }
224    out
225}
226
227pub fn cmd_add(mm: &mut Mindmap, type_prefix: &str, title: &str, desc: &str) -> Result<u32> {
228    let id = mm.next_id();
229    let full_title = format!("{}: {}", type_prefix, title);
230    let line = format!("[{}] **{}** - {}", id, full_title, desc);
231
232    mm.lines.push(line.clone());
233
234    let line_index = mm.lines.len() - 1;
235    let refs_re = Regex::new(r#"\[(\d+)\]"#)?;
236    let mut references = Vec::new();
237    for rcaps in refs_re.captures_iter(desc) {
238        if let Ok(rid) = rcaps[1].parse::<u32>()
239            && rid != id
240        {
241            references.push(rid);
242        }
243    }
244
245    let node = Node {
246        id,
247        raw_title: full_title,
248        description: desc.to_string(),
249        references,
250        line_index,
251    };
252    mm.by_id.insert(id, mm.nodes.len());
253    mm.nodes.push(node);
254
255    Ok(id)
256}
257
258pub fn cmd_add_editor(mm: &mut Mindmap, editor: &str, strict: bool) -> Result<u32> {
259    // require interactive terminal for editor
260    if !atty::is(atty::Stream::Stdin) {
261        return Err(anyhow::anyhow!(
262            "add via editor requires an interactive terminal"
263        ));
264    }
265
266    let id = mm.next_id();
267    let template = format!("[{}] **TYPE: Title** - description", id);
268
269    // create temp file and write template
270    let mut tmp = tempfile::NamedTempFile::new()
271        .with_context(|| "Failed to create temp file for add editor")?;
272    use std::io::Write;
273    writeln!(tmp, "{}", template)?;
274    tmp.flush()?;
275
276    // launch editor
277    let status = std::process::Command::new(editor)
278        .arg(tmp.path())
279        .status()
280        .with_context(|| "Failed to launch editor")?;
281    if !status.success() {
282        return Err(anyhow::anyhow!("Editor exited with non-zero status"));
283    }
284
285    // read edited content and pick first non-empty line
286    let edited = std::fs::read_to_string(tmp.path())?;
287    let nonempty: Vec<&str> = edited
288        .lines()
289        .map(|l| l.trim())
290        .filter(|l| !l.is_empty())
291        .collect();
292    if nonempty.is_empty() {
293        return Err(anyhow::anyhow!("No content written in editor"));
294    }
295    if nonempty.len() > 1 {
296        return Err(anyhow::anyhow!(
297            "Expected exactly one node line in editor; found multiple lines"
298        ));
299    }
300    let line = nonempty[0];
301
302    // parse and validate
303    let parsed = parse_node_line(line, mm.lines.len())?;
304    if parsed.id != id {
305        return Err(anyhow::anyhow!(format!(
306            "Added line id changed; expected [{}]",
307            id
308        )));
309    }
310
311    if strict {
312        for rid in &parsed.references {
313            if !mm.by_id.contains_key(rid) {
314                return Err(anyhow::anyhow!(format!(
315                    "ADD strict: reference to missing node {}",
316                    rid
317                )));
318            }
319        }
320    }
321
322    // apply: append line and node
323    mm.lines.push(line.to_string());
324    let line_index = mm.lines.len() - 1;
325    let node = Node {
326        id: parsed.id,
327        raw_title: parsed.raw_title,
328        description: parsed.description,
329        references: parsed.references,
330        line_index,
331    };
332    mm.by_id.insert(id, mm.nodes.len());
333    mm.nodes.push(node);
334
335    Ok(id)
336}
337
338pub fn cmd_deprecate(mm: &mut Mindmap, id: u32, to: u32) -> Result<()> {
339    let idx = *mm
340        .by_id
341        .get(&id)
342        .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
343
344    if !mm.by_id.contains_key(&to) {
345        eprintln!(
346            "Warning: target node {} does not exist (still updating title)",
347            to
348        );
349    }
350
351    let node = &mut mm.nodes[idx];
352    if !node.raw_title.starts_with("[DEPRECATED") {
353        node.raw_title = format!("[DEPRECATED → {}] {}", to, node.raw_title);
354        mm.lines[node.line_index] = format!(
355            "[{}] **{}** - {}",
356            node.id, node.raw_title, node.description
357        );
358    }
359
360    Ok(())
361}
362
363pub fn cmd_verify(mm: &mut Mindmap, id: u32) -> Result<()> {
364    let idx = *mm
365        .by_id
366        .get(&id)
367        .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
368    let node = &mut mm.nodes[idx];
369
370    let tag = format!("(verify {})", chrono::Local::now().format("%Y-%m-%d"));
371    if !node.description.contains("(verify ") {
372        if node.description.is_empty() {
373            node.description = tag.clone();
374        } else {
375            node.description = format!("{} {}", node.description, tag);
376        }
377        mm.lines[node.line_index] = format!(
378            "[{}] **{}** - {}",
379            node.id, node.raw_title, node.description
380        );
381    }
382    Ok(())
383}
384
385pub fn cmd_edit(mm: &mut Mindmap, id: u32, editor: &str) -> Result<()> {
386    let idx = *mm
387        .by_id
388        .get(&id)
389        .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
390    let node = &mm.nodes[idx];
391
392    // create temp file with the single node line
393    let mut tmp =
394        tempfile::NamedTempFile::new().with_context(|| "Failed to create temp file for editing")?;
395    use std::io::Write;
396    writeln!(
397        tmp,
398        "[{}] **{}** - {}",
399        node.id, node.raw_title, node.description
400    )?;
401    tmp.flush()?;
402
403    // launch editor
404    let status = std::process::Command::new(editor)
405        .arg(tmp.path())
406        .status()
407        .with_context(|| "Failed to launch editor")?;
408    if !status.success() {
409        return Err(anyhow::anyhow!("Editor exited with non-zero status"));
410    }
411
412    // read edited content
413    let edited = std::fs::read_to_string(tmp.path())?;
414    let edited_line = edited.lines().next().unwrap_or("").trim();
415
416    // validate: must match node regex and keep same id
417    let re = Regex::new(r#"^\[(\d+)\] \*\*(.+?)\*\* - (.*)$"#)?;
418    let caps = re
419        .captures(edited_line)
420        .ok_or_else(|| anyhow::anyhow!("Edited line does not match node format"))?;
421    let new_id: u32 = caps[1].parse()?;
422    if new_id != id {
423        return Err(anyhow::anyhow!("Cannot change node ID"));
424    }
425
426    // all good: replace line in mm.lines and update node fields
427    mm.lines[node.line_index] = edited_line.to_string();
428    let new_title = caps[2].to_string();
429    let new_desc = caps[3].to_string();
430    let mut new_refs = Vec::new();
431    let ref_re = Regex::new(r#"\[(\d+)\]"#)?;
432    for rcaps in ref_re.captures_iter(&new_desc) {
433        if let Ok(rid) = rcaps[1].parse::<u32>()
434            && rid != id
435        {
436            new_refs.push(rid);
437        }
438    }
439
440    // update node in-place
441    let node_mut = &mut mm.nodes[idx];
442    node_mut.raw_title = new_title;
443    node_mut.description = new_desc;
444    node_mut.references = new_refs;
445
446    Ok(())
447}
448
449pub fn cmd_put(mm: &mut Mindmap, id: u32, line: &str, strict: bool) -> Result<()> {
450    // full-line replace: parse provided line and enforce same id
451    let idx = *mm
452        .by_id
453        .get(&id)
454        .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
455
456    let parsed = parse_node_line(line, mm.nodes[idx].line_index)?;
457    if parsed.id != id {
458        return Err(anyhow::anyhow!("PUT line id does not match target id"));
459    }
460
461    // strict check for references
462    if strict {
463        for rid in &parsed.references {
464            if !mm.by_id.contains_key(rid) {
465                return Err(anyhow::anyhow!(format!(
466                    "PUT strict: reference to missing node {}",
467                    rid
468                )));
469            }
470        }
471    }
472
473    // apply
474    mm.lines[mm.nodes[idx].line_index] = line.to_string();
475    let node_mut = &mut mm.nodes[idx];
476    node_mut.raw_title = parsed.raw_title;
477    node_mut.description = parsed.description;
478    node_mut.references = parsed.references;
479
480    Ok(())
481}
482
483pub fn cmd_patch(
484    mm: &mut Mindmap,
485    id: u32,
486    typ: Option<&str>,
487    title: Option<&str>,
488    desc: Option<&str>,
489    strict: bool,
490) -> Result<()> {
491    let idx = *mm
492        .by_id
493        .get(&id)
494        .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
495    let node = &mm.nodes[idx];
496
497    // split existing raw_title into optional type and title
498    let mut existing_type: Option<&str> = None;
499    let mut existing_title = node.raw_title.as_str();
500    if let Some(pos) = node.raw_title.find(':') {
501        existing_type = Some(node.raw_title[..pos].trim());
502        existing_title = node.raw_title[pos + 1..].trim();
503    }
504
505    let new_type = typ.unwrap_or(existing_type.unwrap_or(""));
506    let new_title = title.unwrap_or(existing_title);
507    let new_desc = desc.unwrap_or(&node.description);
508
509    // build raw title: if type is empty, omit prefix
510    let new_raw_title = if new_type.is_empty() {
511        new_title.to_string()
512    } else {
513        format!("{}: {}", new_type, new_title)
514    };
515
516    let new_line = format!("[{}] **{}** - {}", id, new_raw_title, new_desc);
517
518    // validate
519    let parsed = parse_node_line(&new_line, node.line_index)?;
520    if parsed.id != id {
521        return Err(anyhow::anyhow!("Patch resulted in different id"));
522    }
523
524    if strict {
525        for rid in &parsed.references {
526            if !mm.by_id.contains_key(rid) {
527                return Err(anyhow::anyhow!(format!(
528                    "PATCH strict: reference to missing node {}",
529                    rid
530                )));
531            }
532        }
533    }
534
535    // apply
536    mm.lines[node.line_index] = new_line;
537    let node_mut = &mut mm.nodes[idx];
538    node_mut.raw_title = parsed.raw_title;
539    node_mut.description = parsed.description;
540    node_mut.references = parsed.references;
541
542    Ok(())
543}
544
545pub fn cmd_delete(mm: &mut Mindmap, id: u32, force: bool) -> Result<()> {
546    // find node index
547    let idx = *mm
548        .by_id
549        .get(&id)
550        .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
551
552    // check incoming references
553    let mut incoming_from = Vec::new();
554    for n in &mm.nodes {
555        if n.references.contains(&id) {
556            incoming_from.push(n.id);
557        }
558    }
559    if !incoming_from.is_empty() && !force {
560        return Err(anyhow::anyhow!(format!(
561            "Node {} is referenced by {:?}; use --force to delete",
562            id, incoming_from
563        )));
564    }
565
566    // remove the line from lines
567    let line_idx = mm.nodes[idx].line_index;
568    mm.lines.remove(line_idx);
569
570    // remove node from nodes vector
571    mm.nodes.remove(idx);
572
573    // rebuild by_id and fix line_index for nodes after removed line
574    mm.by_id.clear();
575    for (i, node) in mm.nodes.iter_mut().enumerate() {
576        // if node was after removed line, decrement its line_index
577        if node.line_index > line_idx {
578            node.line_index -= 1;
579        }
580        mm.by_id.insert(node.id, i);
581    }
582
583    Ok(())
584}
585
586pub fn cmd_lint(mm: &Mindmap) -> Result<Vec<String>> {
587    let mut warnings = Vec::new();
588
589    let node_re = Regex::new(r#"^\[(\d+)\] \*\*(.+?)\*\* - (.*)$"#)?;
590
591    // 1) Syntax: lines starting with '[' but not matching node regex
592    for (i, line) in mm.lines.iter().enumerate() {
593        let trimmed = line.trim_start();
594        if trimmed.starts_with('[') && !node_re.is_match(line) {
595            warnings.push(format!(
596                "Syntax: line {} starts with '[' but does not match node format",
597                i + 1
598            ));
599        }
600    }
601
602    // 2) Duplicate IDs: scan lines for node ids
603    let mut id_map: HashMap<u32, Vec<usize>> = HashMap::new();
604    for (i, line) in mm.lines.iter().enumerate() {
605        if let Some(caps) = node_re.captures(line)
606            && let Ok(id) = caps[1].parse::<u32>() {
607                id_map.entry(id).or_default().push(i + 1);
608            }
609    }
610    for (id, locations) in &id_map {
611        if locations.len() > 1 {
612            warnings.push(format!(
613                "Duplicate ID: node {} appears on lines {:?}",
614                id, locations
615            ));
616        }
617    }
618
619    // 3) Missing references
620    for n in &mm.nodes {
621        for rid in &n.references {
622            if !mm.by_id.contains_key(rid) {
623                warnings.push(format!(
624                    "Missing ref: node {} references missing node {}",
625                    n.id, rid
626                ));
627            }
628        }
629    }
630
631    if warnings.is_empty() {
632        Ok(vec!["Lint OK".to_string()])
633    } else {
634        Ok(warnings)
635    }
636}
637
638pub fn cmd_orphans(mm: &Mindmap) -> Result<Vec<String>> {
639    let mut warnings = Vec::new();
640
641    // Orphans: nodes with no in and no out, excluding META:*
642    let mut incoming: HashMap<u32, usize> = HashMap::new();
643    for n in &mm.nodes {
644        incoming.entry(n.id).or_insert(0);
645    }
646    for n in &mm.nodes {
647        for rid in &n.references {
648            if incoming.contains_key(rid) {
649                *incoming.entry(*rid).or_insert(0) += 1;
650            }
651        }
652    }
653    for n in &mm.nodes {
654        let inc = incoming.get(&n.id).copied().unwrap_or(0);
655        let out = n.references.len();
656        let title_up = n.raw_title.to_uppercase();
657        if inc == 0 && out == 0 && !title_up.starts_with("META") {
658            warnings.push(format!("{}", n.id));
659        }
660    }
661
662    if warnings.is_empty() {
663        Ok(vec!["No orphans".to_string()])
664    } else {
665        Ok(warnings)
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use assert_fs::prelude::*;
673
674    #[test]
675    fn test_parse_nodes() -> Result<()> {
676        let temp = assert_fs::TempDir::new()?;
677        let file = temp.child("MINDMAP.md");
678        file.write_str(
679            "Header line\n[1] **AE: A** - refers to [2]\nSome note\n[2] **AE: B** - base\n",
680        )?;
681
682        let mm = Mindmap::load(file.path().to_path_buf())?;
683        assert_eq!(mm.nodes.len(), 2);
684        assert!(mm.by_id.contains_key(&1));
685        assert!(mm.by_id.contains_key(&2));
686        let n1 = mm.get_node(1).unwrap();
687        assert_eq!(n1.references, vec![2]);
688        temp.close()?;
689        Ok(())
690    }
691
692    #[test]
693    fn test_save_atomic() -> Result<()> {
694        let temp = assert_fs::TempDir::new()?;
695        let file = temp.child("MINDMAP.md");
696        file.write_str("[1] **AE: A** - base\n")?;
697
698        let mut mm = Mindmap::load(file.path().to_path_buf())?;
699        // append a node line
700        let id = mm.next_id();
701        mm.lines.push(format!("[{}] **AE: C** - new\n", id));
702        // reflect node
703        let node = Node {
704            id,
705            raw_title: "AE: C".to_string(),
706            description: "new".to_string(),
707            references: vec![],
708            line_index: mm.lines.len() - 1,
709        };
710        mm.by_id.insert(id, mm.nodes.len());
711        mm.nodes.push(node);
712
713        mm.save()?;
714
715        let content = std::fs::read_to_string(file.path())?;
716        assert!(content.contains("AE: C"));
717        temp.close()?;
718        Ok(())
719    }
720
721    #[test]
722    fn test_lint_syntax_and_duplicates_and_orphan() -> Result<()> {
723        let temp = assert_fs::TempDir::new()?;
724        let file = temp.child("MINDMAP.md");
725        file.write_str("[bad] not a node\n[1] **AE: A** - base\n[1] **AE: Adup** - dup\n[2] **AE: Orphan** - lonely\n")?;
726
727        let mm = Mindmap::load(file.path().to_path_buf())?;
728        let warnings = cmd_lint(&mm)?;
729        // Expect at least syntax and duplicate warnings from lint
730        let joined = warnings.join("\n");
731        assert!(joined.contains("Syntax"));
732        assert!(joined.contains("Duplicate ID"));
733
734        // Orphan detection is now a separate command; verify orphans via cmd_orphans()
735        let orphans = cmd_orphans(&mm)?;
736        let joined_o = orphans.join("\n");
737        // expect node id 2 to be reported as orphan
738        assert!(joined_o.contains("2"));
739
740        temp.close()?;
741        Ok(())
742    }
743
744    #[test]
745    fn test_put_and_patch_basic() -> Result<()> {
746        let temp = assert_fs::TempDir::new()?;
747        let file = temp.child("MINDMAP.md");
748        file.write_str("[1] **AE: One** - first\n[2] **AE: Two** - second\n")?;
749
750        let mut mm = Mindmap::load(file.path().to_path_buf())?;
751        // patch title only for node 1
752        cmd_patch(&mut mm, 1, Some("AE"), Some("OneNew"), None, false)?;
753        assert_eq!(mm.get_node(1).unwrap().raw_title, "AE: OneNew");
754
755        // put full line for node 2
756        let new_line = "[2] **DR: Replaced** - replaced desc [1]";
757        cmd_put(&mut mm, 2, new_line, false)?;
758        assert_eq!(mm.get_node(2).unwrap().raw_title, "DR: Replaced");
759        assert_eq!(mm.get_node(2).unwrap().references, vec![1]);
760
761        temp.close()?;
762        Ok(())
763    }
764
765    #[test]
766    fn test_delete_behaviour() -> Result<()> {
767        let temp = assert_fs::TempDir::new()?;
768        let file = temp.child("MINDMAP.md");
769        // node1 references node2
770        file.write_str("[1] **AE: One** - refers [2]\n[2] **AE: Two** - second\n")?;
771
772        let mut mm = Mindmap::load(file.path().to_path_buf())?;
773        // attempt to delete node 2 without force -> should error
774        let err = cmd_delete(&mut mm, 2, false).unwrap_err();
775        assert!(format!("{}", err).contains("referenced"));
776
777        // delete with force -> succeeds
778        cmd_delete(&mut mm, 2, true)?;
779        assert!(mm.get_node(2).is_none());
780        // lines should no longer contain node 2
781        assert!(!mm.lines.iter().any(|l| l.contains("**AE: Two**")));
782
783        // node1 still has dangling reference (we do not rewrite other nodes automatically)
784        let n1 = mm.get_node(1).unwrap();
785        assert!(n1.references.contains(&2));
786
787        temp.close()?;
788        Ok(())
789    }
790
791    #[test]
792    fn test_load_from_reader_and_no_save_on_stdin() -> Result<()> {
793        let content = "[1] **AE: A** - base\n[2] **AE: B** - refers [1]\n";
794        // load from a string reader and path '-' to simulate stdin
795        let mm = Mindmap::load_from_reader(content.as_bytes(), PathBuf::from("-"))?;
796        assert_eq!(mm.nodes.len(), 2);
797        // saving should fail because path == '-'
798        let err = mm.save().unwrap_err();
799        assert!(format!("{}", err).contains("loaded from stdin"));
800        Ok(())
801    }
802}