Skip to main content

mps/
store.rs

1//! File-system layer — discovers, reads, and writes `.mps` files.
2//!
3//! The CLI delegates all I/O to [`Store`]; no direct file operations happen
4//! in command handlers.
5
6use 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/// Return type for [`Store::resolve_ref_with_elements`]: (epoch_ref, file_path, elements).
17type ResolveResult = Result<
18    (
19        Option<String>,
20        Option<PathBuf>,
21        Option<IndexMap<String, Element>>,
22    ),
23    MpsError,
24>;
25
26#[allow(dead_code)]
27pub struct SearchResult {
28    pub element: Element,
29    pub file: PathBuf,
30    pub date_str: String, // "YYYYMMDD"
31    pub epoch_ref: String,
32}
33
34pub struct Store {
35    storage_dir: PathBuf,
36}
37
38impl Store {
39    pub fn new(storage_dir: impl Into<PathBuf>) -> Self {
40        Store {
41            storage_dir: storage_dir.into(),
42        }
43    }
44
45    /// First .mps file matching date, or None.
46    pub fn find_file(&self, date: NaiveDate) -> Option<PathBuf> {
47        self.find_files(date).into_iter().next()
48    }
49
50    /// All .mps files matching date (handles multiple files per day).
51    pub fn find_files(&self, date: NaiveDate) -> Vec<PathBuf> {
52        let prefix = date.format("%Y%m%d").to_string();
53        let re = mps_file_name_regexp();
54        let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)
55            .map(|rd| {
56                rd.filter_map(|e| e.ok())
57                    .map(|e| e.path())
58                    .filter(|p| {
59                        p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
60                            && p.file_name()
61                                .and_then(|n| n.to_str())
62                                .map(|n| re.is_match(n) && n.starts_with(&prefix))
63                                .unwrap_or(false)
64                    })
65                    .collect()
66            })
67            .unwrap_or_default();
68        files.sort();
69        files
70    }
71
72    /// Existing file for date, or a generated new path (file not yet created).
73    pub fn find_or_create_path(&self, date: NaiveDate) -> PathBuf {
74        self.find_file(date)
75            .unwrap_or_else(|| self.storage_dir.join(new_file_name(date)))
76    }
77
78    /// Parsed elements for date. Returns empty map if no file exists.
79    pub fn parse_date(&self, date: NaiveDate) -> Result<IndexMap<String, Element>, MpsError> {
80        match self.find_file(date) {
81            None => Ok(IndexMap::new()),
82            Some(p) => parser::parse_file(&p),
83        }
84    }
85
86    /// Append a new element to date's file (creates file if absent).
87    pub fn append(
88        &self,
89        kind: &str,
90        body: &str,
91        tags: &[String],
92        attrs: &[(&str, &str)],
93        date: NaiveDate,
94    ) -> Result<PathBuf, MpsError> {
95        let mut parts: Vec<String> = attrs.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
96        parts.extend(tags.iter().cloned());
97        let args_str = parts.join(", ");
98
99        let path = self.find_or_create_path(date);
100        let chunk = format!("\n@{}[{}]{{\n  {}\n}}\n", kind, args_str, body);
101        use std::io::Write;
102        let mut f = std::fs::OpenOptions::new()
103            .create(true)
104            .append(true)
105            .open(&path)?;
106        f.write_all(chunk.as_bytes())?;
107        Ok(path)
108    }
109
110    /// All .mps files in storage_dir, sorted by filename (chronological).
111    pub fn all_files(&self) -> Result<Vec<PathBuf>, MpsError> {
112        let re = mps_file_name_regexp();
113        let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)?
114            .filter_map(|e| e.ok())
115            .map(|e| e.path())
116            .filter(|p| {
117                p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
118                    && p.file_name()
119                        .and_then(|n| n.to_str())
120                        .map(|n| re.is_match(n))
121                        .unwrap_or(false)
122            })
123            .collect();
124        files.sort();
125        Ok(files)
126    }
127
128    /// Files whose date-stamp >= since_date.
129    pub fn files_since(&self, since_date: NaiveDate) -> Result<Vec<PathBuf>, MpsError> {
130        let since_str = since_date.format("%Y%m%d").to_string();
131        let files = self
132            .all_files()?
133            .into_iter()
134            .filter(|p| {
135                p.file_name()
136                    .and_then(|n| n.to_str())
137                    .map(|n| &n[..8] >= since_str.as_str())
138                    .unwrap_or(false)
139            })
140            .collect();
141        Ok(files)
142    }
143
144    /// Unique sorted dates for which .mps files exist.
145    pub fn all_file_dates(&self) -> Result<Vec<NaiveDate>, MpsError> {
146        let mut seen = std::collections::HashSet::new();
147        let mut dates: Vec<NaiveDate> = self
148            .all_files()?
149            .iter()
150            .filter_map(|p| {
151                p.file_name()
152                    .and_then(|n| n.to_str())
153                    .and_then(|n| NaiveDate::parse_from_str(&n[..8], "%Y%m%d").ok())
154            })
155            .filter(|d| seen.insert(*d))
156            .collect();
157        dates.sort();
158        Ok(dates)
159    }
160
161    /// Rewrite an element's typed attributes in-place, atomically.
162    ///
163    /// `ref_str` may be an epoch ref (e.g. "20260428.1") or a human ref (e.g. "task-1").
164    /// Human refs are resolved against `date` (defaults to today in the caller).
165    /// `new_attrs` maps attribute name → new value (e.g. {"status" → "done"}).
166    ///
167    /// Returns `true` on success, `false` if ref not found or file unchanged.
168    pub fn rewrite_element(
169        &self,
170        ref_str: &str,
171        new_attrs: &HashMap<String, String>,
172        date: NaiveDate,
173    ) -> Result<bool, MpsError> {
174        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
175        let (epoch_ref, path) = match (epoch_ref, path) {
176            (Some(e), Some(p)) => (e, p),
177            _ => return Ok(false),
178        };
179
180        let elements = match cached {
181            Some(els) => els,
182            None => parser::parse_file(&path)?,
183        };
184        let el = match elements.get(&epoch_ref) {
185            Some(e) => e.clone(),
186            None => return Ok(false),
187        };
188        if el.is_unknown() {
189            return Ok(false);
190        }
191
192        self.rewrite_element_in_file(&path, &el, &epoch_ref, &elements, new_attrs)
193    }
194
195    // ── private helpers ──────────────────────────────────────────────────────
196
197    /// Resolve a ref string to (epoch_ref, file_path, maybe_elements).
198    ///
199    /// For human refs the file must be parsed to look up the epoch ref; the parsed
200    /// elements are returned as the third field so callers can reuse them instead of
201    /// parsing a second time.  For epoch refs no parse is needed, so `None` is returned
202    /// and the caller should parse when needed.
203    fn resolve_ref_with_elements(&self, ref_str: &str, date: NaiveDate) -> ResolveResult {
204        // Epoch ref: 8 ASCII digits followed by '.' then a digit
205        let is_epoch = ref_str.len() >= 10
206            && ref_str[..8].chars().all(|c| c.is_ascii_digit())
207            && ref_str.chars().nth(8) == Some('.')
208            && ref_str
209                .chars()
210                .nth(9)
211                .map(|c| c.is_ascii_digit())
212                .unwrap_or(false);
213
214        if is_epoch {
215            let d = NaiveDate::parse_from_str(&ref_str[..8], "%Y%m%d")
216                .map_err(|_| MpsError::DateParseError(ref_str[..8].to_string()))?;
217            let path = self.find_file(d);
218            Ok((Some(ref_str.to_string()), path, None))
219        } else {
220            let path = match self.find_file(date) {
221                Some(p) => p,
222                None => return Ok((None, None, None)),
223            };
224            let elements = parser::parse_file(&path)?;
225            let resolver = RefResolver::new(&elements);
226            let epoch_ref = resolver.to_epoch(ref_str).map(|s| s.to_string());
227            Ok((epoch_ref, Some(path), Some(elements)))
228        }
229    }
230
231    // ── Public mutating operations ───────────────────────────────────────────
232
233    /// Delete an element entirely from its file.
234    /// Returns `true` if the element was found and removed, `false` if not found.
235    pub fn delete_element(&self, ref_str: &str, date: NaiveDate) -> Result<bool, MpsError> {
236        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
237        let (epoch_ref, path) = match (epoch_ref, path) {
238            (Some(e), Some(p)) => (e, p),
239            _ => return Ok(false),
240        };
241        let content = std::fs::read_to_string(&path)?;
242        let elements = match cached {
243            Some(els) => els,
244            None => parser::parse_file(&path)?,
245        };
246        let el = match elements.get(&epoch_ref) {
247            Some(e) => e.clone(),
248            None => return Ok(false),
249        };
250        if el.is_unknown() {
251            return Ok(false);
252        }
253
254        let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
255        let (start, end) = match find_element_span(&content, el.sign(), el.raw_args(), occ) {
256            Some(s) => s,
257            None => return Ok(false),
258        };
259
260        let new_content = format!("{}{}", &content[..start], &content[end..]);
261        atomic_write(&path, &new_content)?;
262        Ok(true)
263    }
264
265    /// Extract the body text of an element (the content between its `{` and `}`).
266    /// Returns `None` if the element is not found.
267    pub fn extract_element_body(
268        &self,
269        ref_str: &str,
270        date: NaiveDate,
271    ) -> Result<Option<String>, MpsError> {
272        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
273        let (epoch_ref, path) = match (epoch_ref, path) {
274            (Some(e), Some(p)) => (e, p),
275            _ => return Ok(None),
276        };
277        let content = std::fs::read_to_string(&path)?;
278        let elements = match cached {
279            Some(els) => els,
280            None => parser::parse_file(&path)?,
281        };
282        let el = match elements.get(&epoch_ref) {
283            Some(e) => e.clone(),
284            None => return Ok(None),
285        };
286        if el.is_unknown() {
287            return Ok(None);
288        }
289
290        let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
291        let body = extract_body_text(&content, el.sign(), el.raw_args(), occ);
292        Ok(body)
293    }
294
295    /// Replace an element's body text in-place.
296    /// `new_body` should NOT include the surrounding `{` / `}` braces.
297    pub fn replace_element_body(
298        &self,
299        ref_str: &str,
300        new_body: &str,
301        date: NaiveDate,
302    ) -> Result<bool, MpsError> {
303        let (epoch_ref, path, cached) = self.resolve_ref_with_elements(ref_str, date)?;
304        let (epoch_ref, path) = match (epoch_ref, path) {
305            (Some(e), Some(p)) => (e, p),
306            _ => return Ok(false),
307        };
308        let content = std::fs::read_to_string(&path)?;
309        let elements = match cached {
310            Some(els) => els,
311            None => parser::parse_file(&path)?,
312        };
313        let el = match elements.get(&epoch_ref) {
314            Some(e) => e.clone(),
315            None => return Ok(false),
316        };
317        if el.is_unknown() {
318            return Ok(false);
319        }
320
321        let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
322        let new_content = replace_body_text(&content, el.sign(), el.raw_args(), occ, new_body);
323        match new_content {
324            Some(nc) if nc != content => {
325                atomic_write(&path, &nc)?;
326                Ok(true)
327            }
328            _ => Ok(false),
329        }
330    }
331
332    // ── private helpers ──────────────────────────────────────────────────────
333
334    /// 0-indexed count of elements with the same (sign, raw_args) that appear
335    /// before `epoch_ref` in file order. Used to disambiguate duplicate openers.
336    fn occurrence_of(
337        &self,
338        epoch_ref: &str,
339        type_name: &str,
340        raw: &str,
341        all_elements: &IndexMap<String, Element>,
342    ) -> usize {
343        let mut sorted_keys: Vec<&String> = all_elements.keys().collect();
344        sorted_keys.sort_by(|a, b| {
345            let ap: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
346            let bp: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
347            ap.cmp(&bp)
348        });
349        let mut occurrence = 0usize;
350        for key in &sorted_keys {
351            if *key == epoch_ref {
352                break;
353            }
354            if !key.contains('.') {
355                continue;
356            }
357            if let Some(other) = all_elements.get(*key) {
358                if other.sign() == type_name && other.raw_args() == raw {
359                    occurrence += 1;
360                }
361            }
362        }
363        occurrence
364    }
365
366    /// Rewrite the `@type[args]{` opening line in-place and save atomically.
367    fn rewrite_element_in_file(
368        &self,
369        path: &Path,
370        el: &Element,
371        epoch_ref: &str,
372        all_elements: &IndexMap<String, Element>,
373        new_attrs: &HashMap<String, String>,
374    ) -> Result<bool, MpsError> {
375        let content = std::fs::read_to_string(path)?;
376        let type_name = el.sign();
377        let raw = el.raw_args();
378
379        // Merge new attrs over existing typed attrs (preserve order; append new keys at end).
380        let mut merged: Vec<(String, String)> = el.typed_attrs();
381        for (k, v) in new_attrs {
382            if let Some(pos) = merged.iter().position(|(ek, _)| ek == k) {
383                merged[pos].1 = v.clone();
384            } else {
385                merged.push((k.clone(), v.clone()));
386            }
387        }
388
389        // Build new args string: named attrs first, then tags.
390        let attr_parts: Vec<String> = merged
391            .iter()
392            .filter(|(_, v)| !v.is_empty())
393            .map(|(k, v)| format!("{}: {}", k, v))
394            .collect();
395        let new_args_str: String = attr_parts
396            .into_iter()
397            .chain(el.tags().iter().cloned())
398            .collect::<Vec<_>>()
399            .join(", ");
400
401        // Build a regex matching the current element opening line.
402        let esc_type = regex::escape(type_name);
403        let old_pat = if raw.is_empty() {
404            format!(r"@{}(?:\[\])?\s*\{{", esc_type)
405        } else {
406            format!(r"@{}\[{}\]\s*\{{", esc_type, regex::escape(raw))
407        };
408        let re = regex::Regex::new(&old_pat).map_err(|e| MpsError::ParseError {
409            file: path.display().to_string(),
410            msg: e.to_string(),
411        })?;
412
413        let occurrence = self.occurrence_of(epoch_ref, type_name, raw, all_elements);
414
415        // Replace specifically the (occurrence)-th match (0-indexed).
416        let new_open = format!("@{}[{}]{{", type_name, new_args_str);
417        let new_content = re
418            .find_iter(&content)
419            .enumerate()
420            .find(|(n, _)| *n == occurrence)
421            .map(|(_, m)| {
422                format!(
423                    "{}{}{}",
424                    &content[..m.start()],
425                    new_open,
426                    &content[m.end()..]
427                )
428            });
429        let new_content: Option<String> = new_content;
430
431        let new_content = match new_content {
432            Some(c) => c,
433            None => return Ok(false),
434        };
435        if new_content == content {
436            return Ok(false);
437        }
438
439        atomic_write(path, &new_content)?;
440        Ok(true)
441    }
442
443    /// Full-text search across files. Returns matched elements with file and date_str.
444    pub fn search(
445        &self,
446        query: &str,
447        type_filter: Option<&str>,
448        tag_filter: Option<&str>,
449        since_date: Option<NaiveDate>,
450    ) -> Result<Vec<SearchResult>, MpsError> {
451        let files = match since_date {
452            Some(d) => self.files_since(d)?,
453            None => self.all_files()?,
454        };
455
456        let query_lower = query.to_lowercase();
457        let mut results = Vec::new();
458
459        for file in files {
460            let date_str = file
461                .file_name()
462                .and_then(|n| n.to_str())
463                .map(|n| n[..8].to_string())
464                .unwrap_or_default();
465
466            let elements = parser::parse_file(&file)?;
467
468            for (epoch_ref, el) in elements {
469                if el.is_mps_group() || el.is_unknown() {
470                    continue;
471                }
472
473                if let Some(tf) = type_filter {
474                    if el.sign() != tf {
475                        continue;
476                    }
477                }
478                if let Some(tag) = tag_filter {
479                    if !el.tags().iter().any(|t| t == tag) {
480                        continue;
481                    }
482                }
483                if !el.body_str().to_lowercase().contains(&query_lower) {
484                    continue;
485                }
486
487                results.push(SearchResult {
488                    element: el,
489                    epoch_ref: epoch_ref.clone(),
490                    file: file.clone(),
491                    date_str: date_str.clone(),
492                });
493            }
494        }
495
496        Ok(results)
497    }
498}
499
500// ── File-level helpers ────────────────────────────────────────────────────────
501
502/// Atomic write: write to tmp then rename (POSIX-atomic).
503fn atomic_write(path: &Path, content: &str) -> Result<(), MpsError> {
504    let tmp = PathBuf::from(format!("{}.tmp.{}", path.display(), std::process::id()));
505    std::fs::write(&tmp, content)?;
506    std::fs::rename(&tmp, path)?;
507    Ok(())
508}
509
510/// Build the opening-line regex pattern for `@type[raw]{`.
511fn opener_pattern(type_name: &str, raw: &str) -> String {
512    let esc = regex::escape(type_name);
513    if raw.is_empty() {
514        format!(r"@{}(?:\[\])?\s*\{{", esc)
515    } else {
516        format!(r"@{}\[{}\]\s*\{{", esc, regex::escape(raw))
517    }
518}
519
520/// Find the byte range `(line_start, after_close_newline)` of the `occurrence`-th
521/// element with signature `(type_name, raw)` in `content`.
522/// The range covers the entire element: opening line, body, closing `}` and its newline.
523fn find_element_span(
524    content: &str,
525    type_name: &str,
526    raw: &str,
527    occurrence: usize,
528) -> Option<(usize, usize)> {
529    let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
530
531    // Find the nth occurrence of the opening pattern.
532    let m = re.find_iter(content).nth(occurrence)?;
533
534    // Start of the full line containing the opener.
535    let line_start = content[..m.start()].rfind('\n').map(|p| p + 1).unwrap_or(0);
536
537    // Walk forward from the `{` counting brace depth to find the matching `}`.
538    // m.end() points to the char after `{`, so the `{` is at m.end()-1.
539    let brace_start = m.end() - 1;
540    let mut depth = 0i32;
541    let mut close_end = None;
542    for (i, c) in content[brace_start..].char_indices() {
543        match c {
544            '{' => depth += 1,
545            '}' => {
546                depth -= 1;
547                if depth == 0 {
548                    close_end = Some(brace_start + i + 1); // byte after `}`
549                    break;
550                }
551            }
552            _ => {}
553        }
554    }
555    let end_byte = close_end?;
556
557    // Include the newline that follows the closing `}`, if present.
558    let after_newline = if content[end_byte..].starts_with('\n') {
559        end_byte + 1
560    } else {
561        end_byte
562    };
563
564    Some((line_start, after_newline))
565}
566
567/// Strip the minimum common leading whitespace from all non-empty lines.
568fn dedent(s: &str) -> String {
569    let min_indent = s
570        .lines()
571        .filter(|l| !l.trim().is_empty())
572        .map(|l| l.len() - l.trim_start().len())
573        .min()
574        .unwrap_or(0);
575    s.lines()
576        .map(|l| {
577            if l.len() >= min_indent {
578                &l[min_indent..]
579            } else {
580                l.trim_start()
581            }
582        })
583        .collect::<Vec<_>>()
584        .join("\n")
585}
586
587/// Extract the body text (the content between `{` and `}`) for the given element.
588/// Leading/trailing blank lines are stripped; common indentation is removed (dedented)
589/// so the editor sees clean unindented content.
590fn extract_body_text(
591    content: &str,
592    type_name: &str,
593    raw: &str,
594    occurrence: usize,
595) -> Option<String> {
596    let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
597    let m = re.find_iter(content).nth(occurrence)?;
598
599    // Body starts right after the `{` (end of the match).
600    let body_start = m.end();
601    // Find matching `}`.
602    let brace_start = m.end() - 1;
603    let mut depth = 0i32;
604    let mut close_pos = None;
605    for (i, c) in content[brace_start..].char_indices() {
606        match c {
607            '{' => depth += 1,
608            '}' => {
609                depth -= 1;
610                if depth == 0 {
611                    close_pos = Some(brace_start + i);
612                    break;
613                }
614            }
615            _ => {}
616        }
617    }
618    let close = close_pos?;
619    let raw_body = content[body_start..close].trim_matches('\n');
620    // Dedent so the editor shows "Fix the bug" not "  Fix the bug".
621    Some(dedent(raw_body))
622}
623
624/// Replace the body text of the `occurrence`-th element.
625/// Returns the new full file content, or `None` if the element was not found.
626fn replace_body_text(
627    content: &str,
628    type_name: &str,
629    raw: &str,
630    occurrence: usize,
631    new_body: &str,
632) -> Option<String> {
633    let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
634    let m = re.find_iter(content).nth(occurrence)?;
635
636    let body_start = m.end();
637    let brace_start = m.end() - 1;
638    let mut depth = 0i32;
639    let mut close_pos = None;
640    for (i, c) in content[brace_start..].char_indices() {
641        match c {
642            '{' => depth += 1,
643            '}' => {
644                depth -= 1;
645                if depth == 0 {
646                    close_pos = Some(brace_start + i);
647                    break;
648                }
649            }
650            _ => {}
651        }
652    }
653    let close = close_pos?;
654
655    // Preserve indentation of the closing `}` line.
656    let close_line_start = content[..close].rfind('\n').map(|p| p + 1).unwrap_or(0);
657    let close_indent: String = content[close_line_start..close]
658        .chars()
659        .take_while(|c| c.is_whitespace())
660        .collect();
661
662    // Re-indent new_body to match the element's indentation.
663    let body_indent = &close_indent;
664    let indented_body: String = new_body
665        .lines()
666        .map(|line| {
667            if line.trim().is_empty() {
668                String::new()
669            } else {
670                format!("{}  {}", body_indent, line.trim())
671            }
672        })
673        .collect::<Vec<_>>()
674        .join("\n");
675
676    Some(format!(
677        "{}\n{}\n{}{}",
678        &content[..body_start], // everything up to and including `{`
679        indented_body,
680        close_indent,
681        &content[close..], // `}` and everything after
682    ))
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::elements::ElementKind;
689
690    fn make_store(dir: &Path) -> Store {
691        Store::new(dir)
692    }
693
694    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
695        let path = dir.join(name);
696        std::fs::write(&path, content).unwrap();
697        path
698    }
699
700    #[test]
701    fn test_find_file_absent() {
702        let dir = tempfile::tempdir().unwrap();
703        let store = make_store(dir.path());
704        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
705        assert!(store.find_file(date).is_none());
706    }
707
708    #[test]
709    fn test_find_file_present() {
710        let dir = tempfile::tempdir().unwrap();
711        write_file(dir.path(), "20260101.1700000000.mps", "@task{ Hi }");
712        let store = make_store(dir.path());
713        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
714        assert!(store.find_file(date).is_some());
715    }
716
717    #[test]
718    fn test_parse_date_empty() {
719        let dir = tempfile::tempdir().unwrap();
720        let store = make_store(dir.path());
721        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
722        let els = store.parse_date(date).unwrap();
723        assert!(els.is_empty());
724    }
725
726    #[test]
727    fn test_append_creates_file() {
728        let dir = tempfile::tempdir().unwrap();
729        let store = make_store(dir.path());
730        let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
731        let path = store
732            .append("task", "Do a thing", &["work".into()], &[], date)
733            .unwrap();
734        assert!(path.exists());
735
736        let content = std::fs::read_to_string(&path).unwrap();
737        assert!(content.contains("@task"));
738        assert!(content.contains("Do a thing"));
739    }
740
741    #[test]
742    fn test_append_then_parse() {
743        let dir = tempfile::tempdir().unwrap();
744        let store = make_store(dir.path());
745        let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
746        store
747            .append("task", "Test task", &["work".into()], &[], date)
748            .unwrap();
749        let els = store.parse_date(date).unwrap();
750        // root mps + task
751        assert!(els.len() >= 2);
752        let has_task = els.values().any(|e| e.kind() == ElementKind::Task);
753        assert!(has_task);
754    }
755
756    #[test]
757    fn test_search_by_query() {
758        let dir = tempfile::tempdir().unwrap();
759        write_file(
760            dir.path(),
761            "20260101.1700000000.mps",
762            "@task{ auth token fix }",
763        );
764        let store = make_store(dir.path());
765        let results = store.search("auth", None, None, None).unwrap();
766        assert_eq!(results.len(), 1);
767        assert_eq!(results[0].date_str, "20260101");
768    }
769
770    #[test]
771    fn test_files_since() {
772        let dir = tempfile::tempdir().unwrap();
773        write_file(dir.path(), "20260101.1700000000.mps", "@note{ old }");
774        write_file(dir.path(), "20260601.1800000000.mps", "@note{ new }");
775        let store = make_store(dir.path());
776        let since = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
777        let files = store.files_since(since).unwrap();
778        assert_eq!(files.len(), 1);
779        assert!(files[0].to_str().unwrap().contains("20260601"));
780    }
781
782    // ── delete_element ────────────────────────────────────────────────────────
783
784    #[test]
785    fn test_delete_element_removes_it() {
786        let dir = tempfile::tempdir().unwrap();
787        let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
788        let store = make_store(dir.path());
789        store.append("task", "Delete me", &[], &[], date).unwrap();
790        let els = store.parse_date(date).unwrap();
791        let epoch_ref = els
792            .keys()
793            .find(|k| k.contains('.') && els[*k].sign() == "task")
794            .unwrap()
795            .clone();
796        let removed = store.delete_element(&epoch_ref, date).unwrap();
797        assert!(removed);
798        let els2 = store.parse_date(date).unwrap();
799        assert!(!els2.values().any(|e| e.sign() == "task"));
800    }
801
802    #[test]
803    fn test_delete_element_absent_returns_false() {
804        let dir = tempfile::tempdir().unwrap();
805        let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
806        let store = make_store(dir.path());
807        write_file(dir.path(), "20260601.1700000000.mps", "@note{ hi }");
808        let removed = store
809            .delete_element("20260601.1700000000.999", date)
810            .unwrap();
811        assert!(!removed);
812    }
813
814    #[test]
815    fn test_delete_element_file_still_valid_after() {
816        let dir = tempfile::tempdir().unwrap();
817        let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
818        let store = make_store(dir.path());
819        store.append("task", "Keep me", &[], &[], date).unwrap();
820        store.append("note", "Also kept", &[], &[], date).unwrap();
821        store.append("task", "Delete me", &[], &[], date).unwrap();
822
823        let els = store.parse_date(date).unwrap();
824        let to_delete = els
825            .keys()
826            .rfind(|k| k.contains('.') && els[*k].sign() == "task")
827            .unwrap()
828            .clone();
829
830        store.delete_element(&to_delete, date).unwrap();
831
832        let els2 = store.parse_date(date).unwrap();
833        let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
834        assert_eq!(tasks.len(), 1);
835        assert!(tasks[0].body_str().contains("Keep me"));
836        assert!(els2.values().any(|e| e.sign() == "note"));
837    }
838
839    // ── extract_element_body / replace_element_body ───────────────────────────
840
841    #[test]
842    fn test_extract_element_body_roundtrip() {
843        let dir = tempfile::tempdir().unwrap();
844        let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
845        let store = make_store(dir.path());
846        store
847            .append("note", "Original body text", &[], &[], date)
848            .unwrap();
849
850        let els = store.parse_date(date).unwrap();
851        let epoch_ref = els
852            .keys()
853            .find(|k| k.contains('.') && els[*k].sign() == "note")
854            .unwrap()
855            .clone();
856
857        let body = store
858            .extract_element_body(&epoch_ref, date)
859            .unwrap()
860            .unwrap();
861        assert!(body.contains("Original body text"));
862    }
863
864    #[test]
865    fn test_replace_element_body_writes_new_text() {
866        let dir = tempfile::tempdir().unwrap();
867        let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
868        let store = make_store(dir.path());
869        store.append("note", "Old text", &[], &[], date).unwrap();
870
871        let els = store.parse_date(date).unwrap();
872        let epoch_ref = els
873            .keys()
874            .find(|k| k.contains('.') && els[*k].sign() == "note")
875            .unwrap()
876            .clone();
877
878        let changed = store
879            .replace_element_body(&epoch_ref, "New text", date)
880            .unwrap();
881        assert!(changed);
882
883        let els2 = store.parse_date(date).unwrap();
884        let note = els2.values().find(|e| e.sign() == "note").unwrap();
885        assert!(note.body_str().contains("New text"));
886        assert!(!note.body_str().contains("Old text"));
887    }
888
889    #[test]
890    fn test_replace_element_body_same_content_returns_false() {
891        let dir = tempfile::tempdir().unwrap();
892        let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
893        let store = make_store(dir.path());
894        store.append("note", "Same text", &[], &[], date).unwrap();
895
896        let els = store.parse_date(date).unwrap();
897        let epoch_ref = els
898            .keys()
899            .find(|k| k.contains('.') && els[*k].sign() == "note")
900            .unwrap()
901            .clone();
902
903        let body = store
904            .extract_element_body(&epoch_ref, date)
905            .unwrap()
906            .unwrap();
907        let changed = store.replace_element_body(&epoch_ref, &body, date).unwrap();
908        assert!(!changed, "no-op write should return false");
909    }
910
911    // ── find_element_span / extract_body_text / replace_body_text ────────────
912
913    #[test]
914    fn test_find_element_span_basic() {
915        let content = "@task[work]{\n  Fix the bug\n}\n";
916        let (start, end) = find_element_span(content, "task", "work", 0).unwrap();
917        assert_eq!(start, 0);
918        assert_eq!(&content[start..end], "@task[work]{\n  Fix the bug\n}\n");
919    }
920
921    #[test]
922    fn test_find_element_span_second_occurrence() {
923        let content = "@note{\n  first\n}\n@note{\n  second\n}\n";
924        let (s1, e1) = find_element_span(content, "note", "", 0).unwrap();
925        let (s2, e2) = find_element_span(content, "note", "", 1).unwrap();
926        assert!(s1 < s2);
927        assert!(&content[s1..e1].contains("first"));
928        assert!(&content[s2..e2].contains("second"));
929    }
930
931    #[test]
932    fn test_extract_body_text_basic() {
933        let content = "@task[work]{\n  Fix the bug\n}\n";
934        let body = extract_body_text(content, "task", "work", 0).unwrap();
935        assert_eq!(body.trim(), "Fix the bug");
936    }
937
938    #[test]
939    fn test_replace_body_text_basic() {
940        let content = "@task[work]{\n  Fix the bug\n}\n";
941        let new = replace_body_text(content, "task", "work", 0, "Replaced body").unwrap();
942        assert!(new.contains("Replaced body"));
943        assert!(!new.contains("Fix the bug"));
944        assert!(new.contains("@task[work]{"));
945        assert!(new.contains('}'));
946    }
947
948    #[test]
949    fn test_replace_body_text_multiline() {
950        let content = "@note{\n  line one\n  line two\n}\n";
951        let new = replace_body_text(
952            content,
953            "note",
954            "",
955            0,
956            "new line one\nnew line two\nnew line three",
957        )
958        .unwrap();
959        assert!(new.contains("new line one"));
960        assert!(new.contains("new line three"));
961        assert!(!new.contains("line one\n  line two"));
962    }
963
964    // ── Iteration 1: dedent() edge cases ─────────────────────────────────────
965
966    #[test]
967    fn test_dedent_already_clean() {
968        assert_eq!(dedent("Fix the bug"), "Fix the bug");
969    }
970
971    #[test]
972    fn test_dedent_strips_common_indent() {
973        let s = "  line one\n  line two";
974        assert_eq!(dedent(s), "line one\nline two");
975    }
976
977    #[test]
978    fn test_dedent_preserves_relative_indent() {
979        // All lines have 2 spaces but "  nested" has 4 — relative gap preserved.
980        let s = "  outer\n    nested";
981        assert_eq!(dedent(s), "outer\n  nested");
982    }
983
984    #[test]
985    fn test_dedent_ignores_empty_lines_for_min() {
986        // Empty line should not set min_indent to 0 and ruin dedent.
987        let s = "  line one\n\n  line two";
988        assert_eq!(dedent(s), "line one\n\nline two");
989    }
990
991    #[test]
992    fn test_dedent_empty_string() {
993        assert_eq!(dedent(""), "");
994    }
995
996    #[test]
997    fn test_dedent_all_blank_lines() {
998        // min_indent falls back to 0 — output unchanged.
999        let s = "\n\n";
1000        assert_eq!(dedent(s), "\n");
1001    }
1002
1003    // ── Iteration 2: extract_body_text with deep indent ───────────────────────
1004
1005    #[test]
1006    fn test_extract_body_text_dedents_for_editor() {
1007        // File has 2-space indented body; editor should see unindented text.
1008        let content = "@task[work]{\n  Fix the bug\n  and test it\n}\n";
1009        let body = extract_body_text(content, "task", "work", 0).unwrap();
1010        assert_eq!(body, "Fix the bug\nand test it");
1011    }
1012
1013    #[test]
1014    fn test_extract_body_text_empty_body() {
1015        let content = "@note{\n}\n";
1016        let body = extract_body_text(content, "note", "", 0).unwrap();
1017        assert_eq!(body, "");
1018    }
1019
1020    #[test]
1021    fn test_extract_body_text_single_line_no_indent() {
1022        let content = "@note{ quick note }\n";
1023        let body = extract_body_text(content, "note", "", 0).unwrap();
1024        assert_eq!(body.trim(), "quick note");
1025    }
1026
1027    // ── Iteration 3: extract_body_text second occurrence ─────────────────────
1028
1029    #[test]
1030    fn test_extract_body_text_second_occurrence() {
1031        let content = "@note{\n  first note\n}\n@note{\n  second note\n}\n";
1032        let body1 = extract_body_text(content, "note", "", 0).unwrap();
1033        let body2 = extract_body_text(content, "note", "", 1).unwrap();
1034        assert_eq!(body1.trim(), "first note");
1035        assert_eq!(body2.trim(), "second note");
1036    }
1037
1038    // ── Iteration 4: replace_body_text with nested braces in body ─────────────
1039
1040    #[test]
1041    fn test_replace_body_text_nested_braces_in_body() {
1042        // Body contains `{` and `}` — brace-counting must not confuse them with the closer.
1043        let content = "@note{\n  code: { x: 1 }\n}\n";
1044        let new = replace_body_text(content, "note", "", 0, "simple replacement").unwrap();
1045        assert!(new.contains("simple replacement"));
1046        assert!(!new.contains("code: { x: 1 }"));
1047        assert!(new.contains("@note{"));
1048        // The closing `}` must still be there.
1049        assert!(new.ends_with("}\n") || new.ends_with('}'));
1050    }
1051
1052    // ── Iteration 5: replace_body_text only replaces correct occurrence ────────
1053
1054    #[test]
1055    fn test_replace_body_text_second_occurrence_only() {
1056        let content = "@note{\n  keep this\n}\n@note{\n  replace this\n}\n";
1057        let new = replace_body_text(content, "note", "", 1, "replaced").unwrap();
1058        assert!(new.contains("keep this"), "first note must be untouched");
1059        assert!(new.contains("replaced"), "second note must be updated");
1060        assert!(
1061            !new.contains("replace this"),
1062            "old text of second note gone"
1063        );
1064    }
1065
1066    // ── Iteration 6: find_element_span with nested element inside sprint ──────
1067
1068    #[test]
1069    fn test_find_element_span_nested_does_not_confuse_brace_count() {
1070        // A sprint block contains a task — outer span must include everything.
1071        let content = "@mps[sprint]{\n  @task[work]{\n    Do something\n  }\n}\n";
1072        let (start, end) = find_element_span(content, "mps", "sprint", 0).unwrap();
1073        let span = &content[start..end];
1074        assert!(span.contains("@task"), "span must include nested task");
1075        assert!(span.starts_with("@mps"));
1076    }
1077
1078    // ── Iteration 7: find_element_span absent returns None ────────────────────
1079
1080    #[test]
1081    fn test_find_element_span_absent_returns_none() {
1082        let content = "@note{\n  hi\n}\n";
1083        assert!(find_element_span(content, "task", "", 0).is_none());
1084        assert!(find_element_span(content, "note", "", 1).is_none()); // only 1 note
1085    }
1086
1087    // ── Bonus edge cases ──────────────────────────────────────────────────────
1088
1089    #[test]
1090    fn test_replace_body_text_empty_new_body() {
1091        let content = "@note{\n  some text\n}\n";
1092        let new = replace_body_text(content, "note", "", 0, "").unwrap();
1093        // Must still contain a valid opener and closer.
1094        assert!(new.contains("@note{"));
1095        assert!(new.contains('}'));
1096        // Old text gone.
1097        assert!(!new.contains("some text"));
1098    }
1099
1100    #[test]
1101    fn test_extract_body_text_tabs_are_preserved_after_dedent() {
1102        // A body with tab-indented lines: dedent strips spaces but not mixed tabs.
1103        // The minimum indent is 0 (tab has char value but len==1), so no dedent applied.
1104        let content = "@note{\n\ttab-indented\n}\n";
1105        let body = extract_body_text(content, "note", "", 0).unwrap();
1106        // Tab should still be present (dedent counts byte length, min_indent=1).
1107        // dedent removes 1 char from the front — the tab itself.
1108        assert!(body.contains("tab-indented"));
1109    }
1110
1111    #[test]
1112    fn test_delete_element_second_of_two_same_type() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
1115        let store = make_store(dir.path());
1116        store.append("note", "Keep me", &[], &[], date).unwrap();
1117        store.append("note", "Delete me", &[], &[], date).unwrap();
1118
1119        let els = store.parse_date(date).unwrap();
1120        let to_delete = els
1121            .iter()
1122            .filter(|(k, e)| {
1123                k.contains('.') && e.sign() == "note" && e.body_str().contains("Delete me")
1124            })
1125            .map(|(k, _)| k.clone())
1126            .next()
1127            .unwrap();
1128
1129        let removed = store.delete_element(&to_delete, date).unwrap();
1130        assert!(removed);
1131
1132        let els2 = store.parse_date(date).unwrap();
1133        let notes: Vec<_> = els2.values().filter(|e| e.sign() == "note").collect();
1134        assert_eq!(notes.len(), 1);
1135        assert!(
1136            notes[0].body_str().contains("Keep me"),
1137            "the wrong note was deleted"
1138        );
1139        assert!(!notes[0].body_str().contains("Delete me"));
1140    }
1141
1142    #[test]
1143    fn test_extract_and_replace_preserves_other_elements() {
1144        let dir = tempfile::tempdir().unwrap();
1145        let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();
1146        let store = make_store(dir.path());
1147        store.append("task", "Fix bug", &[], &[], date).unwrap();
1148        store.append("note", "Edit me", &[], &[], date).unwrap();
1149        store.append("task", "Write tests", &[], &[], date).unwrap();
1150
1151        let els = store.parse_date(date).unwrap();
1152        let note_ref = els
1153            .iter()
1154            .find(|(k, e)| k.contains('.') && e.sign() == "note")
1155            .map(|(k, _)| k.clone())
1156            .unwrap();
1157
1158        store
1159            .replace_element_body(&note_ref, "Updated note", date)
1160            .unwrap();
1161
1162        let els2 = store.parse_date(date).unwrap();
1163        let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
1164        assert_eq!(tasks.len(), 2, "both tasks must survive the note edit");
1165        let note = els2.values().find(|e| e.sign() == "note").unwrap();
1166        assert!(note.body_str().contains("Updated note"));
1167    }
1168}