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 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
100pub 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 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 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 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 let edited = std::fs::read_to_string(tmp.path())?;
314 let edited_line = edited.lines().next().unwrap_or("").trim();
315
316 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 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 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 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 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 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 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 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 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 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 let idx = *mm
448 .by_id
449 .get(&id)
450 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
451
452 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 let line_idx = mm.nodes[idx].line_index;
468 mm.lines.remove(line_idx);
469
470 mm.nodes.remove(idx);
472
473 mm.by_id.clear();
475 for (i, node) in mm.nodes.iter_mut().enumerate() {
476 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 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 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 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 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 let id = mm.next_id();
602 mm.lines.push(format!("[{}] **AE: C** - new\n", id));
603 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 let joined = warnings.join("\n");
632 assert!(joined.contains("Syntax"));
633 assert!(joined.contains("Duplicate ID"));
634
635 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 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 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 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 let err = cmd_delete(&mut mm, 2, false).unwrap_err();
675 assert!(format!("{}", err).contains("referenced"));
676
677 cmd_delete(&mut mm, 2, true)?;
679 assert!(mm.get_node(2).is_none());
680 assert!(!mm.lines.iter().any(|l| l.contains("**AE: Two**")));
682
683 let n1 = mm.get_node(1).unwrap();
685 assert!(n1.references.contains(&2));
686
687 temp.close()?;
688 Ok(())
689 }
690}