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 dedent(s: &str) -> String {
494 let min_indent = s.lines()
495 .filter(|l| !l.trim().is_empty())
496 .map(|l| l.len() - l.trim_start().len())
497 .min()
498 .unwrap_or(0);
499 s.lines()
500 .map(|l| if l.len() >= min_indent { &l[min_indent..] } else { l.trim_start() })
501 .collect::<Vec<_>>()
502 .join("\n")
503}
504
505fn extract_body_text(content: &str, type_name: &str, raw: &str, occurrence: usize) -> Option<String> {
509 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
510 let m = re.find_iter(content).nth(occurrence)?;
511
512 let body_start = m.end();
514 let brace_start = m.end() - 1;
516 let mut depth = 0i32;
517 let mut close_pos = 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 { close_pos = Some(brace_start + i); break; }
524 }
525 _ => {}
526 }
527 }
528 let close = close_pos?;
529 let raw_body = content[body_start..close].trim_matches('\n');
530 Some(dedent(raw_body))
532}
533
534fn replace_body_text(content: &str, type_name: &str, raw: &str, occurrence: usize, new_body: &str) -> Option<String> {
537 let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
538 let m = re.find_iter(content).nth(occurrence)?;
539
540 let body_start = m.end();
541 let brace_start = m.end() - 1;
542 let mut depth = 0i32;
543 let mut close_pos = None;
544 for (i, c) in content[brace_start..].char_indices() {
545 match c {
546 '{' => depth += 1,
547 '}' => {
548 depth -= 1;
549 if depth == 0 { close_pos = Some(brace_start + i); break; }
550 }
551 _ => {}
552 }
553 }
554 let close = close_pos?;
555
556 let close_line_start = content[..close].rfind('\n').map(|p| p + 1).unwrap_or(0);
558 let close_indent: String = content[close_line_start..close]
559 .chars().take_while(|c| c.is_whitespace()).collect();
560
561 let body_indent = &close_indent;
563 let indented_body: String = new_body.lines()
564 .map(|line| {
565 if line.trim().is_empty() { String::new() }
566 else { format!("{} {}", body_indent, line.trim()) }
567 })
568 .collect::<Vec<_>>()
569 .join("\n");
570
571 Some(format!(
572 "{}\n{}\n{}{}",
573 &content[..body_start], indented_body,
575 close_indent,
576 &content[close..], ))
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use crate::elements::ElementKind;
584
585 fn make_store(dir: &Path) -> Store {
586 Store::new(dir)
587 }
588
589 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
590 let path = dir.join(name);
591 std::fs::write(&path, content).unwrap();
592 path
593 }
594
595 #[test]
596 fn test_find_file_absent() {
597 let dir = tempfile::tempdir().unwrap();
598 let store = make_store(dir.path());
599 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
600 assert!(store.find_file(date).is_none());
601 }
602
603 #[test]
604 fn test_find_file_present() {
605 let dir = tempfile::tempdir().unwrap();
606 write_file(dir.path(), "20260101.1700000000.mps", "@task{ Hi }");
607 let store = make_store(dir.path());
608 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
609 assert!(store.find_file(date).is_some());
610 }
611
612 #[test]
613 fn test_parse_date_empty() {
614 let dir = tempfile::tempdir().unwrap();
615 let store = make_store(dir.path());
616 let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
617 let els = store.parse_date(date).unwrap();
618 assert!(els.is_empty());
619 }
620
621 #[test]
622 fn test_append_creates_file() {
623 let dir = tempfile::tempdir().unwrap();
624 let store = make_store(dir.path());
625 let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
626 let path = store.append("task", "Do a thing", &["work".into()], &[], date).unwrap();
627 assert!(path.exists());
628
629 let content = std::fs::read_to_string(&path).unwrap();
630 assert!(content.contains("@task"));
631 assert!(content.contains("Do a thing"));
632 }
633
634 #[test]
635 fn test_append_then_parse() {
636 let dir = tempfile::tempdir().unwrap();
637 let store = make_store(dir.path());
638 let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
639 store.append("task", "Test task", &["work".into()], &[], date).unwrap();
640 let els = store.parse_date(date).unwrap();
641 assert!(els.len() >= 2);
643 let has_task = els.values().any(|e| e.kind() == ElementKind::Task);
644 assert!(has_task);
645 }
646
647 #[test]
648 fn test_search_by_query() {
649 let dir = tempfile::tempdir().unwrap();
650 write_file(dir.path(), "20260101.1700000000.mps", "@task{ auth token fix }");
651 let store = make_store(dir.path());
652 let results = store.search("auth", None, None, None).unwrap();
653 assert_eq!(results.len(), 1);
654 assert_eq!(results[0].date_str, "20260101");
655 }
656
657 #[test]
658 fn test_files_since() {
659 let dir = tempfile::tempdir().unwrap();
660 write_file(dir.path(), "20260101.1700000000.mps", "@note{ old }");
661 write_file(dir.path(), "20260601.1800000000.mps", "@note{ new }");
662 let store = make_store(dir.path());
663 let since = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
664 let files = store.files_since(since).unwrap();
665 assert_eq!(files.len(), 1);
666 assert!(files[0].to_str().unwrap().contains("20260601"));
667 }
668
669 #[test]
672 fn test_delete_element_removes_it() {
673 let dir = tempfile::tempdir().unwrap();
674 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
675 let store = make_store(dir.path());
676 store.append("task", "Delete me", &[], &[], date).unwrap();
677 let els = store.parse_date(date).unwrap();
678 let epoch_ref = els.keys()
679 .find(|k| k.contains('.') && els[*k].sign() == "task")
680 .unwrap().clone();
681 let removed = store.delete_element(&epoch_ref, date).unwrap();
682 assert!(removed);
683 let els2 = store.parse_date(date).unwrap();
684 assert!(!els2.values().any(|e| e.sign() == "task"));
685 }
686
687 #[test]
688 fn test_delete_element_absent_returns_false() {
689 let dir = tempfile::tempdir().unwrap();
690 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
691 let store = make_store(dir.path());
692 write_file(dir.path(), "20260601.1700000000.mps", "@note{ hi }");
693 let removed = store.delete_element("20260601.1700000000.999", date).unwrap();
694 assert!(!removed);
695 }
696
697 #[test]
698 fn test_delete_element_file_still_valid_after() {
699 let dir = tempfile::tempdir().unwrap();
700 let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
701 let store = make_store(dir.path());
702 store.append("task", "Keep me", &[], &[], date).unwrap();
703 store.append("note", "Also kept", &[], &[], date).unwrap();
704 store.append("task", "Delete me", &[], &[], date).unwrap();
705
706 let els = store.parse_date(date).unwrap();
707 let to_delete = els.keys()
708 .filter(|k| k.contains('.') && els[*k].sign() == "task")
709 .last().unwrap().clone();
710
711 store.delete_element(&to_delete, date).unwrap();
712
713 let els2 = store.parse_date(date).unwrap();
714 let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
715 assert_eq!(tasks.len(), 1);
716 assert!(tasks[0].body_str().contains("Keep me"));
717 assert!(els2.values().any(|e| e.sign() == "note"));
718 }
719
720 #[test]
723 fn test_extract_element_body_roundtrip() {
724 let dir = tempfile::tempdir().unwrap();
725 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
726 let store = make_store(dir.path());
727 store.append("note", "Original body text", &[], &[], date).unwrap();
728
729 let els = store.parse_date(date).unwrap();
730 let epoch_ref = els.keys()
731 .find(|k| k.contains('.') && els[*k].sign() == "note")
732 .unwrap().clone();
733
734 let body = store.extract_element_body(&epoch_ref, date).unwrap().unwrap();
735 assert!(body.contains("Original body text"));
736 }
737
738 #[test]
739 fn test_replace_element_body_writes_new_text() {
740 let dir = tempfile::tempdir().unwrap();
741 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
742 let store = make_store(dir.path());
743 store.append("note", "Old text", &[], &[], date).unwrap();
744
745 let els = store.parse_date(date).unwrap();
746 let epoch_ref = els.keys()
747 .find(|k| k.contains('.') && els[*k].sign() == "note")
748 .unwrap().clone();
749
750 let changed = store.replace_element_body(&epoch_ref, "New text", date).unwrap();
751 assert!(changed);
752
753 let els2 = store.parse_date(date).unwrap();
754 let note = els2.values().find(|e| e.sign() == "note").unwrap();
755 assert!(note.body_str().contains("New text"));
756 assert!(!note.body_str().contains("Old text"));
757 }
758
759 #[test]
760 fn test_replace_element_body_same_content_returns_false() {
761 let dir = tempfile::tempdir().unwrap();
762 let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
763 let store = make_store(dir.path());
764 store.append("note", "Same text", &[], &[], date).unwrap();
765
766 let els = store.parse_date(date).unwrap();
767 let epoch_ref = els.keys()
768 .find(|k| k.contains('.') && els[*k].sign() == "note")
769 .unwrap().clone();
770
771 let body = store.extract_element_body(&epoch_ref, date).unwrap().unwrap();
772 let changed = store.replace_element_body(&epoch_ref, &body, date).unwrap();
773 assert!(!changed, "no-op write should return false");
774 }
775
776 #[test]
779 fn test_find_element_span_basic() {
780 let content = "@task[work]{\n Fix the bug\n}\n";
781 let (start, end) = find_element_span(content, "task", "work", 0).unwrap();
782 assert_eq!(start, 0);
783 assert_eq!(&content[start..end], "@task[work]{\n Fix the bug\n}\n");
784 }
785
786 #[test]
787 fn test_find_element_span_second_occurrence() {
788 let content = "@note{\n first\n}\n@note{\n second\n}\n";
789 let (s1, e1) = find_element_span(content, "note", "", 0).unwrap();
790 let (s2, e2) = find_element_span(content, "note", "", 1).unwrap();
791 assert!(s1 < s2);
792 assert!(&content[s1..e1].contains("first"));
793 assert!(&content[s2..e2].contains("second"));
794 }
795
796 #[test]
797 fn test_extract_body_text_basic() {
798 let content = "@task[work]{\n Fix the bug\n}\n";
799 let body = extract_body_text(content, "task", "work", 0).unwrap();
800 assert_eq!(body.trim(), "Fix the bug");
801 }
802
803 #[test]
804 fn test_replace_body_text_basic() {
805 let content = "@task[work]{\n Fix the bug\n}\n";
806 let new = replace_body_text(content, "task", "work", 0, "Replaced body").unwrap();
807 assert!(new.contains("Replaced body"));
808 assert!(!new.contains("Fix the bug"));
809 assert!(new.contains("@task[work]{"));
810 assert!(new.contains('}'));
811 }
812
813 #[test]
814 fn test_replace_body_text_multiline() {
815 let content = "@note{\n line one\n line two\n}\n";
816 let new = replace_body_text(content, "note", "", 0, "new line one\nnew line two\nnew line three").unwrap();
817 assert!(new.contains("new line one"));
818 assert!(new.contains("new line three"));
819 assert!(!new.contains("line one\n line two"));
820 }
821
822 #[test]
825 fn test_dedent_already_clean() {
826 assert_eq!(dedent("Fix the bug"), "Fix the bug");
827 }
828
829 #[test]
830 fn test_dedent_strips_common_indent() {
831 let s = " line one\n line two";
832 assert_eq!(dedent(s), "line one\nline two");
833 }
834
835 #[test]
836 fn test_dedent_preserves_relative_indent() {
837 let s = " outer\n nested";
839 assert_eq!(dedent(s), "outer\n nested");
840 }
841
842 #[test]
843 fn test_dedent_ignores_empty_lines_for_min() {
844 let s = " line one\n\n line two";
846 assert_eq!(dedent(s), "line one\n\nline two");
847 }
848
849 #[test]
850 fn test_dedent_empty_string() {
851 assert_eq!(dedent(""), "");
852 }
853
854 #[test]
855 fn test_dedent_all_blank_lines() {
856 let s = "\n\n";
858 assert_eq!(dedent(s), "\n");
859 }
860
861 #[test]
864 fn test_extract_body_text_dedents_for_editor() {
865 let content = "@task[work]{\n Fix the bug\n and test it\n}\n";
867 let body = extract_body_text(content, "task", "work", 0).unwrap();
868 assert_eq!(body, "Fix the bug\nand test it");
869 }
870
871 #[test]
872 fn test_extract_body_text_empty_body() {
873 let content = "@note{\n}\n";
874 let body = extract_body_text(content, "note", "", 0).unwrap();
875 assert_eq!(body, "");
876 }
877
878 #[test]
879 fn test_extract_body_text_single_line_no_indent() {
880 let content = "@note{ quick note }\n";
881 let body = extract_body_text(content, "note", "", 0).unwrap();
882 assert_eq!(body.trim(), "quick note");
883 }
884
885 #[test]
888 fn test_extract_body_text_second_occurrence() {
889 let content = "@note{\n first note\n}\n@note{\n second note\n}\n";
890 let body1 = extract_body_text(content, "note", "", 0).unwrap();
891 let body2 = extract_body_text(content, "note", "", 1).unwrap();
892 assert_eq!(body1.trim(), "first note");
893 assert_eq!(body2.trim(), "second note");
894 }
895
896 #[test]
899 fn test_replace_body_text_nested_braces_in_body() {
900 let content = "@note{\n code: { x: 1 }\n}\n";
902 let new = replace_body_text(content, "note", "", 0, "simple replacement").unwrap();
903 assert!(new.contains("simple replacement"));
904 assert!(!new.contains("code: { x: 1 }"));
905 assert!(new.contains("@note{"));
906 assert!(new.ends_with("}\n") || new.ends_with('}'));
908 }
909
910 #[test]
913 fn test_replace_body_text_second_occurrence_only() {
914 let content = "@note{\n keep this\n}\n@note{\n replace this\n}\n";
915 let new = replace_body_text(content, "note", "", 1, "replaced").unwrap();
916 assert!(new.contains("keep this"), "first note must be untouched");
917 assert!(new.contains("replaced"), "second note must be updated");
918 assert!(!new.contains("replace this"), "old text of second note gone");
919 }
920
921 #[test]
924 fn test_find_element_span_nested_does_not_confuse_brace_count() {
925 let content = "@mps[sprint]{\n @task[work]{\n Do something\n }\n}\n";
927 let (start, end) = find_element_span(content, "mps", "sprint", 0).unwrap();
928 let span = &content[start..end];
929 assert!(span.contains("@task"), "span must include nested task");
930 assert!(span.starts_with("@mps"));
931 }
932
933 #[test]
936 fn test_find_element_span_absent_returns_none() {
937 let content = "@note{\n hi\n}\n";
938 assert!(find_element_span(content, "task", "", 0).is_none());
939 assert!(find_element_span(content, "note", "", 1).is_none()); }
941
942 #[test]
945 fn test_replace_body_text_empty_new_body() {
946 let content = "@note{\n some text\n}\n";
947 let new = replace_body_text(content, "note", "", 0, "").unwrap();
948 assert!(new.contains("@note{"));
950 assert!(new.contains('}'));
951 assert!(!new.contains("some text"));
953 }
954
955 #[test]
956 fn test_extract_body_text_tabs_are_preserved_after_dedent() {
957 let content = "@note{\n\ttab-indented\n}\n";
960 let body = extract_body_text(content, "note", "", 0).unwrap();
961 assert!(body.contains("tab-indented"));
964 }
965
966 #[test]
967 fn test_delete_element_second_of_two_same_type() {
968 let dir = tempfile::tempdir().unwrap();
969 let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
970 let store = make_store(dir.path());
971 store.append("note", "Keep me", &[], &[], date).unwrap();
972 store.append("note", "Delete me", &[], &[], date).unwrap();
973
974 let els = store.parse_date(date).unwrap();
975 let to_delete = els.iter()
976 .filter(|(k, e)| k.contains('.') && e.sign() == "note" && e.body_str().contains("Delete me"))
977 .map(|(k, _)| k.clone())
978 .next().unwrap();
979
980 let removed = store.delete_element(&to_delete, date).unwrap();
981 assert!(removed);
982
983 let els2 = store.parse_date(date).unwrap();
984 let notes: Vec<_> = els2.values().filter(|e| e.sign() == "note").collect();
985 assert_eq!(notes.len(), 1);
986 assert!(notes[0].body_str().contains("Keep me"), "the wrong note was deleted");
987 assert!(!notes[0].body_str().contains("Delete me"));
988 }
989
990 #[test]
991 fn test_extract_and_replace_preserves_other_elements() {
992 let dir = tempfile::tempdir().unwrap();
993 let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();
994 let store = make_store(dir.path());
995 store.append("task", "Fix bug", &[], &[], date).unwrap();
996 store.append("note", "Edit me", &[], &[], date).unwrap();
997 store.append("task", "Write tests", &[], &[], date).unwrap();
998
999 let els = store.parse_date(date).unwrap();
1000 let note_ref = els.iter()
1001 .find(|(k, e)| k.contains('.') && e.sign() == "note")
1002 .map(|(k, _)| k.clone()).unwrap();
1003
1004 store.replace_element_body(¬e_ref, "Updated note", date).unwrap();
1005
1006 let els2 = store.parse_date(date).unwrap();
1007 let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
1008 assert_eq!(tasks.len(), 2, "both tasks must survive the note edit");
1009 let note = els2.values().find(|e| e.sign() == "note").unwrap();
1010 assert!(note.body_str().contains("Updated note"));
1011 }
1012}