1use 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, }
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 pub fn find_file(&self, date: NaiveDate) -> Option<PathBuf> {
34 self.find_files(date).into_iter().next()
35 }
36
37 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 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 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 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 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 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 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 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 fn resolve_ref_to_path(
177 &self,
178 ref_str: &str,
179 date: NaiveDate,
180 ) -> Result<(Option<String>, Option<PathBuf>), MpsError> {
181 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 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 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 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 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 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 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 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 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 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 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}