Files
tolaria/src-tauri/src/frontmatter/mod.rs
2026-04-30 05:37:58 +02:00

254 lines
8.2 KiB
Rust

mod ops;
#[cfg(test)]
mod ops_update_tests;
mod yaml;
use std::fs;
use std::path::Path;
pub use ops::update_frontmatter_content;
pub use yaml::{format_yaml_key, FrontmatterValue};
fn is_markdown_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
})
}
fn validate_frontmatter_path(path: &str, file_path: &Path) -> Result<(), String> {
if !file_path.exists() {
return Err(format!("File does not exist: {}", path));
}
if !is_markdown_path(file_path) {
return Err(format!(
"Frontmatter can only be updated on Markdown notes: {}",
path
));
}
Ok(())
}
/// Helper to read a file, apply a frontmatter transformation, and write back.
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
where
F: FnOnce(&str) -> Result<String, String>,
{
let file_path = Path::new(path);
validate_frontmatter_path(path, file_path)?;
let content =
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
let updated = transform(&content)?;
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
Ok(updated)
}
/// Update a single frontmatter property in a markdown file.
pub fn update_frontmatter(
path: &str,
key: &str,
value: FrontmatterValue,
) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, Some(value.clone()))
})
}
/// Delete a frontmatter property from a markdown file.
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, None)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_frontmatter_file_not_found() {
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_update_frontmatter_rejects_binary_attachment_before_utf8_read() {
let dir = tempfile::tempdir().unwrap();
let attachment_dir = dir.path().join("attachments");
fs::create_dir_all(&attachment_dir).unwrap();
let attachment_path = attachment_dir.join("screenshot.png");
fs::write(&attachment_path, [0xff, 0xfe, 0xfd]).unwrap();
let err = update_frontmatter(
attachment_path.to_str().unwrap(),
"Status",
FrontmatterValue::String("Done".to_string()),
)
.unwrap_err();
assert!(err.contains("Frontmatter can only be updated on Markdown notes"));
assert!(err.contains("screenshot.png"));
}
#[test]
fn test_roundtrip_update_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_update_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"A".to_string(),
"B".to_string(),
])),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let aliases = map.get("aliases").unwrap();
if let gray_matter::Pod::Array(arr) = aliases {
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].as_string().unwrap(), "A");
assert_eq!(arr[1].as_string().unwrap(), "B");
} else {
panic!("Expected array");
}
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_add_then_delete() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let with_owner = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(with_owner.contains("Owner: Luca"));
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
assert!(!without_owner.contains("Owner"));
assert!(without_owner.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_empty_block() {
let content = "---\n---\n\n# Test\n";
let result = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New Title".to_string())),
);
assert!(result.is_ok());
assert!(result.unwrap().contains("title: New Title"));
}
#[test]
fn test_update_frontmatter_block_scalar_writes_and_rewrites() {
let cases = [
(
"---\ntype: Type\n---\n# Project\n",
"## Objective\n\n## Timeline",
&["template: |", " ## Objective", "type: Type"][..],
&[][..],
),
(
"---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n",
"## New\n\n## Content",
&[" ## New", "color: green"][..],
&["## Old"][..],
),
];
for (content, template, expected_present, expected_absent) in cases {
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
for expected in expected_present {
assert!(updated.contains(expected));
}
for unexpected in expected_absent {
assert!(!updated.contains(unexpected));
}
}
}
#[test]
fn test_delete_frontmatter_block_scalar() {
let content =
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(content, "template", None).unwrap();
assert!(!updated.contains("template"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_update_frontmatter_no_body_after_closing() {
let content = "---\ntitle: Old\n---\n";
let updated = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New".to_string())),
)
.unwrap();
assert!(updated.contains("title: New"));
assert!(!updated.contains("title: Old"));
}
#[test]
fn test_roundtrip_block_scalar() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let roundtripped = map.get("template").unwrap().as_string().unwrap();
assert!(roundtripped.contains("## Objective"));
assert!(roundtripped.contains("## Timeline"));
assert!(roundtripped.contains("Describe the goal."));
} else {
panic!("Expected hash");
}
}
}