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