1use crate::constants::{mps_file_name_regexp, new_file_name, MPS_EXT};
7use crate::elements::Element;
8use crate::error::MpsError;
9use crate::parser;
10use crate::ref_resolver::RefResolver;
11use chrono::NaiveDate;
12use indexmap::IndexMap;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16type ResolveResult = Result<
18 (
19 Option<String>,
20 Option<PathBuf>,
21 Option<IndexMap<String, Element>>,
22 ),
23 MpsError,
24>;
25
26#[allow(dead_code)]
27pub struct SearchResult {
28 pub element: Element,
29 pub file: PathBuf,
30 pub date_str: String, pub epoch_ref: String,
32}
33
34pub struct Store {
35 storage_dir: PathBuf,
36}
37
38impl Store {
39 pub fn new(storage_dir: impl Into<PathBuf>) -> Self {
40 Store {
41 storage_dir: storage_dir.into(),
42 }
43 }
44
45 pub fn find_file(&self, date: NaiveDate) -> Option<PathBuf> {
47 self.find_files(date).into_iter().next()
48 }
49
50 pub fn find_files(&self, date: NaiveDate) -> Vec<PathBuf> {
52 let prefix = date.format("%Y%m%d").to_string();
53 let re = mps_file_name_regexp();
54 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)
55 .map(|rd| {
56 rd.filter_map(|e| e.ok())
57 .map(|e| e.path())
58 .filter(|p| {
59 p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
60 && p.file_name()
61 .and_then(|n| n.to_str())
62 .map(|n| re.is_match(n) && n.starts_with(&prefix))
63 .unwrap_or(false)
64 })
65 .collect()
66 })
67 .unwrap_or_default();
68 files.sort();
69 files
70 }
71
72 pub fn find_or_create_path(&self, date: NaiveDate) -> PathBuf {
74 self.find_file(date)
75 .unwrap_or_else(|| self.storage_dir.join(new_file_name(date)))
76 }
77
78 pub fn parse_date(&self, date: NaiveDate) -> Result<IndexMap<String, Element>, MpsError> {
80 match self.find_file(date) {
81 None => Ok(IndexMap::new()),
82 Some(p) => parser::parse_file(&p),
83 }
84 }
85
86 pub fn append(
88 &self,
89 kind: &str,
90 body: &str,
91 tags: &[String],
92 attrs: &[(&str, &str)],
93 date: NaiveDate,
94 ) -> Result<PathBuf, MpsError> {
95 let mut parts: Vec<String> = attrs.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
96 parts.extend(tags.iter().cloned());
97 let args_str = parts.join(", ");
98
99 let path = self.find_or_create_path(date);
100 let chunk = format!("\n@{}[{}]{{\n {}\n}}\n", kind, args_str, body);
101 use std::io::Write;
102 let mut f = std::fs::OpenOptions::new()
103 .create(true)
104 .append(true)
105 .open(&path)?;
106 f.write_all(chunk.as_bytes())?;
107 Ok(path)
108 }
109
110 pub fn all_files(&self) -> Result<Vec<PathBuf>, MpsError> {
112 let re = mps_file_name_regexp();
113 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)?
114 .filter_map(|e| e.ok())
115 .map(|e| e.path())
116 .filter(|p| {
117 p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
118 && p.file_name()
119 .and_then(|n| n.to_str())
120 .map(|n| re.is_match(n))
121 .unwrap_or(false)
122 })
123 .collect();
124 files.sort();
125 Ok(files)
126 }
127
128 pub fn files_since(&self, since_date: NaiveDate) -> Result<Vec<PathBuf>, MpsError> {
130 let since_str = since_date.format("%Y%m%d").to_string();
131 let files = self
132 .all_files()?
133 .into_iter()
134 .filter(|p| {
135 p.file_name()
136 .and_then(|n| n.to_str())
137 .map(|n| &n[..8] >= since_str.as_str())
138 .unwrap_or(false)
139 })
140 .collect();
141 Ok(files)
142 }
143
144 pub fn all_file_dates(&self) -> Result<Vec<NaiveDate>, MpsError> {
146 let mut seen = std::collections::HashSet::new();
147 let mut dates: Vec<NaiveDate> = self
148 .all_files()?
149 .iter()
150 .filter_map(|p| {
151 p.file_name()
152 .and_then(|n| n.to_str())
153 .and_then(|n| NaiveDate::parse_from_str(&n[..8], "%Y%m%d").ok())
154 })
155 .filter(|d| seen.insert(*d))
156 .collect();
157 dates.sort();
158 Ok(dates)
159 }
160
161 pub fn rewrite_element(
169 &self,
170 ref_str: &str,
171 new_attrs: &HashMap<String, String>,
172 date: NaiveDate,
173 ) -> Result<bool, MpsError> {
174 let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
175 let (epoch_ref, path) = match (epoch_ref, path) {
176 (Some(e), Some(p)) => (e, p),
177 _ => return Ok(false),
178 };
179
180 let elements = match cached {
181 Some(els) => els,
182 None => parser::parse_file(&path)?,
183 };
184 let el = match elements.get(&epoch_ref) {
185 Some(e) => e.clone(),
186 None => return Ok(false),
187 };
188 if el.is_unknown() {
189 return Ok(false);
190 }
191
192 self.rewrite_element_in_file(&path, &el, &epoch_ref, &elements, new_attrs)
193 }
194
195 fn resolve_ref_with_elements(&self, ref_str: &str, date: NaiveDate) -> ResolveResult {
204 let is_epoch = ref_str.len() >= 10
206 && ref_str[..8].chars().all(|c| c.is_ascii_digit())
207 && ref_str.chars().nth(8) == Some('.')
208 && ref_str
209 .chars()
210 .nth(9)
211 .map(|c| c.is_ascii_digit())
212 .unwrap_or(false);
213
214 if is_epoch {
215 let d = NaiveDate::parse_from_str(&ref_str[..8], "%Y%m%d")
216 .map_err(|_| MpsError::DateParseError(ref_str[..8].to_string()))?;
217 let path = self.find_file(d);
218 Ok((Some(ref_str.to_string()), path, None))
219 } else {
220 let path = match self.find_file(date) {
221 Some(p) => p,
222 None => return Ok((None, None, None)),
223 };
224 let elements = parser::parse_file(&path)?;
225 let resolver = RefResolver::new(&elements);
226 let epoch_ref = resolver.to_epoch(ref_str).map(|s| s.to_string());
227 Ok((epoch_ref, Some(path), Some(elements)))
228 }
229 }
230
231 pub fn delete_element(&self, ref_str: &str, date: NaiveDate) -> Result<bool, MpsError> {
236 let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
237 let (epoch_ref, path) = match (epoch_ref, path) {
238 (Some(e), Some(p)) => (e, p),
239 _ => return Ok(false),
240 };
241 let content = std::fs::read_to_string(&path)?;
242 let elements = match cached {
243 Some(els) => els,
244 None => parser::parse_file(&path)?,
245 };
246 let el = match elements.get(&epoch_ref) {
247 Some(e) => e.clone(),
248 None => return Ok(false),
249 };
250 if el.is_unknown() {
251 return Ok(false);
252 }
253
254 let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
255 let (start, end) = match find_element_span(&content, el.sign(), el.raw_args(), occ) {
256 Some(s) => s,
257 None => return Ok(false),
258 };
259
260 let new_content = format!("{}{}", &content[..start], &content[end..]);
261 atomic_write(&path, &new_content)?;
262 Ok(true)
263 }
264
265 pub fn extract_element_body(
268 &self,
269 ref_str: &str,
270 date: NaiveDate,
271 ) -> Result<Option<String>, MpsError> {
272 let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
273 let (epoch_ref, path) = match (epoch_ref, path) {
274 (Some(e), Some(p)) => (e, p),
275 _ => return Ok(None),
276 };
277 let content = std::fs::read_to_string(&path)?;
278 let elements = match cached {
279 Some(els) => els,
280 None => parser::parse_file(&path)?,
281 };
282 let el = match elements.get(&epoch_ref) {
283 Some(e) => e.clone(),
284 None => return Ok(None),
285 };
286 if el.is_unknown() {
287 return Ok(None);
288 }
289
290 let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
291 let body = extract_body_text(&content, el.sign(), el.raw_args(), occ);
292 Ok(body)
293 }
294
295 pub fn replace_element_body(
298 &self,
299 ref_str: &str,
300 new_body: &str,
301 date: NaiveDate,
302 ) -> Result<bool, MpsError> {
303 let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
304 let (epoch_ref, path) = match (epoch_ref, path) {
305 (Some(e), Some(p)) => (e, p),
306 _ => return Ok(false),
307 };
308 let content = std::fs::read_to_string(&path)?;
309 let elements = match cached {
310 Some(els) => els,
311 None => parser::parse_file(&path)?,
312 };
313 let el = match elements.get(&epoch_ref) {
314 Some(e) => e.clone(),
315 None => return Ok(false),
316 };
317 if el.is_unknown() {
318 return Ok(false);
319 }
320
321 let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
322 let new_content = replace_body_text(&content, el.sign(), el.raw_args(), occ, new_body);
323 match new_content {
324 Some(nc) if nc != content => {
325 atomic_write(&path, &nc)?;
326 Ok(true)
327 }
328 _ => Ok(false),
329 }
330 }
331
332 fn occurrence_of(
337 &self,
338 epoch_ref: &str,
339 type_name: &str,
340 raw: &str,
341 all_elements: &IndexMap<String, Element>,
342 ) -> usize {
343 let mut sorted_keys: Vec<&String> = all_elements.keys().collect();
344 sorted_keys.sort_by(|a, b| {
345 let ap: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
346 let bp: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
347 ap.cmp(&bp)
348 });
349 let mut occurrence = 0usize;
350 for key in &sorted_keys {
351 if *key == epoch_ref {
352 break;
353 }
354 if !key.contains('.') {
355 continue;
356 }
357 if let Some(other) = all_elements.get(*key) {
358 if other.sign() == type_name && other.raw_args() == raw {
359 occurrence += 1;
360 }
361 }
362 }
363 occurrence
364 }
365
366 fn rewrite_element_in_file(
368 &self,
369 path: &Path,
370 el: &Element,
371 epoch_ref: &str,
372 all_elements: &IndexMap<String, Element>,
373 new_attrs: &HashMap<String, String>,
374 ) -> Result<bool, MpsError> {
375 let content = std::fs::read_to_string(path)?;
376 let type_name = el.sign();
377 let raw = el.raw_args();
378
379 let mut merged: Vec<(String, String)> = el.typed_attrs();
381 for (k, v) in new_attrs {
382 if let Some(pos) = merged.iter().position(|(ek, _)| ek == k) {
383 merged[pos].1 = v.clone();
384 } else {
385 merged.push((k.clone(), v.clone()));
386 }
387 }
388
389 let attr_parts: Vec<String> = merged
391 .iter()
392 .filter(|(_, v)| !v.is_empty())
393 .map(|(k, v)| format!("{}: {}", k, v))
394 .collect();
395 let new_args_str: String = attr_parts
396 .into_iter()
397 .chain(el.tags().iter().cloned())
398 .collect::<Vec<_>>()
399 .join(", ");
400
401 let esc_type = regex::escape(type_name);
403 let old_pat = if raw.is_empty() {
404 format!(r"@{}(?:\[\])?\s*\{{", esc_type)
405 } else {
406 format!(r"@{}\[{}\]\s*\{{", esc_type, regex::escape(raw))
407 };
408 let re = regex::Regex::new(&old_pat).map_err(|e| MpsError::ParseError {
409 file: path.display().to_string(),
410 msg: e.to_string(),
411 })?;
412
413 let occurrence = self.occurrence_of(epoch_ref, type_name, raw, all_elements);
414
415 let new_open = format!("@{}[{}]{{", type_name, new_args_str);
417 let new_content = re
418 .find_iter(&content)
419 .enumerate()
420 .find(|(n, _)| *n == occurrence)
421 .map(|(_, m)| {
422 format!(
423 "{}{}{}",
424 &content[..m.start()],
425 new_open,
426 &content[m.end()..]
427 )
428 });
429 let new_content: Option<String> = new_content;
430
431 let new_content = match new_content {
432 Some(c) => c,
433 None => return Ok(false),
434 };
435 if new_content == content {
436 return Ok(false);
437 }
438
439 atomic_write(path, &new_content)?;
440 Ok(true)
441 }
442
443 pub fn search(
445 &self,
446 query: &str,
447 type_filter: Option<&str>,
448 tag_filter: Option<&str>,
449 since_date: Option<NaiveDate>,
450 ) -> Result<Vec<SearchResult>, MpsError> {
451 let files = match since_date {
452 Some(d) => self.files_since(d)?,
453 None => self.all_files()?,
454 };
455
456 let query_lower = query.to_lowercase();
457 let mut results = Vec::new();
458
459 for file in files {
460 let date_str = file
461 .file_name()
462 .and_then(|n| n.to_str())
463 .map(|n| n[..8].to_string())
464 .unwrap_or_default();
465
466 let elements = parser::parse_file(&file)?;
467
468 for (epoch_ref, el) in elements {
469 if el.is_mps_group() || el.is_unknown() {
470 continue;
471 }
472
473 if let Some(tf) = type_filter {
474 if el.sign() != tf {
475 continue;
476 }
477 }
478 if let Some(tag) = tag_filter {
479 if !el.tags().iter().any(|t| t == tag) {
480 continue;
481 }
482 }
483 if !el.body_str().to_lowercase().contains(&query_lower) {
484 continue;
485 }
486
487 results.push(SearchResult {
488 element: el,
489 epoch_ref: epoch_ref.clone(),
490 file: file.clone(),
491 date_str: date_str.clone(),
492 });
493 }
494 }
495
496 Ok(results)
497 }
498}
499
500fn atomic_write(path: &Path, content: &str) -> Result<(), MpsError> {
504 let tmp = PathBuf::from(format!("{}.tmp.{}", path.display(), std::process::id()));
505 std::fs::write(&tmp, content)?;
506 std::fs::rename(&tmp, path)?;
507 Ok(())
508}
509
510fn opener_pattern(type_name: &str, raw: &str) -> String {
512 let esc = regex::escape(type_name);
513 if raw.is_empty() {
514 format!(r"@{}(?:\[\])?\s*\{{", esc)
515 } else {
516 format!(r"@{}\[{}\]\s*\{{", esc, regex::escape(raw))
517 }
518}
519
520fn find_element_span(
524 content: &str,
525 type_name: &str,
526 raw: &str,
527 occurrence: usize,
528) -> Option<(usize, usize)> {
529 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
530
531 let m = re.find_iter(content).nth(occurrence)?;
533
534 let line_start = content[..m.start()].rfind('\n').map(|p| p + 1).unwrap_or(0);
536
537 let brace_start = m.end() - 1;
540 let mut depth = 0i32;
541 let mut close_end = None;
542 for (i, c) in content[brace_start..].char_indices() {
543 match c {
544 '{' => depth += 1,
545 '}' => {
546 depth -= 1;
547 if depth == 0 {
548 close_end = Some(brace_start + i + 1); break;
550 }
551 }
552 _ => {}
553 }
554 }
555 let end_byte = close_end?;
556
557 let after_newline = if content[end_byte..].starts_with('\n') {
559 end_byte + 1
560 } else {
561 end_byte
562 };
563
564 Some((line_start, after_newline))
565}
566
567fn dedent(s: &str) -> String {
569 let min_indent = s
570 .lines()
571 .filter(|l| !l.trim().is_empty())
572 .map(|l| l.len() - l.trim_start().len())
573 .min()
574 .unwrap_or(0);
575 s.lines()
576 .map(|l| {
577 if l.len() >= min_indent {
578 &l[min_indent..]
579 } else {
580 l.trim_start()
581 }
582 })
583 .collect::<Vec<_>>()
584 .join("\n")
585}
586
587fn extract_body_text(
591 content: &str,
592 type_name: &str,
593 raw: &str,
594 occurrence: usize,
595) -> Option<String> {
596 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
597 let m = re.find_iter(content).nth(occurrence)?;
598
599 let body_start = m.end();
601 let brace_start = m.end() - 1;
603 let mut depth = 0i32;
604 let mut close_pos = None;
605 for (i, c) in content[brace_start..].char_indices() {
606 match c {
607 '{' => depth += 1,
608 '}' => {
609 depth -= 1;
610 if depth == 0 {
611 close_pos = Some(brace_start + i);
612 break;
613 }
614 }
615 _ => {}
616 }
617 }
618 let close = close_pos?;
619 let raw_body = content[body_start..close].trim_matches('\n');
620 Some(dedent(raw_body))
622}
623
624fn replace_body_text(
627 content: &str,
628 type_name: &str,
629 raw: &str,
630 occurrence: usize,
631 new_body: &str,
632) -> Option<String> {
633 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
634 let m = re.find_iter(content).nth(occurrence)?;
635
636 let body_start = m.end();
637 let brace_start = m.end() - 1;
638 let mut depth = 0i32;
639 let mut close_pos = None;
640 for (i, c) in content[brace_start..].char_indices() {
641 match c {
642 '{' => depth += 1,
643 '}' => {
644 depth -= 1;
645 if depth == 0 {
646 close_pos = Some(brace_start + i);
647 break;
648 }
649 }
650 _ => {}
651 }
652 }
653 let close = close_pos?;
654
655 let close_line_start = content[..close].rfind('\n').map(|p| p + 1).unwrap_or(0);
657 let close_indent: String = content[close_line_start..close]
658 .chars()
659 .take_while(|c| c.is_whitespace())
660 .collect();
661
662 let body_indent = &close_indent;
664 let indented_body: String = new_body
665 .lines()
666 .map(|line| {
667 if line.trim().is_empty() {
668 String::new()
669 } else {
670 format!("{} {}", body_indent, line.trim())
671 }
672 })
673 .collect::<Vec<_>>()
674 .join("\n");
675
676 Some(format!(
677 "{}\n{}\n{}{}",
678 &content[..body_start], indented_body,
680 close_indent,
681 &content[close..], ))
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::elements::ElementKind;
689
690 fn make_store(dir: &Path) -> Store {
691 Store::new(dir)
692 }
693
694 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
695 let path = dir.join(name);
696 std::fs::write(&path, content).unwrap();
697 path
698 }
699
700 #[test]
701 fn test_find_file_absent() {
702 let dir = tempfile::tempdir().unwrap();
703 let store = make_store(dir.path());
704 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
705 assert!(store.find_file(date).is_none());
706 }
707
708 #[test]
709 fn test_find_file_present() {
710 let dir = tempfile::tempdir().unwrap();
711 write_file(dir.path(), "20260101.1700000000.mps", "@task{ Hi }");
712 let store = make_store(dir.path());
713 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
714 assert!(store.find_file(date).is_some());
715 }
716
717 #[test]
718 fn test_parse_date_empty() {
719 let dir = tempfile::tempdir().unwrap();
720 let store = make_store(dir.path());
721 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
722 let els = store.parse_date(date).unwrap();
723 assert!(els.is_empty());
724 }
725
726 #[test]
727 fn test_append_creates_file() {
728 let dir = tempfile::tempdir().unwrap();
729 let store = make_store(dir.path());
730 let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
731 let path = store
732 .append("task", "Do a thing", &["work".into()], &[], date)
733 .unwrap();
734 assert!(path.exists());
735
736 let content = std::fs::read_to_string(&path).unwrap();
737 assert!(content.contains("@task"));
738 assert!(content.contains("Do a thing"));
739 }
740
741 #[test]
742 fn test_append_then_parse() {
743 let dir = tempfile::tempdir().unwrap();
744 let store = make_store(dir.path());
745 let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
746 store
747 .append("task", "Test task", &["work".into()], &[], date)
748 .unwrap();
749 let els = store.parse_date(date).unwrap();
750 assert!(els.len() >= 2);
752 let has_task = els.values().any(|e| e.kind() == ElementKind::Task);
753 assert!(has_task);
754 }
755
756 #[test]
757 fn test_search_by_query() {
758 let dir = tempfile::tempdir().unwrap();
759 write_file(
760 dir.path(),
761 "20260101.1700000000.mps",
762 "@task{ auth token fix }",
763 );
764 let store = make_store(dir.path());
765 let results = store.search("auth", None, None, None).unwrap();
766 assert_eq!(results.len(), 1);
767 assert_eq!(results[0].date_str, "20260101");
768 }
769
770 #[test]
771 fn test_files_since() {
772 let dir = tempfile::tempdir().unwrap();
773 write_file(dir.path(), "20260101.1700000000.mps", "@note{ old }");
774 write_file(dir.path(), "20260601.1800000000.mps", "@note{ new }");
775 let store = make_store(dir.path());
776 let since = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
777 let files = store.files_since(since).unwrap();
778 assert_eq!(files.len(), 1);
779 assert!(files[0].to_str().unwrap().contains("20260601"));
780 }
781
782 #[test]
785 fn test_delete_element_removes_it() {
786 let dir = tempfile::tempdir().unwrap();
787 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
788 let store = make_store(dir.path());
789 store.append("task", "Delete me", &[], &[], date).unwrap();
790 let els = store.parse_date(date).unwrap();
791 let epoch_ref = els
792 .keys()
793 .find(|k| k.contains('.') && els[*k].sign() == "task")
794 .unwrap()
795 .clone();
796 let removed = store.delete_element(&epoch_ref, date).unwrap();
797 assert!(removed);
798 let els2 = store.parse_date(date).unwrap();
799 assert!(!els2.values().any(|e| e.sign() == "task"));
800 }
801
802 #[test]
803 fn test_delete_element_absent_returns_false() {
804 let dir = tempfile::tempdir().unwrap();
805 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
806 let store = make_store(dir.path());
807 write_file(dir.path(), "20260601.1700000000.mps", "@note{ hi }");
808 let removed = store
809 .delete_element("20260601.1700000000.999", date)
810 .unwrap();
811 assert!(!removed);
812 }
813
814 #[test]
815 fn test_delete_element_file_still_valid_after() {
816 let dir = tempfile::tempdir().unwrap();
817 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
818 let store = make_store(dir.path());
819 store.append("task", "Keep me", &[], &[], date).unwrap();
820 store.append("note", "Also kept", &[], &[], date).unwrap();
821 store.append("task", "Delete me", &[], &[], date).unwrap();
822
823 let els = store.parse_date(date).unwrap();
824 let to_delete = els
825 .keys()
826 .rfind(|k| k.contains('.') && els[*k].sign() == "task")
827 .unwrap()
828 .clone();
829
830 store.delete_element(&to_delete, date).unwrap();
831
832 let els2 = store.parse_date(date).unwrap();
833 let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
834 assert_eq!(tasks.len(), 1);
835 assert!(tasks[0].body_str().contains("Keep me"));
836 assert!(els2.values().any(|e| e.sign() == "note"));
837 }
838
839 #[test]
842 fn test_extract_element_body_roundtrip() {
843 let dir = tempfile::tempdir().unwrap();
844 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
845 let store = make_store(dir.path());
846 store
847 .append("note", "Original body text", &[], &[], date)
848 .unwrap();
849
850 let els = store.parse_date(date).unwrap();
851 let epoch_ref = els
852 .keys()
853 .find(|k| k.contains('.') && els[*k].sign() == "note")
854 .unwrap()
855 .clone();
856
857 let body = store
858 .extract_element_body(&epoch_ref, date)
859 .unwrap()
860 .unwrap();
861 assert!(body.contains("Original body text"));
862 }
863
864 #[test]
865 fn test_replace_element_body_writes_new_text() {
866 let dir = tempfile::tempdir().unwrap();
867 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
868 let store = make_store(dir.path());
869 store.append("note", "Old text", &[], &[], date).unwrap();
870
871 let els = store.parse_date(date).unwrap();
872 let epoch_ref = els
873 .keys()
874 .find(|k| k.contains('.') && els[*k].sign() == "note")
875 .unwrap()
876 .clone();
877
878 let changed = store
879 .replace_element_body(&epoch_ref, "New text", date)
880 .unwrap();
881 assert!(changed);
882
883 let els2 = store.parse_date(date).unwrap();
884 let note = els2.values().find(|e| e.sign() == "note").unwrap();
885 assert!(note.body_str().contains("New text"));
886 assert!(!note.body_str().contains("Old text"));
887 }
888
889 #[test]
890 fn test_replace_element_body_same_content_returns_false() {
891 let dir = tempfile::tempdir().unwrap();
892 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
893 let store = make_store(dir.path());
894 store.append("note", "Same text", &[], &[], date).unwrap();
895
896 let els = store.parse_date(date).unwrap();
897 let epoch_ref = els
898 .keys()
899 .find(|k| k.contains('.') && els[*k].sign() == "note")
900 .unwrap()
901 .clone();
902
903 let body = store
904 .extract_element_body(&epoch_ref, date)
905 .unwrap()
906 .unwrap();
907 let changed = store.replace_element_body(&epoch_ref, &body, date).unwrap();
908 assert!(!changed, "no-op write should return false");
909 }
910
911 #[test]
914 fn test_find_element_span_basic() {
915 let content = "@task[work]{\n Fix the bug\n}\n";
916 let (start, end) = find_element_span(content, "task", "work", 0).unwrap();
917 assert_eq!(start, 0);
918 assert_eq!(&content[start..end], "@task[work]{\n Fix the bug\n}\n");
919 }
920
921 #[test]
922 fn test_find_element_span_second_occurrence() {
923 let content = "@note{\n first\n}\n@note{\n second\n}\n";
924 let (s1, e1) = find_element_span(content, "note", "", 0).unwrap();
925 let (s2, e2) = find_element_span(content, "note", "", 1).unwrap();
926 assert!(s1 < s2);
927 assert!(&content[s1..e1].contains("first"));
928 assert!(&content[s2..e2].contains("second"));
929 }
930
931 #[test]
932 fn test_extract_body_text_basic() {
933 let content = "@task[work]{\n Fix the bug\n}\n";
934 let body = extract_body_text(content, "task", "work", 0).unwrap();
935 assert_eq!(body.trim(), "Fix the bug");
936 }
937
938 #[test]
939 fn test_replace_body_text_basic() {
940 let content = "@task[work]{\n Fix the bug\n}\n";
941 let new = replace_body_text(content, "task", "work", 0, "Replaced body").unwrap();
942 assert!(new.contains("Replaced body"));
943 assert!(!new.contains("Fix the bug"));
944 assert!(new.contains("@task[work]{"));
945 assert!(new.contains('}'));
946 }
947
948 #[test]
949 fn test_replace_body_text_multiline() {
950 let content = "@note{\n line one\n line two\n}\n";
951 let new = replace_body_text(
952 content,
953 "note",
954 "",
955 0,
956 "new line one\nnew line two\nnew line three",
957 )
958 .unwrap();
959 assert!(new.contains("new line one"));
960 assert!(new.contains("new line three"));
961 assert!(!new.contains("line one\n line two"));
962 }
963
964 #[test]
967 fn test_dedent_already_clean() {
968 assert_eq!(dedent("Fix the bug"), "Fix the bug");
969 }
970
971 #[test]
972 fn test_dedent_strips_common_indent() {
973 let s = " line one\n line two";
974 assert_eq!(dedent(s), "line one\nline two");
975 }
976
977 #[test]
978 fn test_dedent_preserves_relative_indent() {
979 let s = " outer\n nested";
981 assert_eq!(dedent(s), "outer\n nested");
982 }
983
984 #[test]
985 fn test_dedent_ignores_empty_lines_for_min() {
986 let s = " line one\n\n line two";
988 assert_eq!(dedent(s), "line one\n\nline two");
989 }
990
991 #[test]
992 fn test_dedent_empty_string() {
993 assert_eq!(dedent(""), "");
994 }
995
996 #[test]
997 fn test_dedent_all_blank_lines() {
998 let s = "\n\n";
1000 assert_eq!(dedent(s), "\n");
1001 }
1002
1003 #[test]
1006 fn test_extract_body_text_dedents_for_editor() {
1007 let content = "@task[work]{\n Fix the bug\n and test it\n}\n";
1009 let body = extract_body_text(content, "task", "work", 0).unwrap();
1010 assert_eq!(body, "Fix the bug\nand test it");
1011 }
1012
1013 #[test]
1014 fn test_extract_body_text_empty_body() {
1015 let content = "@note{\n}\n";
1016 let body = extract_body_text(content, "note", "", 0).unwrap();
1017 assert_eq!(body, "");
1018 }
1019
1020 #[test]
1021 fn test_extract_body_text_single_line_no_indent() {
1022 let content = "@note{ quick note }\n";
1023 let body = extract_body_text(content, "note", "", 0).unwrap();
1024 assert_eq!(body.trim(), "quick note");
1025 }
1026
1027 #[test]
1030 fn test_extract_body_text_second_occurrence() {
1031 let content = "@note{\n first note\n}\n@note{\n second note\n}\n";
1032 let body1 = extract_body_text(content, "note", "", 0).unwrap();
1033 let body2 = extract_body_text(content, "note", "", 1).unwrap();
1034 assert_eq!(body1.trim(), "first note");
1035 assert_eq!(body2.trim(), "second note");
1036 }
1037
1038 #[test]
1041 fn test_replace_body_text_nested_braces_in_body() {
1042 let content = "@note{\n code: { x: 1 }\n}\n";
1044 let new = replace_body_text(content, "note", "", 0, "simple replacement").unwrap();
1045 assert!(new.contains("simple replacement"));
1046 assert!(!new.contains("code: { x: 1 }"));
1047 assert!(new.contains("@note{"));
1048 assert!(new.ends_with("}\n") || new.ends_with('}'));
1050 }
1051
1052 #[test]
1055 fn test_replace_body_text_second_occurrence_only() {
1056 let content = "@note{\n keep this\n}\n@note{\n replace this\n}\n";
1057 let new = replace_body_text(content, "note", "", 1, "replaced").unwrap();
1058 assert!(new.contains("keep this"), "first note must be untouched");
1059 assert!(new.contains("replaced"), "second note must be updated");
1060 assert!(
1061 !new.contains("replace this"),
1062 "old text of second note gone"
1063 );
1064 }
1065
1066 #[test]
1069 fn test_find_element_span_nested_does_not_confuse_brace_count() {
1070 let content = "@mps[sprint]{\n @task[work]{\n Do something\n }\n}\n";
1072 let (start, end) = find_element_span(content, "mps", "sprint", 0).unwrap();
1073 let span = &content[start..end];
1074 assert!(span.contains("@task"), "span must include nested task");
1075 assert!(span.starts_with("@mps"));
1076 }
1077
1078 #[test]
1081 fn test_find_element_span_absent_returns_none() {
1082 let content = "@note{\n hi\n}\n";
1083 assert!(find_element_span(content, "task", "", 0).is_none());
1084 assert!(find_element_span(content, "note", "", 1).is_none()); }
1086
1087 #[test]
1090 fn test_replace_body_text_empty_new_body() {
1091 let content = "@note{\n some text\n}\n";
1092 let new = replace_body_text(content, "note", "", 0, "").unwrap();
1093 assert!(new.contains("@note{"));
1095 assert!(new.contains('}'));
1096 assert!(!new.contains("some text"));
1098 }
1099
1100 #[test]
1101 fn test_extract_body_text_tabs_are_preserved_after_dedent() {
1102 let content = "@note{\n\ttab-indented\n}\n";
1105 let body = extract_body_text(content, "note", "", 0).unwrap();
1106 assert!(body.contains("tab-indented"));
1109 }
1110
1111 #[test]
1112 fn test_delete_element_second_of_two_same_type() {
1113 let dir = tempfile::tempdir().unwrap();
1114 let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
1115 let store = make_store(dir.path());
1116 store.append("note", "Keep me", &[], &[], date).unwrap();
1117 store.append("note", "Delete me", &[], &[], date).unwrap();
1118
1119 let els = store.parse_date(date).unwrap();
1120 let to_delete = els
1121 .iter()
1122 .filter(|(k, e)| {
1123 k.contains('.') && e.sign() == "note" && e.body_str().contains("Delete me")
1124 })
1125 .map(|(k, _)| k.clone())
1126 .next()
1127 .unwrap();
1128
1129 let removed = store.delete_element(&to_delete, date).unwrap();
1130 assert!(removed);
1131
1132 let els2 = store.parse_date(date).unwrap();
1133 let notes: Vec<_> = els2.values().filter(|e| e.sign() == "note").collect();
1134 assert_eq!(notes.len(), 1);
1135 assert!(
1136 notes[0].body_str().contains("Keep me"),
1137 "the wrong note was deleted"
1138 );
1139 assert!(!notes[0].body_str().contains("Delete me"));
1140 }
1141
1142 #[test]
1143 fn test_extract_and_replace_preserves_other_elements() {
1144 let dir = tempfile::tempdir().unwrap();
1145 let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();
1146 let store = make_store(dir.path());
1147 store.append("task", "Fix bug", &[], &[], date).unwrap();
1148 store.append("note", "Edit me", &[], &[], date).unwrap();
1149 store.append("task", "Write tests", &[], &[], date).unwrap();
1150
1151 let els = store.parse_date(date).unwrap();
1152 let note_ref = els
1153 .iter()
1154 .find(|(k, e)| k.contains('.') && e.sign() == "note")
1155 .map(|(k, _)| k.clone())
1156 .unwrap();
1157
1158 store
1159 .replace_element_body(¬e_ref, "Updated note", date)
1160 .unwrap();
1161
1162 let els2 = store.parse_date(date).unwrap();
1163 let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
1164 assert_eq!(tasks.len(), 2, "both tasks must survive the note edit");
1165 let note = els2.values().find(|e| e.sign() == "note").unwrap();
1166 assert!(note.body_str().contains("Updated note"));
1167 }
1168}