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