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