1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use chrono::NaiveDate;
9use indexmap::IndexMap;
10use crate::constants::{mps_file_name_regexp, new_file_name, MPS_EXT};
11use crate::elements::Element;
12use crate::error::MpsError;
13use crate::parser;
14use crate::ref_resolver::RefResolver;
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 { storage_dir: storage_dir.into() }
30 }
31
32 pub fn find_file(&self, date: NaiveDate) -> Option<PathBuf> {
34 self.find_files(date).into_iter().next()
35 }
36
37 pub fn find_files(&self, date: NaiveDate) -> Vec<PathBuf> {
39 let prefix = date.format("%Y%m%d").to_string();
40 let re = mps_file_name_regexp();
41 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)
42 .map(|rd| {
43 rd.filter_map(|e| e.ok())
44 .map(|e| e.path())
45 .filter(|p| {
46 p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
47 && p.file_name()
48 .and_then(|n| n.to_str())
49 .map(|n| re.is_match(n) && n.starts_with(&prefix))
50 .unwrap_or(false)
51 })
52 .collect()
53 })
54 .unwrap_or_default();
55 files.sort();
56 files
57 }
58
59 pub fn find_or_create_path(&self, date: NaiveDate) -> PathBuf {
61 self.find_file(date)
62 .unwrap_or_else(|| self.storage_dir.join(new_file_name(date)))
63 }
64
65 pub fn parse_date(&self, date: NaiveDate) -> Result<IndexMap<String, Element>, MpsError> {
67 match self.find_file(date) {
68 None => Ok(IndexMap::new()),
69 Some(p) => parser::parse_file(&p),
70 }
71 }
72
73 pub fn append(
75 &self,
76 kind: &str,
77 body: &str,
78 tags: &[String],
79 attrs: &[(&str, &str)],
80 date: NaiveDate,
81 ) -> Result<PathBuf, MpsError> {
82 let mut parts: Vec<String> = attrs.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
83 parts.extend(tags.iter().cloned());
84 let args_str = parts.join(", ");
85
86 let path = self.find_or_create_path(date);
87 let chunk = format!("\n@{}[{}]{{\n {}\n}}\n", kind, args_str, body);
88 use std::io::Write;
89 let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
90 f.write_all(chunk.as_bytes())?;
91 Ok(path)
92 }
93
94 pub fn all_files(&self) -> Result<Vec<PathBuf>, MpsError> {
96 let re = mps_file_name_regexp();
97 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)?
98 .filter_map(|e| e.ok())
99 .map(|e| e.path())
100 .filter(|p| {
101 p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
102 && p.file_name()
103 .and_then(|n| n.to_str())
104 .map(|n| re.is_match(n))
105 .unwrap_or(false)
106 })
107 .collect();
108 files.sort();
109 Ok(files)
110 }
111
112 pub fn files_since(&self, since_date: NaiveDate) -> Result<Vec<PathBuf>, MpsError> {
114 let since_str = since_date.format("%Y%m%d").to_string();
115 let files = self.all_files()?
116 .into_iter()
117 .filter(|p| {
118 p.file_name()
119 .and_then(|n| n.to_str())
120 .map(|n| &n[..8] >= since_str.as_str())
121 .unwrap_or(false)
122 })
123 .collect();
124 Ok(files)
125 }
126
127 pub fn all_file_dates(&self) -> Result<Vec<NaiveDate>, MpsError> {
129 let mut seen = std::collections::HashSet::new();
130 let mut dates: Vec<NaiveDate> = self.all_files()?
131 .iter()
132 .filter_map(|p| {
133 p.file_name()
134 .and_then(|n| n.to_str())
135 .and_then(|n| NaiveDate::parse_from_str(&n[..8], "%Y%m%d").ok())
136 })
137 .filter(|d| seen.insert(*d))
138 .collect();
139 dates.sort();
140 Ok(dates)
141 }
142
143 pub fn rewrite_element(
151 &self,
152 ref_str: &str,
153 new_attrs: &HashMap<String, String>,
154 date: NaiveDate,
155 ) -> Result<bool, MpsError> {
156 let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
157 let (epoch_ref, path) = match (epoch_ref, path) {
158 (Some(e), Some(p)) => (e, p),
159 _ => return Ok(false),
160 };
161
162 let elements = parser::parse_file(&path)?;
163 let el = match elements.get(&epoch_ref) {
164 Some(e) => e.clone(),
165 None => return Ok(false),
166 };
167 if el.is_unknown() { return Ok(false); }
168
169 self.rewrite_element_in_file(&path, &el, &epoch_ref, &elements, new_attrs)
170 }
171
172 fn resolve_ref_to_path(
177 &self,
178 ref_str: &str,
179 date: NaiveDate,
180 ) -> Result<(Option<String>, Option<PathBuf>), MpsError> {
181 let is_epoch = ref_str.len() >= 10
183 && ref_str[..8].chars().all(|c| c.is_ascii_digit())
184 && ref_str.chars().nth(8) == Some('.')
185 && ref_str.chars().nth(9).map(|c| c.is_ascii_digit()).unwrap_or(false);
186
187 if is_epoch {
188 let d = NaiveDate::parse_from_str(&ref_str[..8], "%Y%m%d")
189 .map_err(|_| MpsError::DateParseError(ref_str[..8].to_string()))?;
190 let path = self.find_file(d);
191 Ok((Some(ref_str.to_string()), path))
192 } else {
193 let path = match self.find_file(date) {
194 Some(p) => p,
195 None => return Ok((None, None)),
196 };
197 let elements = parser::parse_file(&path)?;
198 let resolver = RefResolver::new(&elements);
199 let epoch_ref = resolver.to_epoch(ref_str).map(|s| s.to_string());
200 Ok((epoch_ref, Some(path)))
201 }
202 }
203
204 pub fn delete_element(&self, ref_str: &str, date: NaiveDate) -> Result<bool, MpsError> {
209 let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
210 let (epoch_ref, path) = match (epoch_ref, path) {
211 (Some(e), Some(p)) => (e, p),
212 _ => return Ok(false),
213 };
214 let content = std::fs::read_to_string(&path)?;
215 let elements = parser::parse_file(&path)?;
216 let el = match elements.get(&epoch_ref) {
217 Some(e) => e.clone(),
218 None => return Ok(false),
219 };
220 if el.is_unknown() { return Ok(false); }
221
222 let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
223 let (start, end) = match find_element_span(&content, el.sign(), el.raw_args(), occ) {
224 Some(s) => s,
225 None => return Ok(false),
226 };
227
228 let new_content = format!("{}{}", &content[..start], &content[end..]);
229 atomic_write(&path, &new_content)?;
230 Ok(true)
231 }
232
233 pub fn extract_element_body(&self, ref_str: &str, date: NaiveDate) -> Result<Option<String>, MpsError> {
236 let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
237 let (epoch_ref, path) = match (epoch_ref, path) {
238 (Some(e), Some(p)) => (e, p),
239 _ => return Ok(None),
240 };
241 let content = std::fs::read_to_string(&path)?;
242 let elements = parser::parse_file(&path)?;
243 let el = match elements.get(&epoch_ref) {
244 Some(e) => e.clone(),
245 None => return Ok(None),
246 };
247 if el.is_unknown() { return Ok(None); }
248
249 let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
250 let body = extract_body_text(&content, el.sign(), el.raw_args(), occ);
251 Ok(body)
252 }
253
254 pub fn replace_element_body(
257 &self,
258 ref_str: &str,
259 new_body: &str,
260 date: NaiveDate,
261 ) -> Result<bool, MpsError> {
262 let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
263 let (epoch_ref, path) = match (epoch_ref, path) {
264 (Some(e), Some(p)) => (e, p),
265 _ => return Ok(false),
266 };
267 let content = std::fs::read_to_string(&path)?;
268 let elements = parser::parse_file(&path)?;
269 let el = match elements.get(&epoch_ref) {
270 Some(e) => e.clone(),
271 None => return Ok(false),
272 };
273 if el.is_unknown() { return Ok(false); }
274
275 let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
276 let new_content = replace_body_text(&content, el.sign(), el.raw_args(), occ, new_body);
277 match new_content {
278 Some(nc) if nc != content => {
279 atomic_write(&path, &nc)?;
280 Ok(true)
281 }
282 _ => Ok(false),
283 }
284 }
285
286 fn occurrence_of(
291 &self,
292 epoch_ref: &str,
293 type_name: &str,
294 raw: &str,
295 all_elements: &IndexMap<String, Element>,
296 ) -> usize {
297 let mut sorted_keys: Vec<&String> = all_elements.keys().collect();
298 sorted_keys.sort_by(|a, b| {
299 let ap: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
300 let bp: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
301 ap.cmp(&bp)
302 });
303 let mut occurrence = 0usize;
304 for key in &sorted_keys {
305 if *key == epoch_ref { break; }
306 if !key.contains('.') { continue; }
307 if let Some(other) = all_elements.get(*key) {
308 if other.sign() == type_name && other.raw_args() == raw {
309 occurrence += 1;
310 }
311 }
312 }
313 occurrence
314 }
315
316 fn rewrite_element_in_file(
318 &self,
319 path: &Path,
320 el: &Element,
321 epoch_ref: &str,
322 all_elements: &IndexMap<String, Element>,
323 new_attrs: &HashMap<String, String>,
324 ) -> Result<bool, MpsError> {
325 let content = std::fs::read_to_string(path)?;
326 let type_name = el.sign();
327 let raw = el.raw_args();
328
329 let mut merged: Vec<(String, String)> = el.typed_attrs();
331 for (k, v) in new_attrs {
332 if let Some(pos) = merged.iter().position(|(ek, _)| ek == k) {
333 merged[pos].1 = v.clone();
334 } else {
335 merged.push((k.clone(), v.clone()));
336 }
337 }
338
339 let attr_parts: Vec<String> = merged.iter()
341 .filter(|(_, v)| !v.is_empty())
342 .map(|(k, v)| format!("{}: {}", k, v))
343 .collect();
344 let new_args_str: String = attr_parts.into_iter()
345 .chain(el.tags().iter().cloned())
346 .collect::<Vec<_>>()
347 .join(", ");
348
349 let esc_type = regex::escape(type_name);
351 let old_pat = if raw.is_empty() {
352 format!(r"@{}(?:\[\])?\s*\{{", esc_type)
353 } else {
354 format!(r"@{}\[{}\]\s*\{{", esc_type, regex::escape(raw))
355 };
356 let re = regex::Regex::new(&old_pat)
357 .map_err(|e| MpsError::ParseError { file: path.display().to_string(), msg: e.to_string() })?;
358
359 let occurrence = self.occurrence_of(epoch_ref, type_name, raw, all_elements);
360
361 let new_open = format!("@{}[{}]{{", type_name, new_args_str);
363 let mut match_n = 0usize;
364 let mut new_content: Option<String> = None;
365 for m in re.find_iter(&content) {
366 if match_n == occurrence {
367 new_content = Some(format!("{}{}{}", &content[..m.start()], new_open, &content[m.end()..]));
368 break;
369 }
370 match_n += 1;
371 }
372
373 let new_content = match new_content {
374 Some(c) => c,
375 None => return Ok(false),
376 };
377 if new_content == content { return Ok(false); }
378
379 atomic_write(path, &new_content)?;
380 Ok(true)
381 }
382
383 pub fn search(
385 &self,
386 query: &str,
387 type_filter: Option<&str>,
388 tag_filter: Option<&str>,
389 since_date: Option<NaiveDate>,
390 ) -> Result<Vec<SearchResult>, MpsError> {
391 let files = match since_date {
392 Some(d) => self.files_since(d)?,
393 None => self.all_files()?,
394 };
395
396 let query_lower = query.to_lowercase();
397 let mut results = Vec::new();
398
399 for file in files {
400 let date_str = file.file_name()
401 .and_then(|n| n.to_str())
402 .map(|n| n[..8].to_string())
403 .unwrap_or_default();
404
405 let elements = parser::parse_file(&file)?;
406
407 for (_, el) in elements {
408 if el.is_mps_group() || el.is_unknown() { continue; }
409
410 if let Some(tf) = type_filter {
411 if el.sign() != tf { continue; }
412 }
413 if let Some(tag) = tag_filter {
414 if !el.tags().iter().any(|t| t == tag) { continue; }
415 }
416 if !el.body_str().to_lowercase().contains(&query_lower) { continue; }
417
418 results.push(SearchResult {
419 element: el,
420 file: file.clone(),
421 date_str: date_str.clone(),
422 });
423 }
424 }
425
426 Ok(results)
427 }
428}
429
430fn atomic_write(path: &Path, content: &str) -> Result<(), MpsError> {
434 let tmp = PathBuf::from(format!("{}.tmp.{}", path.display(), std::process::id()));
435 std::fs::write(&tmp, content)?;
436 std::fs::rename(&tmp, path)?;
437 Ok(())
438}
439
440fn opener_pattern(type_name: &str, raw: &str) -> String {
442 let esc = regex::escape(type_name);
443 if raw.is_empty() {
444 format!(r"@{}(?:\[\])?\s*\{{", esc)
445 } else {
446 format!(r"@{}\[{}\]\s*\{{", esc, regex::escape(raw))
447 }
448}
449
450fn find_element_span(content: &str, type_name: &str, raw: &str, occurrence: usize) -> Option<(usize, usize)> {
454 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
455
456 let m = re.find_iter(content).nth(occurrence)?;
458
459 let line_start = content[..m.start()].rfind('\n').map(|p| p + 1).unwrap_or(0);
461
462 let brace_start = m.end() - 1;
465 let mut depth = 0i32;
466 let mut close_end = None;
467 for (i, c) in content[brace_start..].char_indices() {
468 match c {
469 '{' => depth += 1,
470 '}' => {
471 depth -= 1;
472 if depth == 0 {
473 close_end = Some(brace_start + i + 1); break;
475 }
476 }
477 _ => {}
478 }
479 }
480 let end_byte = close_end?;
481
482 let after_newline = if content[end_byte..].starts_with('\n') {
484 end_byte + 1
485 } else {
486 end_byte
487 };
488
489 Some((line_start, after_newline))
490}
491
492fn extract_body_text(content: &str, type_name: &str, raw: &str, occurrence: usize) -> Option<String> {
495 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
496 let m = re.find_iter(content).nth(occurrence)?;
497
498 let body_start = m.end();
500 let brace_start = m.end() - 1;
502 let mut depth = 0i32;
503 let mut close_pos = None;
504 for (i, c) in content[brace_start..].char_indices() {
505 match c {
506 '{' => depth += 1,
507 '}' => {
508 depth -= 1;
509 if depth == 0 { close_pos = Some(brace_start + i); break; }
510 }
511 _ => {}
512 }
513 }
514 let close = close_pos?;
515 let body = &content[body_start..close];
516 Some(body.trim_matches('\n').to_string())
518}
519
520fn replace_body_text(content: &str, type_name: &str, raw: &str, occurrence: usize, new_body: &str) -> Option<String> {
523 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
524 let m = re.find_iter(content).nth(occurrence)?;
525
526 let body_start = m.end();
527 let brace_start = m.end() - 1;
528 let mut depth = 0i32;
529 let mut close_pos = None;
530 for (i, c) in content[brace_start..].char_indices() {
531 match c {
532 '{' => depth += 1,
533 '}' => {
534 depth -= 1;
535 if depth == 0 { close_pos = Some(brace_start + i); break; }
536 }
537 _ => {}
538 }
539 }
540 let close = close_pos?;
541
542 let close_line_start = content[..close].rfind('\n').map(|p| p + 1).unwrap_or(0);
544 let close_indent: String = content[close_line_start..close]
545 .chars().take_while(|c| c.is_whitespace()).collect();
546
547 let body_indent = &close_indent;
549 let indented_body: String = new_body.lines()
550 .map(|line| {
551 if line.trim().is_empty() { String::new() }
552 else { format!("{} {}", body_indent, line.trim()) }
553 })
554 .collect::<Vec<_>>()
555 .join("\n");
556
557 Some(format!(
558 "{}\n{}\n{}{}",
559 &content[..body_start], indented_body,
561 close_indent,
562 &content[close..], ))
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::elements::ElementKind;
570
571 fn make_store(dir: &Path) -> Store {
572 Store::new(dir)
573 }
574
575 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
576 let path = dir.join(name);
577 std::fs::write(&path, content).unwrap();
578 path
579 }
580
581 #[test]
582 fn test_find_file_absent() {
583 let dir = tempfile::tempdir().unwrap();
584 let store = make_store(dir.path());
585 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
586 assert!(store.find_file(date).is_none());
587 }
588
589 #[test]
590 fn test_find_file_present() {
591 let dir = tempfile::tempdir().unwrap();
592 write_file(dir.path(), "20260101.1700000000.mps", "@task{ Hi }");
593 let store = make_store(dir.path());
594 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
595 assert!(store.find_file(date).is_some());
596 }
597
598 #[test]
599 fn test_parse_date_empty() {
600 let dir = tempfile::tempdir().unwrap();
601 let store = make_store(dir.path());
602 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
603 let els = store.parse_date(date).unwrap();
604 assert!(els.is_empty());
605 }
606
607 #[test]
608 fn test_append_creates_file() {
609 let dir = tempfile::tempdir().unwrap();
610 let store = make_store(dir.path());
611 let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
612 let path = store.append("task", "Do a thing", &["work".into()], &[], date).unwrap();
613 assert!(path.exists());
614
615 let content = std::fs::read_to_string(&path).unwrap();
616 assert!(content.contains("@task"));
617 assert!(content.contains("Do a thing"));
618 }
619
620 #[test]
621 fn test_append_then_parse() {
622 let dir = tempfile::tempdir().unwrap();
623 let store = make_store(dir.path());
624 let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
625 store.append("task", "Test task", &["work".into()], &[], date).unwrap();
626 let els = store.parse_date(date).unwrap();
627 assert!(els.len() >= 2);
629 let has_task = els.values().any(|e| e.kind() == ElementKind::Task);
630 assert!(has_task);
631 }
632
633 #[test]
634 fn test_search_by_query() {
635 let dir = tempfile::tempdir().unwrap();
636 write_file(dir.path(), "20260101.1700000000.mps", "@task{ auth token fix }");
637 let store = make_store(dir.path());
638 let results = store.search("auth", None, None, None).unwrap();
639 assert_eq!(results.len(), 1);
640 assert_eq!(results[0].date_str, "20260101");
641 }
642
643 #[test]
644 fn test_files_since() {
645 let dir = tempfile::tempdir().unwrap();
646 write_file(dir.path(), "20260101.1700000000.mps", "@note{ old }");
647 write_file(dir.path(), "20260601.1800000000.mps", "@note{ new }");
648 let store = make_store(dir.path());
649 let since = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
650 let files = store.files_since(since).unwrap();
651 assert_eq!(files.len(), 1);
652 assert!(files[0].to_str().unwrap().contains("20260601"));
653 }
654
655 #[test]
658 fn test_delete_element_removes_it() {
659 let dir = tempfile::tempdir().unwrap();
660 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
661 let store = make_store(dir.path());
662 store.append("task", "Delete me", &[], &[], date).unwrap();
663 let els = store.parse_date(date).unwrap();
664 let epoch_ref = els.keys()
665 .find(|k| k.contains('.') && els[*k].sign() == "task")
666 .unwrap().clone();
667 let removed = store.delete_element(&epoch_ref, date).unwrap();
668 assert!(removed);
669 let els2 = store.parse_date(date).unwrap();
670 assert!(!els2.values().any(|e| e.sign() == "task"));
671 }
672
673 #[test]
674 fn test_delete_element_absent_returns_false() {
675 let dir = tempfile::tempdir().unwrap();
676 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
677 let store = make_store(dir.path());
678 write_file(dir.path(), "20260601.1700000000.mps", "@note{ hi }");
679 let removed = store.delete_element("20260601.1700000000.999", date).unwrap();
680 assert!(!removed);
681 }
682
683 #[test]
684 fn test_delete_element_file_still_valid_after() {
685 let dir = tempfile::tempdir().unwrap();
686 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
687 let store = make_store(dir.path());
688 store.append("task", "Keep me", &[], &[], date).unwrap();
689 store.append("note", "Also kept", &[], &[], date).unwrap();
690 store.append("task", "Delete me", &[], &[], date).unwrap();
691
692 let els = store.parse_date(date).unwrap();
693 let to_delete = els.keys()
694 .filter(|k| k.contains('.') && els[*k].sign() == "task")
695 .last().unwrap().clone();
696
697 store.delete_element(&to_delete, date).unwrap();
698
699 let els2 = store.parse_date(date).unwrap();
700 let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
701 assert_eq!(tasks.len(), 1);
702 assert!(tasks[0].body_str().contains("Keep me"));
703 assert!(els2.values().any(|e| e.sign() == "note"));
704 }
705
706 #[test]
709 fn test_extract_element_body_roundtrip() {
710 let dir = tempfile::tempdir().unwrap();
711 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
712 let store = make_store(dir.path());
713 store.append("note", "Original body text", &[], &[], date).unwrap();
714
715 let els = store.parse_date(date).unwrap();
716 let epoch_ref = els.keys()
717 .find(|k| k.contains('.') && els[*k].sign() == "note")
718 .unwrap().clone();
719
720 let body = store.extract_element_body(&epoch_ref, date).unwrap().unwrap();
721 assert!(body.contains("Original body text"));
722 }
723
724 #[test]
725 fn test_replace_element_body_writes_new_text() {
726 let dir = tempfile::tempdir().unwrap();
727 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
728 let store = make_store(dir.path());
729 store.append("note", "Old text", &[], &[], date).unwrap();
730
731 let els = store.parse_date(date).unwrap();
732 let epoch_ref = els.keys()
733 .find(|k| k.contains('.') && els[*k].sign() == "note")
734 .unwrap().clone();
735
736 let changed = store.replace_element_body(&epoch_ref, "New text", date).unwrap();
737 assert!(changed);
738
739 let els2 = store.parse_date(date).unwrap();
740 let note = els2.values().find(|e| e.sign() == "note").unwrap();
741 assert!(note.body_str().contains("New text"));
742 assert!(!note.body_str().contains("Old text"));
743 }
744
745 #[test]
746 fn test_replace_element_body_same_content_returns_false() {
747 let dir = tempfile::tempdir().unwrap();
748 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
749 let store = make_store(dir.path());
750 store.append("note", "Same text", &[], &[], date).unwrap();
751
752 let els = store.parse_date(date).unwrap();
753 let epoch_ref = els.keys()
754 .find(|k| k.contains('.') && els[*k].sign() == "note")
755 .unwrap().clone();
756
757 let body = store.extract_element_body(&epoch_ref, date).unwrap().unwrap();
758 let changed = store.replace_element_body(&epoch_ref, &body, date).unwrap();
759 assert!(!changed, "no-op write should return false");
760 }
761
762 #[test]
765 fn test_find_element_span_basic() {
766 let content = "@task[work]{\n Fix the bug\n}\n";
767 let (start, end) = find_element_span(content, "task", "work", 0).unwrap();
768 assert_eq!(start, 0);
769 assert_eq!(&content[start..end], "@task[work]{\n Fix the bug\n}\n");
770 }
771
772 #[test]
773 fn test_find_element_span_second_occurrence() {
774 let content = "@note{\n first\n}\n@note{\n second\n}\n";
775 let (s1, e1) = find_element_span(content, "note", "", 0).unwrap();
776 let (s2, e2) = find_element_span(content, "note", "", 1).unwrap();
777 assert!(s1 < s2);
778 assert!(&content[s1..e1].contains("first"));
779 assert!(&content[s2..e2].contains("second"));
780 }
781
782 #[test]
783 fn test_extract_body_text_basic() {
784 let content = "@task[work]{\n Fix the bug\n}\n";
785 let body = extract_body_text(content, "task", "work", 0).unwrap();
786 assert_eq!(body.trim(), "Fix the bug");
787 }
788
789 #[test]
790 fn test_replace_body_text_basic() {
791 let content = "@task[work]{\n Fix the bug\n}\n";
792 let new = replace_body_text(content, "task", "work", 0, "Replaced body").unwrap();
793 assert!(new.contains("Replaced body"));
794 assert!(!new.contains("Fix the bug"));
795 assert!(new.contains("@task[work]{"));
796 assert!(new.contains('}'));
797 }
798
799 #[test]
800 fn test_replace_body_text_multiline() {
801 let content = "@note{\n line one\n line two\n}\n";
802 let new = replace_body_text(content, "note", "", 0, "new line one\nnew line two\nnew line three").unwrap();
803 assert!(new.contains("new line one"));
804 assert!(new.contains("new line three"));
805 assert!(!new.contains("line one\n line two"));
806 }
807}