Skip to main content

mindmap_cli/
lib.rs

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