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    /// Rewrite the `@type[args]{` opening line in-place and save atomically.
205    ///
206    /// `epoch_ref` and `all_elements` are used to determine WHICH occurrence of the
207    /// opening pattern to replace when multiple elements share identical raw_args.
208    /// Elements are sorted by their numeric ref-path parts (same order as RefResolver),
209    /// which mirrors the order openers appear in the file. This makes the N-th
210    /// occurrence of the pattern map to the correct element even with duplicate args.
211    fn rewrite_element_in_file(
212        &self,
213        path:         &Path,
214        el:           &Element,
215        epoch_ref:    &str,
216        all_elements: &IndexMap<String, Element>,
217        new_attrs:    &HashMap<String, String>,
218    ) -> Result<bool, MpsError> {
219        let content   = std::fs::read_to_string(path)?;
220        let type_name = el.sign();
221        let raw       = el.raw_args();
222
223        // Merge new attrs over existing typed attrs (preserve order; append new keys at end).
224        let mut merged: Vec<(String, String)> = el.typed_attrs();
225        for (k, v) in new_attrs {
226            if let Some(pos) = merged.iter().position(|(ek, _)| ek == k) {
227                merged[pos].1 = v.clone();
228            } else {
229                merged.push((k.clone(), v.clone()));
230            }
231        }
232
233        // Build new args string: named attrs first, then tags.
234        let attr_parts: Vec<String> = merged.iter()
235            .filter(|(_, v)| !v.is_empty())
236            .map(|(k, v)| format!("{}: {}", k, v))
237            .collect();
238        let new_args_str: String = attr_parts.into_iter()
239            .chain(el.tags().iter().cloned())
240            .collect::<Vec<_>>()
241            .join(", ");
242
243        // Build a regex matching the current element opening line.
244        let esc_type = regex::escape(type_name);
245        let old_pat = if raw.is_empty() {
246            format!(r"@{}(?:\[\])?\s*\{{", esc_type)
247        } else {
248            format!(r"@{}\[{}\]\s*\{{", esc_type, regex::escape(raw))
249        };
250
251        let re = regex::Regex::new(&old_pat)
252            .map_err(|e| MpsError::ParseError { file: path.display().to_string(), msg: e.to_string() })?;
253
254        // Determine which occurrence to replace.
255        // Sort all elements by their numeric ref-path parts — this order mirrors the
256        // order in which openers appear in the file (depth-first, opening order).
257        // Count how many elements with the same (sign, raw_args) appear before our
258        // target in that sorted order; skip synthetic root (keys without any dot).
259        let mut sorted_keys: Vec<&String> = all_elements.keys().collect();
260        sorted_keys.sort_by(|a, b| {
261            let ap: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
262            let bp: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
263            ap.cmp(&bp)
264        });
265
266        let mut occurrence: usize = 0;
267        for key in &sorted_keys {
268            if *key == epoch_ref { break; }
269            // Skip synthetic root (no dot → single-segment key like "20260302")
270            if !key.contains('.') { continue; }
271            if let Some(other) = all_elements.get(*key) {
272                if other.sign() == type_name && other.raw_args() == raw {
273                    occurrence += 1;
274                }
275            }
276        }
277
278        // Replace specifically the (occurrence)-th match (0-indexed).
279        let new_open = format!("@{}[{}]{{", type_name, new_args_str);
280        let mut match_n = 0usize;
281        let mut new_content: Option<String> = None;
282        for m in re.find_iter(&content) {
283            if match_n == occurrence {
284                new_content = Some(format!("{}{}{}", &content[..m.start()], new_open, &content[m.end()..]));
285                break;
286            }
287            match_n += 1;
288        }
289
290        let new_content = match new_content {
291            Some(c) => c,
292            None    => return Ok(false),
293        };
294        if new_content == content { return Ok(false); }
295
296        // Atomic write: tmp file → rename
297        let tmp_path = PathBuf::from(format!("{}.tmp.{}", path.display(), std::process::id()));
298        std::fs::write(&tmp_path, &new_content)?;
299        std::fs::rename(&tmp_path, path)?;
300        Ok(true)
301    }
302
303    /// Full-text search across files. Returns matched elements with file and date_str.
304    pub fn search(
305        &self,
306        query:       &str,
307        type_filter: Option<&str>,
308        tag_filter:  Option<&str>,
309        since_date:  Option<NaiveDate>,
310    ) -> Result<Vec<SearchResult>, MpsError> {
311        let files = match since_date {
312            Some(d) => self.files_since(d)?,
313            None    => self.all_files()?,
314        };
315
316        let query_lower = query.to_lowercase();
317        let mut results = Vec::new();
318
319        for file in files {
320            let date_str = file.file_name()
321                .and_then(|n| n.to_str())
322                .map(|n| n[..8].to_string())
323                .unwrap_or_default();
324
325            let elements = parser::parse_file(&file)?;
326
327            for (_, el) in elements {
328                if el.is_mps_group() || el.is_unknown() { continue; }
329
330                if let Some(tf) = type_filter {
331                    if el.sign() != tf { continue; }
332                }
333                if let Some(tag) = tag_filter {
334                    if !el.tags().iter().any(|t| t == tag) { continue; }
335                }
336                if !el.body_str().to_lowercase().contains(&query_lower) { continue; }
337
338                results.push(SearchResult {
339                    element: el,
340                    file: file.clone(),
341                    date_str: date_str.clone(),
342                });
343            }
344        }
345
346        Ok(results)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::elements::ElementKind;
354
355    fn make_store(dir: &Path) -> Store {
356        Store::new(dir)
357    }
358
359    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
360        let path = dir.join(name);
361        std::fs::write(&path, content).unwrap();
362        path
363    }
364
365    #[test]
366    fn test_find_file_absent() {
367        let dir = tempfile::tempdir().unwrap();
368        let store = make_store(dir.path());
369        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
370        assert!(store.find_file(date).is_none());
371    }
372
373    #[test]
374    fn test_find_file_present() {
375        let dir = tempfile::tempdir().unwrap();
376        write_file(dir.path(), "20260101.1700000000.mps", "@task{ Hi }");
377        let store = make_store(dir.path());
378        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
379        assert!(store.find_file(date).is_some());
380    }
381
382    #[test]
383    fn test_parse_date_empty() {
384        let dir = tempfile::tempdir().unwrap();
385        let store = make_store(dir.path());
386        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
387        let els = store.parse_date(date).unwrap();
388        assert!(els.is_empty());
389    }
390
391    #[test]
392    fn test_append_creates_file() {
393        let dir = tempfile::tempdir().unwrap();
394        let store = make_store(dir.path());
395        let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
396        let path = store.append("task", "Do a thing", &["work".into()], &[], date).unwrap();
397        assert!(path.exists());
398
399        let content = std::fs::read_to_string(&path).unwrap();
400        assert!(content.contains("@task"));
401        assert!(content.contains("Do a thing"));
402    }
403
404    #[test]
405    fn test_append_then_parse() {
406        let dir = tempfile::tempdir().unwrap();
407        let store = make_store(dir.path());
408        let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
409        store.append("task", "Test task", &["work".into()], &[], date).unwrap();
410        let els = store.parse_date(date).unwrap();
411        // root mps + task
412        assert!(els.len() >= 2);
413        let has_task = els.values().any(|e| e.kind() == ElementKind::Task);
414        assert!(has_task);
415    }
416
417    #[test]
418    fn test_search_by_query() {
419        let dir = tempfile::tempdir().unwrap();
420        write_file(dir.path(), "20260101.1700000000.mps", "@task{ auth token fix }");
421        let store = make_store(dir.path());
422        let results = store.search("auth", None, None, None).unwrap();
423        assert_eq!(results.len(), 1);
424        assert_eq!(results[0].date_str, "20260101");
425    }
426
427    #[test]
428    fn test_files_since() {
429        let dir = tempfile::tempdir().unwrap();
430        write_file(dir.path(), "20260101.1700000000.mps", "@note{ old }");
431        write_file(dir.path(), "20260601.1800000000.mps", "@note{ new }");
432        let store = make_store(dir.path());
433        let since = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
434        let files = store.files_since(since).unwrap();
435        assert_eq!(files.len(), 1);
436        assert!(files[0].to_str().unwrap().contains("20260601"));
437    }
438}