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 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 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 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 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
120pub 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 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 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 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 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 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 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 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 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 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 let edited = std::fs::read_to_string(tmp.path())?;
414 let edited_line = edited.lines().next().unwrap_or("").trim();
415
416 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 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 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 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 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 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 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 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 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 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 let idx = *mm
548 .by_id
549 .get(&id)
550 .ok_or_else(|| anyhow::anyhow!(format!("Node {} not found", id)))?;
551
552 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 let line_idx = mm.nodes[idx].line_index;
568 mm.lines.remove(line_idx);
569
570 mm.nodes.remove(idx);
572
573 mm.by_id.clear();
575 for (i, node) in mm.nodes.iter_mut().enumerate() {
576 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 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 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 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 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 let id = mm.next_id();
701 mm.lines.push(format!("[{}] **AE: C** - new\n", id));
702 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 let joined = warnings.join("\n");
731 assert!(joined.contains("Syntax"));
732 assert!(joined.contains("Duplicate ID"));
733
734 let orphans = cmd_orphans(&mm)?;
736 let joined_o = orphans.join("\n");
737 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 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 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 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 let err = cmd_delete(&mut mm, 2, false).unwrap_err();
775 assert!(format!("{}", err).contains("referenced"));
776
777 cmd_delete(&mut mm, 2, true)?;
779 assert!(mm.get_node(2).is_none());
780 assert!(!mm.lines.iter().any(|l| l.contains("**AE: Two**")));
782
783 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 let mm = Mindmap::load_from_reader(content.as_bytes(), PathBuf::from("-"))?;
796 assert_eq!(mm.nodes.len(), 2);
797 let err = mm.save().unwrap_err();
799 assert!(format!("{}", err).contains("loaded from stdin"));
800 Ok(())
801 }
802}