use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use indexmap::IndexMap;
use crate::constants::{mps_file_name_regexp, new_file_name, MPS_EXT};
use crate::elements::Element;
use crate::error::MpsError;
use crate::parser;
use crate::ref_resolver::RefResolver;
#[allow(dead_code)]
pub struct SearchResult {
pub element: Element,
pub file: PathBuf,
pub date_str: String, }
pub struct Store {
storage_dir: PathBuf,
}
impl Store {
pub fn new(storage_dir: impl Into<PathBuf>) -> Self {
Store { storage_dir: storage_dir.into() }
}
pub fn find_file(&self, date: NaiveDate) -> Option<PathBuf> {
self.find_files(date).into_iter().next()
}
pub fn find_files(&self, date: NaiveDate) -> Vec<PathBuf> {
let prefix = date.format("%Y%m%d").to_string();
let re = mps_file_name_regexp();
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
&& p.file_name()
.and_then(|n| n.to_str())
.map(|n| re.is_match(n) && n.starts_with(&prefix))
.unwrap_or(false)
})
.collect()
})
.unwrap_or_default();
files.sort();
files
}
pub fn find_or_create_path(&self, date: NaiveDate) -> PathBuf {
self.find_file(date)
.unwrap_or_else(|| self.storage_dir.join(new_file_name(date)))
}
pub fn parse_date(&self, date: NaiveDate) -> Result<IndexMap<String, Element>, MpsError> {
match self.find_file(date) {
None => Ok(IndexMap::new()),
Some(p) => parser::parse_file(&p),
}
}
pub fn append(
&self,
kind: &str,
body: &str,
tags: &[String],
attrs: &[(&str, &str)],
date: NaiveDate,
) -> Result<PathBuf, MpsError> {
let mut parts: Vec<String> = attrs.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
parts.extend(tags.iter().cloned());
let args_str = parts.join(", ");
let path = self.find_or_create_path(date);
let chunk = format!("\n@{}[{}]{{\n {}\n}}\n", kind, args_str, body);
use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&path)?;
f.write_all(chunk.as_bytes())?;
Ok(path)
}
pub fn all_files(&self) -> Result<Vec<PathBuf>, MpsError> {
let re = mps_file_name_regexp();
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.storage_dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension().and_then(|e| e.to_str()) == Some(MPS_EXT)
&& p.file_name()
.and_then(|n| n.to_str())
.map(|n| re.is_match(n))
.unwrap_or(false)
})
.collect();
files.sort();
Ok(files)
}
pub fn files_since(&self, since_date: NaiveDate) -> Result<Vec<PathBuf>, MpsError> {
let since_str = since_date.format("%Y%m%d").to_string();
let files = self.all_files()?
.into_iter()
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| &n[..8] >= since_str.as_str())
.unwrap_or(false)
})
.collect();
Ok(files)
}
pub fn all_file_dates(&self) -> Result<Vec<NaiveDate>, MpsError> {
let mut seen = std::collections::HashSet::new();
let mut dates: Vec<NaiveDate> = self.all_files()?
.iter()
.filter_map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.and_then(|n| NaiveDate::parse_from_str(&n[..8], "%Y%m%d").ok())
})
.filter(|d| seen.insert(*d))
.collect();
dates.sort();
Ok(dates)
}
pub fn rewrite_element(
&self,
ref_str: &str,
new_attrs: &HashMap<String, String>,
date: NaiveDate,
) -> Result<bool, MpsError> {
let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
let (epoch_ref, path) = match (epoch_ref, path) {
(Some(e), Some(p)) => (e, p),
_ => return Ok(false),
};
let elements = parser::parse_file(&path)?;
let el = match elements.get(&epoch_ref) {
Some(e) => e.clone(),
None => return Ok(false),
};
if el.is_unknown() { return Ok(false); }
self.rewrite_element_in_file(&path, &el, &epoch_ref, &elements, new_attrs)
}
fn resolve_ref_to_path(
&self,
ref_str: &str,
date: NaiveDate,
) -> Result<(Option<String>, Option<PathBuf>), MpsError> {
let is_epoch = ref_str.len() >= 10
&& ref_str[..8].chars().all(|c| c.is_ascii_digit())
&& ref_str.chars().nth(8) == Some('.')
&& ref_str.chars().nth(9).map(|c| c.is_ascii_digit()).unwrap_or(false);
if is_epoch {
let d = NaiveDate::parse_from_str(&ref_str[..8], "%Y%m%d")
.map_err(|_| MpsError::DateParseError(ref_str[..8].to_string()))?;
let path = self.find_file(d);
Ok((Some(ref_str.to_string()), path))
} else {
let path = match self.find_file(date) {
Some(p) => p,
None => return Ok((None, None)),
};
let elements = parser::parse_file(&path)?;
let resolver = RefResolver::new(&elements);
let epoch_ref = resolver.to_epoch(ref_str).map(|s| s.to_string());
Ok((epoch_ref, Some(path)))
}
}
pub fn delete_element(&self, ref_str: &str, date: NaiveDate) -> Result<bool, MpsError> {
let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
let (epoch_ref, path) = match (epoch_ref, path) {
(Some(e), Some(p)) => (e, p),
_ => return Ok(false),
};
let content = std::fs::read_to_string(&path)?;
let elements = parser::parse_file(&path)?;
let el = match elements.get(&epoch_ref) {
Some(e) => e.clone(),
None => return Ok(false),
};
if el.is_unknown() { return Ok(false); }
let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
let (start, end) = match find_element_span(&content, el.sign(), el.raw_args(), occ) {
Some(s) => s,
None => return Ok(false),
};
let new_content = format!("{}{}", &content[..start], &content[end..]);
atomic_write(&path, &new_content)?;
Ok(true)
}
pub fn extract_element_body(&self, ref_str: &str, date: NaiveDate) -> Result<Option<String>, MpsError> {
let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
let (epoch_ref, path) = match (epoch_ref, path) {
(Some(e), Some(p)) => (e, p),
_ => return Ok(None),
};
let content = std::fs::read_to_string(&path)?;
let elements = parser::parse_file(&path)?;
let el = match elements.get(&epoch_ref) {
Some(e) => e.clone(),
None => return Ok(None),
};
if el.is_unknown() { return Ok(None); }
let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
let body = extract_body_text(&content, el.sign(), el.raw_args(), occ);
Ok(body)
}
pub fn replace_element_body(
&self,
ref_str: &str,
new_body: &str,
date: NaiveDate,
) -> Result<bool, MpsError> {
let (epoch_ref, path) = self.resolve_ref_to_path(ref_str, date)?;
let (epoch_ref, path) = match (epoch_ref, path) {
(Some(e), Some(p)) => (e, p),
_ => return Ok(false),
};
let content = std::fs::read_to_string(&path)?;
let elements = parser::parse_file(&path)?;
let el = match elements.get(&epoch_ref) {
Some(e) => e.clone(),
None => return Ok(false),
};
if el.is_unknown() { return Ok(false); }
let occ = self.occurrence_of(&epoch_ref, el.sign(), el.raw_args(), &elements);
let new_content = replace_body_text(&content, el.sign(), el.raw_args(), occ, new_body);
match new_content {
Some(nc) if nc != content => {
atomic_write(&path, &nc)?;
Ok(true)
}
_ => Ok(false),
}
}
fn occurrence_of(
&self,
epoch_ref: &str,
type_name: &str,
raw: &str,
all_elements: &IndexMap<String, Element>,
) -> usize {
let mut sorted_keys: Vec<&String> = all_elements.keys().collect();
sorted_keys.sort_by(|a, b| {
let ap: Vec<u64> = a.split('.').filter_map(|s| s.parse().ok()).collect();
let bp: Vec<u64> = b.split('.').filter_map(|s| s.parse().ok()).collect();
ap.cmp(&bp)
});
let mut occurrence = 0usize;
for key in &sorted_keys {
if *key == epoch_ref { break; }
if !key.contains('.') { continue; }
if let Some(other) = all_elements.get(*key) {
if other.sign() == type_name && other.raw_args() == raw {
occurrence += 1;
}
}
}
occurrence
}
fn rewrite_element_in_file(
&self,
path: &Path,
el: &Element,
epoch_ref: &str,
all_elements: &IndexMap<String, Element>,
new_attrs: &HashMap<String, String>,
) -> Result<bool, MpsError> {
let content = std::fs::read_to_string(path)?;
let type_name = el.sign();
let raw = el.raw_args();
let mut merged: Vec<(String, String)> = el.typed_attrs();
for (k, v) in new_attrs {
if let Some(pos) = merged.iter().position(|(ek, _)| ek == k) {
merged[pos].1 = v.clone();
} else {
merged.push((k.clone(), v.clone()));
}
}
let attr_parts: Vec<String> = merged.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| format!("{}: {}", k, v))
.collect();
let new_args_str: String = attr_parts.into_iter()
.chain(el.tags().iter().cloned())
.collect::<Vec<_>>()
.join(", ");
let esc_type = regex::escape(type_name);
let old_pat = if raw.is_empty() {
format!(r"@{}(?:\[\])?\s*\{{", esc_type)
} else {
format!(r"@{}\[{}\]\s*\{{", esc_type, regex::escape(raw))
};
let re = regex::Regex::new(&old_pat)
.map_err(|e| MpsError::ParseError { file: path.display().to_string(), msg: e.to_string() })?;
let occurrence = self.occurrence_of(epoch_ref, type_name, raw, all_elements);
let new_open = format!("@{}[{}]{{", type_name, new_args_str);
let mut match_n = 0usize;
let mut new_content: Option<String> = None;
for m in re.find_iter(&content) {
if match_n == occurrence {
new_content = Some(format!("{}{}{}", &content[..m.start()], new_open, &content[m.end()..]));
break;
}
match_n += 1;
}
let new_content = match new_content {
Some(c) => c,
None => return Ok(false),
};
if new_content == content { return Ok(false); }
atomic_write(path, &new_content)?;
Ok(true)
}
pub fn search(
&self,
query: &str,
type_filter: Option<&str>,
tag_filter: Option<&str>,
since_date: Option<NaiveDate>,
) -> Result<Vec<SearchResult>, MpsError> {
let files = match since_date {
Some(d) => self.files_since(d)?,
None => self.all_files()?,
};
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for file in files {
let date_str = file.file_name()
.and_then(|n| n.to_str())
.map(|n| n[..8].to_string())
.unwrap_or_default();
let elements = parser::parse_file(&file)?;
for (_, el) in elements {
if el.is_mps_group() || el.is_unknown() { continue; }
if let Some(tf) = type_filter {
if el.sign() != tf { continue; }
}
if let Some(tag) = tag_filter {
if !el.tags().iter().any(|t| t == tag) { continue; }
}
if !el.body_str().to_lowercase().contains(&query_lower) { continue; }
results.push(SearchResult {
element: el,
file: file.clone(),
date_str: date_str.clone(),
});
}
}
Ok(results)
}
}
fn atomic_write(path: &Path, content: &str) -> Result<(), MpsError> {
let tmp = PathBuf::from(format!("{}.tmp.{}", path.display(), std::process::id()));
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn opener_pattern(type_name: &str, raw: &str) -> String {
let esc = regex::escape(type_name);
if raw.is_empty() {
format!(r"@{}(?:\[\])?\s*\{{", esc)
} else {
format!(r"@{}\[{}\]\s*\{{", esc, regex::escape(raw))
}
}
fn find_element_span(content: &str, type_name: &str, raw: &str, occurrence: usize) -> Option<(usize, usize)> {
let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
let m = re.find_iter(content).nth(occurrence)?;
let line_start = content[..m.start()].rfind('\n').map(|p| p + 1).unwrap_or(0);
let brace_start = m.end() - 1;
let mut depth = 0i32;
let mut close_end = None;
for (i, c) in content[brace_start..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
close_end = Some(brace_start + i + 1); break;
}
}
_ => {}
}
}
let end_byte = close_end?;
let after_newline = if content[end_byte..].starts_with('\n') {
end_byte + 1
} else {
end_byte
};
Some((line_start, after_newline))
}
fn dedent(s: &str) -> String {
let min_indent = s.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
s.lines()
.map(|l| if l.len() >= min_indent { &l[min_indent..] } else { l.trim_start() })
.collect::<Vec<_>>()
.join("\n")
}
fn extract_body_text(content: &str, type_name: &str, raw: &str, occurrence: usize) -> Option<String> {
let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
let m = re.find_iter(content).nth(occurrence)?;
let body_start = m.end();
let brace_start = m.end() - 1;
let mut depth = 0i32;
let mut close_pos = None;
for (i, c) in content[brace_start..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 { close_pos = Some(brace_start + i); break; }
}
_ => {}
}
}
let close = close_pos?;
let raw_body = content[body_start..close].trim_matches('\n');
Some(dedent(raw_body))
}
fn replace_body_text(content: &str, type_name: &str, raw: &str, occurrence: usize, new_body: &str) -> Option<String> {
let re = regex::Regex::new(&opener_pattern(type_name, raw)).ok()?;
let m = re.find_iter(content).nth(occurrence)?;
let body_start = m.end();
let brace_start = m.end() - 1;
let mut depth = 0i32;
let mut close_pos = None;
for (i, c) in content[brace_start..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 { close_pos = Some(brace_start + i); break; }
}
_ => {}
}
}
let close = close_pos?;
let close_line_start = content[..close].rfind('\n').map(|p| p + 1).unwrap_or(0);
let close_indent: String = content[close_line_start..close]
.chars().take_while(|c| c.is_whitespace()).collect();
let body_indent = &close_indent;
let indented_body: String = new_body.lines()
.map(|line| {
if line.trim().is_empty() { String::new() }
else { format!("{} {}", body_indent, line.trim()) }
})
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"{}\n{}\n{}{}",
&content[..body_start], indented_body,
close_indent,
&content[close..], ))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::elements::ElementKind;
fn make_store(dir: &Path) -> Store {
Store::new(dir)
}
fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn test_find_file_absent() {
let dir = tempfile::tempdir().unwrap();
let store = make_store(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
assert!(store.find_file(date).is_none());
}
#[test]
fn test_find_file_present() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "20260101.1700000000.mps", "@task{ Hi }");
let store = make_store(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
assert!(store.find_file(date).is_some());
}
#[test]
fn test_parse_date_empty() {
let dir = tempfile::tempdir().unwrap();
let store = make_store(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let els = store.parse_date(date).unwrap();
assert!(els.is_empty());
}
#[test]
fn test_append_creates_file() {
let dir = tempfile::tempdir().unwrap();
let store = make_store(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
let path = store.append("task", "Do a thing", &["work".into()], &[], date).unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("@task"));
assert!(content.contains("Do a thing"));
}
#[test]
fn test_append_then_parse() {
let dir = tempfile::tempdir().unwrap();
let store = make_store(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 4, 28).unwrap();
store.append("task", "Test task", &["work".into()], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
assert!(els.len() >= 2);
let has_task = els.values().any(|e| e.kind() == ElementKind::Task);
assert!(has_task);
}
#[test]
fn test_search_by_query() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "20260101.1700000000.mps", "@task{ auth token fix }");
let store = make_store(dir.path());
let results = store.search("auth", None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].date_str, "20260101");
}
#[test]
fn test_files_since() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "20260101.1700000000.mps", "@note{ old }");
write_file(dir.path(), "20260601.1800000000.mps", "@note{ new }");
let store = make_store(dir.path());
let since = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
let files = store.files_since(since).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].to_str().unwrap().contains("20260601"));
}
#[test]
fn test_delete_element_removes_it() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
let store = make_store(dir.path());
store.append("task", "Delete me", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let epoch_ref = els.keys()
.find(|k| k.contains('.') && els[*k].sign() == "task")
.unwrap().clone();
let removed = store.delete_element(&epoch_ref, date).unwrap();
assert!(removed);
let els2 = store.parse_date(date).unwrap();
assert!(!els2.values().any(|e| e.sign() == "task"));
}
#[test]
fn test_delete_element_absent_returns_false() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
let store = make_store(dir.path());
write_file(dir.path(), "20260601.1700000000.mps", "@note{ hi }");
let removed = store.delete_element("20260601.1700000000.999", date).unwrap();
assert!(!removed);
}
#[test]
fn test_delete_element_file_still_valid_after() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
let store = make_store(dir.path());
store.append("task", "Keep me", &[], &[], date).unwrap();
store.append("note", "Also kept", &[], &[], date).unwrap();
store.append("task", "Delete me", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let to_delete = els.keys()
.filter(|k| k.contains('.') && els[*k].sign() == "task")
.last().unwrap().clone();
store.delete_element(&to_delete, date).unwrap();
let els2 = store.parse_date(date).unwrap();
let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
assert_eq!(tasks.len(), 1);
assert!(tasks[0].body_str().contains("Keep me"));
assert!(els2.values().any(|e| e.sign() == "note"));
}
#[test]
fn test_extract_element_body_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
let store = make_store(dir.path());
store.append("note", "Original body text", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let epoch_ref = els.keys()
.find(|k| k.contains('.') && els[*k].sign() == "note")
.unwrap().clone();
let body = store.extract_element_body(&epoch_ref, date).unwrap().unwrap();
assert!(body.contains("Original body text"));
}
#[test]
fn test_replace_element_body_writes_new_text() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
let store = make_store(dir.path());
store.append("note", "Old text", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let epoch_ref = els.keys()
.find(|k| k.contains('.') && els[*k].sign() == "note")
.unwrap().clone();
let changed = store.replace_element_body(&epoch_ref, "New text", date).unwrap();
assert!(changed);
let els2 = store.parse_date(date).unwrap();
let note = els2.values().find(|e| e.sign() == "note").unwrap();
assert!(note.body_str().contains("New text"));
assert!(!note.body_str().contains("Old text"));
}
#[test]
fn test_replace_element_body_same_content_returns_false() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 6, 2).unwrap();
let store = make_store(dir.path());
store.append("note", "Same text", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let epoch_ref = els.keys()
.find(|k| k.contains('.') && els[*k].sign() == "note")
.unwrap().clone();
let body = store.extract_element_body(&epoch_ref, date).unwrap().unwrap();
let changed = store.replace_element_body(&epoch_ref, &body, date).unwrap();
assert!(!changed, "no-op write should return false");
}
#[test]
fn test_find_element_span_basic() {
let content = "@task[work]{\n Fix the bug\n}\n";
let (start, end) = find_element_span(content, "task", "work", 0).unwrap();
assert_eq!(start, 0);
assert_eq!(&content[start..end], "@task[work]{\n Fix the bug\n}\n");
}
#[test]
fn test_find_element_span_second_occurrence() {
let content = "@note{\n first\n}\n@note{\n second\n}\n";
let (s1, e1) = find_element_span(content, "note", "", 0).unwrap();
let (s2, e2) = find_element_span(content, "note", "", 1).unwrap();
assert!(s1 < s2);
assert!(&content[s1..e1].contains("first"));
assert!(&content[s2..e2].contains("second"));
}
#[test]
fn test_extract_body_text_basic() {
let content = "@task[work]{\n Fix the bug\n}\n";
let body = extract_body_text(content, "task", "work", 0).unwrap();
assert_eq!(body.trim(), "Fix the bug");
}
#[test]
fn test_replace_body_text_basic() {
let content = "@task[work]{\n Fix the bug\n}\n";
let new = replace_body_text(content, "task", "work", 0, "Replaced body").unwrap();
assert!(new.contains("Replaced body"));
assert!(!new.contains("Fix the bug"));
assert!(new.contains("@task[work]{"));
assert!(new.contains('}'));
}
#[test]
fn test_replace_body_text_multiline() {
let content = "@note{\n line one\n line two\n}\n";
let new = replace_body_text(content, "note", "", 0, "new line one\nnew line two\nnew line three").unwrap();
assert!(new.contains("new line one"));
assert!(new.contains("new line three"));
assert!(!new.contains("line one\n line two"));
}
#[test]
fn test_dedent_already_clean() {
assert_eq!(dedent("Fix the bug"), "Fix the bug");
}
#[test]
fn test_dedent_strips_common_indent() {
let s = " line one\n line two";
assert_eq!(dedent(s), "line one\nline two");
}
#[test]
fn test_dedent_preserves_relative_indent() {
let s = " outer\n nested";
assert_eq!(dedent(s), "outer\n nested");
}
#[test]
fn test_dedent_ignores_empty_lines_for_min() {
let s = " line one\n\n line two";
assert_eq!(dedent(s), "line one\n\nline two");
}
#[test]
fn test_dedent_empty_string() {
assert_eq!(dedent(""), "");
}
#[test]
fn test_dedent_all_blank_lines() {
let s = "\n\n";
assert_eq!(dedent(s), "\n");
}
#[test]
fn test_extract_body_text_dedents_for_editor() {
let content = "@task[work]{\n Fix the bug\n and test it\n}\n";
let body = extract_body_text(content, "task", "work", 0).unwrap();
assert_eq!(body, "Fix the bug\nand test it");
}
#[test]
fn test_extract_body_text_empty_body() {
let content = "@note{\n}\n";
let body = extract_body_text(content, "note", "", 0).unwrap();
assert_eq!(body, "");
}
#[test]
fn test_extract_body_text_single_line_no_indent() {
let content = "@note{ quick note }\n";
let body = extract_body_text(content, "note", "", 0).unwrap();
assert_eq!(body.trim(), "quick note");
}
#[test]
fn test_extract_body_text_second_occurrence() {
let content = "@note{\n first note\n}\n@note{\n second note\n}\n";
let body1 = extract_body_text(content, "note", "", 0).unwrap();
let body2 = extract_body_text(content, "note", "", 1).unwrap();
assert_eq!(body1.trim(), "first note");
assert_eq!(body2.trim(), "second note");
}
#[test]
fn test_replace_body_text_nested_braces_in_body() {
let content = "@note{\n code: { x: 1 }\n}\n";
let new = replace_body_text(content, "note", "", 0, "simple replacement").unwrap();
assert!(new.contains("simple replacement"));
assert!(!new.contains("code: { x: 1 }"));
assert!(new.contains("@note{"));
assert!(new.ends_with("}\n") || new.ends_with('}'));
}
#[test]
fn test_replace_body_text_second_occurrence_only() {
let content = "@note{\n keep this\n}\n@note{\n replace this\n}\n";
let new = replace_body_text(content, "note", "", 1, "replaced").unwrap();
assert!(new.contains("keep this"), "first note must be untouched");
assert!(new.contains("replaced"), "second note must be updated");
assert!(!new.contains("replace this"), "old text of second note gone");
}
#[test]
fn test_find_element_span_nested_does_not_confuse_brace_count() {
let content = "@mps[sprint]{\n @task[work]{\n Do something\n }\n}\n";
let (start, end) = find_element_span(content, "mps", "sprint", 0).unwrap();
let span = &content[start..end];
assert!(span.contains("@task"), "span must include nested task");
assert!(span.starts_with("@mps"));
}
#[test]
fn test_find_element_span_absent_returns_none() {
let content = "@note{\n hi\n}\n";
assert!(find_element_span(content, "task", "", 0).is_none());
assert!(find_element_span(content, "note", "", 1).is_none()); }
#[test]
fn test_replace_body_text_empty_new_body() {
let content = "@note{\n some text\n}\n";
let new = replace_body_text(content, "note", "", 0, "").unwrap();
assert!(new.contains("@note{"));
assert!(new.contains('}'));
assert!(!new.contains("some text"));
}
#[test]
fn test_extract_body_text_tabs_are_preserved_after_dedent() {
let content = "@note{\n\ttab-indented\n}\n";
let body = extract_body_text(content, "note", "", 0).unwrap();
assert!(body.contains("tab-indented"));
}
#[test]
fn test_delete_element_second_of_two_same_type() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
let store = make_store(dir.path());
store.append("note", "Keep me", &[], &[], date).unwrap();
store.append("note", "Delete me", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let to_delete = els.iter()
.filter(|(k, e)| k.contains('.') && e.sign() == "note" && e.body_str().contains("Delete me"))
.map(|(k, _)| k.clone())
.next().unwrap();
let removed = store.delete_element(&to_delete, date).unwrap();
assert!(removed);
let els2 = store.parse_date(date).unwrap();
let notes: Vec<_> = els2.values().filter(|e| e.sign() == "note").collect();
assert_eq!(notes.len(), 1);
assert!(notes[0].body_str().contains("Keep me"), "the wrong note was deleted");
assert!(!notes[0].body_str().contains("Delete me"));
}
#[test]
fn test_extract_and_replace_preserves_other_elements() {
let dir = tempfile::tempdir().unwrap();
let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();
let store = make_store(dir.path());
store.append("task", "Fix bug", &[], &[], date).unwrap();
store.append("note", "Edit me", &[], &[], date).unwrap();
store.append("task", "Write tests", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let note_ref = els.iter()
.find(|(k, e)| k.contains('.') && e.sign() == "note")
.map(|(k, _)| k.clone()).unwrap();
store.replace_element_body(¬e_ref, "Updated note", date).unwrap();
let els2 = store.parse_date(date).unwrap();
let tasks: Vec<_> = els2.values().filter(|e| e.sign() == "task").collect();
assert_eq!(tasks.len(), 2, "both tasks must survive the note edit");
let note = els2.values().find(|e| e.sign() == "note").unwrap();
assert!(note.body_str().contains("Updated note"));
}
}