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 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,  // "YYYYMMDD"
21}
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    /// First .mps file matching date, or None.
33    pub fn find_file(&self, date: NaiveDate) -> Option<PathBuf> {
34        self.find_files(date).into_iter().next()
35    }
36
37    /// All .mps files matching date (handles multiple files per day).
38    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    /// Existing file for date, or a generated new path (file not yet created).
60    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    /// Parsed elements for date. Returns empty map if no file exists.
66    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    /// Append a new element to date's file (creates file if absent).
74    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    /// All .mps files in storage_dir, sorted by filename (chronological).
95    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    /// Files whose date-stamp >= since_date.
113    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    /// Unique sorted dates for which .mps files exist.
128    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    /// Rewrite an element's typed attributes in-place, atomically.
144    ///
145    /// `ref_str` may be an epoch ref (e.g. "20260428.1") or a human ref (e.g. "task-1").
146    /// Human refs are resolved against `date` (defaults to today in the caller).
147    /// `new_attrs` maps attribute name → new value (e.g. {"status" → "done"}).
148    ///
149    /// Returns `true` on success, `false` if ref not found or file unchanged.
150    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    // ── private helpers ──────────────────────────────────────────────────────
173
174    /// Resolve a ref string to (epoch_ref, file_path).
175    /// Epoch refs (YYYYMMDD.n...) encode their own date; human refs need the given date.
176    fn resolve_ref_to_path(
177        &self,
178        ref_str: &str,
179        date:    NaiveDate,
180    ) -> Result<(Option<String>, Option<PathBuf>), MpsError> {
181        // Epoch ref: 8 ASCII digits followed by '.' then a digit
182        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    // ── Public mutating operations ───────────────────────────────────────────
205
206    /// Delete an element entirely from its file.
207    /// Returns `true` if the element was found and removed, `false` if not found.
208    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    /// Extract the body text of an element (the content between its `{` and `}`).
234    /// Returns `None` if the element is not found.
235    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    /// Replace an element's body text in-place.
255    /// `new_body` should NOT include the surrounding `{` / `}` braces.
256    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    // ── private helpers ──────────────────────────────────────────────────────
287
288    /// 0-indexed count of elements with the same (sign, raw_args) that appear
289    /// before `epoch_ref` in file order. Used to disambiguate duplicate openers.
290    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    /// Rewrite the `@type[args]{` opening line in-place and save atomically.
317    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        // Merge new attrs over existing typed attrs (preserve order; append new keys at end).
330        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        // Build new args string: named attrs first, then tags.
340        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        // Build a regex matching the current element opening line.
350        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        // Replace specifically the (occurrence)-th match (0-indexed).
362        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    /// Full-text search across files. Returns matched elements with file and date_str.
384    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
430// ── File-level helpers ────────────────────────────────────────────────────────
431
432/// Atomic write: write to tmp then rename (POSIX-atomic).
433fn 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
440/// Build the opening-line regex pattern for `@type[raw]{`.
441fn 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
450/// Find the byte range `(line_start, after_close_newline)` of the `occurrence`-th
451/// element with signature `(type_name, raw)` in `content`.
452/// The range covers the entire element: opening line, body, closing `}` and its newline.
453fn 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    // Find the nth occurrence of the opening pattern.
457    let m = re.find_iter(content).nth(occurrence)?;
458
459    // Start of the full line containing the opener.
460    let line_start = content[..m.start()].rfind('\n').map(|p| p + 1).unwrap_or(0);
461
462    // Walk forward from the `{` counting brace depth to find the matching `}`.
463    // m.end() points to the char after `{`, so the `{` is at m.end()-1.
464    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); // byte after `}`
474                    break;
475                }
476            }
477            _ => {}
478        }
479    }
480    let end_byte = close_end?;
481
482    // Include the newline that follows the closing `}`, if present.
483    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
492/// Strip the minimum common leading whitespace from all non-empty lines.
493fn 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
505/// Extract the body text (the content between `{` and `}`) for the given element.
506/// Leading/trailing blank lines are stripped; common indentation is removed (dedented)
507/// so the editor sees clean unindented content.
508fn 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    // Body starts right after the `{` (end of the match).
513    let body_start = m.end();
514    // Find matching `}`.
515    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    // Dedent so the editor shows "Fix the bug" not "  Fix the bug".
531    Some(dedent(raw_body))
532}
533
534/// Replace the body text of the `occurrence`-th element.
535/// Returns the new full file content, or `None` if the element was not found.
536fn 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    // Preserve indentation of the closing `}` line.
557    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    // Re-indent new_body to match the element's indentation.
562    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], // everything up to and including `{`
574        indented_body,
575        close_indent,
576        &content[close..],     // `}` and everything after
577    ))
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        // root mps + task
642        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    // ── delete_element ────────────────────────────────────────────────────────
670
671    #[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    // ── extract_element_body / replace_element_body ───────────────────────────
721
722    #[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    // ── find_element_span / extract_body_text / replace_body_text ────────────
777
778    #[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    // ── Iteration 1: dedent() edge cases ─────────────────────────────────────
823
824    #[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        // All lines have 2 spaces but "  nested" has 4 — relative gap preserved.
838        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        // Empty line should not set min_indent to 0 and ruin dedent.
845        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        // min_indent falls back to 0 — output unchanged.
857        let s = "\n\n";
858        assert_eq!(dedent(s), "\n");
859    }
860
861    // ── Iteration 2: extract_body_text with deep indent ───────────────────────
862
863    #[test]
864    fn test_extract_body_text_dedents_for_editor() {
865        // File has 2-space indented body; editor should see unindented text.
866        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    // ── Iteration 3: extract_body_text second occurrence ─────────────────────
886
887    #[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    // ── Iteration 4: replace_body_text with nested braces in body ─────────────
897
898    #[test]
899    fn test_replace_body_text_nested_braces_in_body() {
900        // Body contains `{` and `}` — brace-counting must not confuse them with the closer.
901        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        // The closing `}` must still be there.
907        assert!(new.ends_with("}\n") || new.ends_with('}'));
908    }
909
910    // ── Iteration 5: replace_body_text only replaces correct occurrence ────────
911
912    #[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    // ── Iteration 6: find_element_span with nested element inside sprint ──────
922
923    #[test]
924    fn test_find_element_span_nested_does_not_confuse_brace_count() {
925        // A sprint block contains a task — outer span must include everything.
926        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    // ── Iteration 7: find_element_span absent returns None ────────────────────
934
935    #[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()); // only 1 note
940    }
941
942    // ── Bonus edge cases ──────────────────────────────────────────────────────
943
944    #[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        // Must still contain a valid opener and closer.
949        assert!(new.contains("@note{"));
950        assert!(new.contains('}'));
951        // Old text gone.
952        assert!(!new.contains("some text"));
953    }
954
955    #[test]
956    fn test_extract_body_text_tabs_are_preserved_after_dedent() {
957        // A body with tab-indented lines: dedent strips spaces but not mixed tabs.
958        // The minimum indent is 0 (tab has char value but len==1), so no dedent applied.
959        let content = "@note{\n\ttab-indented\n}\n";
960        let body = extract_body_text(content, "note", "", 0).unwrap();
961        // Tab should still be present (dedent counts byte length, min_indent=1).
962        // dedent removes 1 char from the front — the tab itself.
963        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(&note_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}