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