use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use chrono::TimeZone;
use crate::error::{DiaryxError, Result};
use crate::frontmatter;
use crate::fs::AsyncFileSystem;
use crate::link_parser;
#[derive(Debug, Clone, Default)]
pub struct FrontmatterMetadata {
pub title: Option<String>,
pub part_of: Option<String>,
pub contents: Option<Vec<String>>,
pub attachments: Option<Vec<String>>,
pub audience: Option<Vec<String>>,
pub description: Option<String>,
pub updated: Option<i64>,
pub extra: HashMap<String, serde_json::Value>,
}
impl FrontmatterMetadata {
pub fn from_json(value: &serde_json::Value) -> Self {
Self::from_json_with_file_path(value, None)
}
pub fn from_json_with_file_path(
value: &serde_json::Value,
_canonical_file_path: Option<&str>,
) -> Self {
Self::from_json_with_markdown_links(value, link_parser::path_to_title)
}
pub fn from_json_with_markdown_links<F>(value: &serde_json::Value, title_resolver: F) -> Self
where
F: Fn(&str) -> String,
{
let obj = value.as_object();
let title = obj
.and_then(|o| o.get("title"))
.and_then(|v| v.as_str())
.map(String::from);
let part_of = obj
.and_then(|o| o.get("part_of"))
.and_then(|v| v.as_str())
.map(|raw_value| {
let parsed = link_parser::parse_link(raw_value);
let canonical_path = &parsed.path;
let link_title = parsed
.title
.clone()
.unwrap_or_else(|| title_resolver(canonical_path));
link_parser::format_link(canonical_path, &link_title)
});
let contents = obj
.and_then(|o| o.get("contents"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|raw_value| {
let parsed = link_parser::parse_link(raw_value);
let canonical_path = &parsed.path;
let link_title = parsed
.title
.clone()
.unwrap_or_else(|| title_resolver(canonical_path));
link_parser::format_link(canonical_path, &link_title)
})
.collect()
});
let attachments = obj
.and_then(|o| o.get("attachments"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| {
if let Some(s) = v.as_str() {
Some(s.to_string())
} else if let Some(obj) = v.as_object() {
obj.get("path").and_then(|p| p.as_str()).map(String::from)
} else {
None
}
})
.collect()
});
let audience = obj
.and_then(|o| o.get("audience"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
});
let description = obj
.and_then(|o| o.get("description"))
.and_then(|v| v.as_str())
.map(String::from);
let updated = obj
.and_then(|o| o.get("modified_at"))
.and_then(|v| v.as_i64());
let mut extra = HashMap::new();
if let Some(extra_obj) = obj.and_then(|o| o.get("extra")).and_then(|v| v.as_object()) {
for (key, value) in extra_obj {
if !key.starts_with('_') {
extra.insert(key.clone(), value.clone());
}
}
}
Self {
title,
part_of,
contents,
attachments,
audience,
description,
updated,
extra,
}
}
pub fn to_yaml(&self) -> String {
let mut lines: Vec<String> = Vec::new();
if let Some(title) = &self.title {
lines.push(format!("title: {}", yaml_string(title)));
}
if let Some(part_of) = &self.part_of {
lines.push(format!("part_of: {}", yaml_string(part_of)));
}
if let Some(contents) = &self.contents {
if contents.is_empty() {
lines.push("contents: []".to_string());
} else {
lines.push("contents:".to_string());
for item in contents {
lines.push(format!(" - {}", yaml_string(item)));
}
}
}
if let Some(audience) = &self.audience
&& !audience.is_empty()
{
lines.push("audience:".to_string());
for item in audience {
lines.push(format!(" - {}", yaml_string(item)));
}
}
if let Some(description) = &self.description {
lines.push(format!("description: {}", yaml_string(description)));
}
if let Some(attachments) = &self.attachments
&& !attachments.is_empty()
{
lines.push("attachments:".to_string());
for item in attachments {
lines.push(format!(" - {}", yaml_string(item)));
}
}
if let Some(updated) = self.updated {
if let Some(dt) = chrono::Utc.timestamp_millis_opt(updated).single() {
let formatted = dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
lines.push(format!("updated: {}", yaml_string(&formatted)));
} else {
lines.push(format!("updated: {}", updated));
}
}
for (key, value) in &self.extra {
lines.push(format!("{}: {}", key, yaml_value(value)));
}
lines.join("\n")
}
}
fn yaml_string(value: &str) -> String {
if value.is_empty()
|| value.contains(':')
|| value.contains('#')
|| value.contains('[')
|| value.contains(']')
|| value.contains('{')
|| value.contains('}')
|| value.contains('|')
|| value.contains('>')
|| value.contains('&')
|| value.contains('*')
|| value.contains('!')
|| value.contains('?')
|| value.contains('\'')
|| value.contains('"')
|| value.contains('%')
|| value.contains('@')
|| value.contains('`')
|| value.contains('\n')
|| value.starts_with(' ')
|| value.ends_with(' ')
|| looks_like_number(value)
|| is_yaml_keyword(value)
{
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
} else {
value.to_string()
}
}
fn looks_like_number(s: &str) -> bool {
s.parse::<f64>().is_ok()
}
fn is_yaml_keyword(s: &str) -> bool {
matches!(
s.to_lowercase().as_str(),
"true" | "false" | "null" | "yes" | "no" | "on" | "off"
)
}
fn yaml_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => yaml_string(s),
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(yaml_value).collect();
format!("[{}]", items.join(", "))
}
serde_json::Value::Object(_) => {
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
}
}
}
pub async fn write_file_with_metadata<FS: AsyncFileSystem>(
fs: &FS,
path: &Path,
metadata: &serde_json::Value,
body: &str,
) -> Result<()> {
write_file_with_metadata_and_canonical_path(fs, path, metadata, body, None).await
}
pub async fn write_file_with_metadata_and_canonical_path<FS: AsyncFileSystem>(
fs: &FS,
path: &Path,
metadata: &serde_json::Value,
body: &str,
canonical_path: Option<&str>,
) -> Result<()> {
let fm = FrontmatterMetadata::from_json_with_file_path(metadata, canonical_path);
let yaml = fm.to_yaml();
let content = if yaml.is_empty() {
body.to_string()
} else {
format!("---\n{}\n---\n{}", yaml, body)
};
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs.create_dir_all(parent).await?;
}
recover_backup_if_needed(fs, path).await?;
let temp_path = temp_path_for(path);
let backup_path = backup_path_for(path);
let safe_write_result: Result<()> = async {
if fs.exists(&temp_path).await {
let _ = fs.delete_file(&temp_path).await;
}
fs.write_file(&temp_path, &content)
.await
.map_err(|e| DiaryxError::FileWrite {
path: temp_path.clone(),
source: e,
})?;
if fs.exists(path).await
&& let Err(e) = fs.move_file(path, &backup_path).await
{
if is_not_found_io_error(&e) {
log::warn!(
"metadata_writer: backup move skipped for '{}': {}",
path.display(),
e
);
} else {
return Err(DiaryxError::FileWrite {
path: backup_path.clone(),
source: e,
});
}
}
if let Err(e) = fs.move_file(&temp_path, path).await {
if fs.exists(&backup_path).await {
let _ = fs.move_file(&backup_path, path).await;
}
return Err(DiaryxError::FileWrite {
path: path.to_path_buf(),
source: e,
});
}
if fs.exists(&backup_path).await {
let _ = fs.delete_file(&backup_path).await;
}
Ok(())
}
.await;
match safe_write_result {
Ok(()) => Ok(()),
Err(e) if should_fallback_to_direct_write(&e) => {
log::warn!(
"metadata_writer: safe-write failed for '{}', falling back to direct overwrite: {}",
path.display(),
e
);
if fs.exists(&temp_path).await {
let _ = fs.delete_file(&temp_path).await;
}
fs.write_file(path, &content)
.await
.map_err(|source| DiaryxError::FileWrite {
path: path.to_path_buf(),
source,
})?;
if fs.exists(&backup_path).await {
let _ = fs.delete_file(&backup_path).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
pub async fn update_file_metadata<FS: AsyncFileSystem>(
fs: &FS,
path: &Path,
metadata: &serde_json::Value,
new_body: Option<&str>,
) -> Result<()> {
let body = if let Some(b) = new_body {
b.to_string()
} else {
let content = fs
.read_to_string(path)
.await
.map_err(|e| DiaryxError::FileRead {
path: path.to_path_buf(),
source: e,
})?;
let parsed = frontmatter::parse_or_empty(&content)?;
parsed.body
};
let mut merged_metadata = metadata.clone();
if let Some(obj) = merged_metadata.as_object_mut()
&& let Ok(existing_content) = fs.read_to_string(path).await
&& let Ok(parsed) = frontmatter::parse_or_empty(&existing_content)
{
let fm = &parsed.frontmatter;
let missing_contents = obj.get("contents").map(|v| v.is_null()).unwrap_or(true);
if missing_contents && let Some(seq) = fm.get("contents").and_then(|v| v.as_sequence()) {
let preserved: Vec<serde_json::Value> = seq
.iter()
.filter_map(|v| v.as_str().map(|s| serde_json::Value::String(s.to_string())))
.collect();
obj.insert("contents".to_string(), serde_json::Value::Array(preserved));
}
let missing_part_of = obj.get("part_of").map(|v| v.is_null()).unwrap_or(true);
if missing_part_of && let Some(parent) = fm.get("part_of").and_then(|v| v.as_str()) {
obj.insert(
"part_of".to_string(),
serde_json::Value::String(parent.to_string()),
);
}
}
write_file_with_metadata(fs, path, &merged_metadata, &body).await
}
fn temp_path_for(path: &Path) -> PathBuf {
match path.file_name().and_then(|n| n.to_str()) {
Some(name) => path.with_file_name(format!("{}.tmp", name)),
None => path.with_extension("tmp"),
}
}
fn backup_path_for(path: &Path) -> PathBuf {
match path.file_name().and_then(|n| n.to_str()) {
Some(name) => path.with_file_name(format!("{}.bak", name)),
None => path.with_extension("bak"),
}
}
fn should_fallback_to_direct_write(err: &DiaryxError) -> bool {
if let DiaryxError::FileWrite { source, .. } = err {
if is_not_found_io_error(source) {
return true;
}
if source.kind() == ErrorKind::AlreadyExists {
return true;
}
}
let msg = err.to_string();
msg.contains("NoModificationAllowedError")
|| msg.contains("InvalidStateError")
|| msg.contains("NotAllowedError")
|| msg.contains("NotFoundError")
|| msg.contains("A requested file or directory could not be found")
|| msg.contains("The object can not be found here")
|| msg.contains("already exists")
}
fn is_not_found_io_error(err: &std::io::Error) -> bool {
err.kind() == ErrorKind::NotFound
|| err.to_string().contains("NotFoundError")
|| err
.to_string()
.contains("A requested file or directory could not be found")
|| err.to_string().contains("The object can not be found here")
}
async fn recover_backup_if_needed<FS: AsyncFileSystem>(fs: &FS, path: &Path) -> Result<()> {
let backup_path = backup_path_for(path);
if fs.exists(&backup_path).await {
if !fs.exists(path).await {
fs.move_file(&backup_path, path)
.await
.map_err(|e| DiaryxError::FileWrite {
path: path.to_path_buf(),
source: e,
})?;
} else {
let _ = fs.delete_file(&backup_path).await;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, InMemoryFileSystem, SyncToAsyncFs, block_on_test};
use std::io;
use std::sync::Mutex;
struct NotFoundOnBackupMoveFs {
inner: InMemoryFileSystem,
always_fail_backup_move: bool,
fail_backup_move_once: Mutex<bool>,
}
impl NotFoundOnBackupMoveFs {
fn fail_once() -> Self {
Self {
inner: InMemoryFileSystem::new(),
always_fail_backup_move: false,
fail_backup_move_once: Mutex::new(true),
}
}
fn fail_always() -> Self {
Self {
inner: InMemoryFileSystem::new(),
always_fail_backup_move: true,
fail_backup_move_once: Mutex::new(false),
}
}
fn should_fail_backup_move(&self, to: &Path) -> bool {
if !to.to_string_lossy().ends_with(".bak") {
return false;
}
if self.always_fail_backup_move {
return true;
}
let mut once = self.fail_backup_move_once.lock().unwrap();
if *once {
*once = false;
return true;
}
false
}
}
impl FileSystem for NotFoundOnBackupMoveFs {
fn read_to_string(&self, path: &Path) -> io::Result<String> {
self.inner.read_to_string(path)
}
fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
self.inner.write_file(path, content)
}
fn create_new(&self, path: &Path, content: &str) -> io::Result<()> {
self.inner.create_new(path, content)
}
fn delete_file(&self, path: &Path) -> io::Result<()> {
self.inner.delete_file(path)
}
fn list_md_files(&self, dir: &Path) -> io::Result<Vec<PathBuf>> {
self.inner.list_md_files(dir)
}
fn exists(&self, path: &Path) -> bool {
self.inner.exists(path)
}
fn create_dir_all(&self, path: &Path) -> io::Result<()> {
self.inner.create_dir_all(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.inner.is_dir(path)
}
fn move_file(&self, from: &Path, to: &Path) -> io::Result<()> {
if self.should_fail_backup_move(to) {
return Err(io::Error::new(
ErrorKind::NotFound,
"NotFoundError: A requested file or directory could not be found",
));
}
self.inner.move_file(from, to)
}
}
#[test]
fn test_yaml_string_simple() {
assert_eq!(yaml_string("hello"), "hello");
assert_eq!(yaml_string("hello world"), "hello world");
}
#[test]
fn test_yaml_string_needs_quoting() {
assert_eq!(yaml_string("hello: world"), "\"hello: world\"");
assert_eq!(yaml_string("has #comment"), "\"has #comment\"");
assert_eq!(yaml_string("true"), "\"true\"");
assert_eq!(yaml_string("123"), "\"123\"");
assert_eq!(yaml_string(" leading space"), "\" leading space\"");
}
#[test]
fn test_yaml_string_escaping() {
assert_eq!(yaml_string("has \"quotes\""), "\"has \\\"quotes\\\"\"");
}
#[test]
fn test_frontmatter_metadata_from_json() {
let json = serde_json::json!({
"title": "Test Title",
"part_of": "workspace/parent.md",
"contents": ["child1.md", "child2.md"],
"description": "A test file",
"extra": {
"custom_key": "custom_value",
"_body": "should be excluded"
}
});
let fm = FrontmatterMetadata::from_json(&json);
assert_eq!(fm.title, Some("Test Title".to_string()));
assert_eq!(
fm.part_of,
Some("[Parent](/workspace/parent.md)".to_string())
);
assert_eq!(
fm.contents,
Some(vec".to_string(),
"[Child2](/child2.md)".to_string()
])
);
assert_eq!(fm.description, Some("A test file".to_string()));
assert!(fm.extra.contains_key("custom_key"));
assert!(!fm.extra.contains_key("_body")); }
#[test]
fn test_frontmatter_metadata_to_yaml() {
let fm = FrontmatterMetadata {
title: Some("Test Title".to_string()),
part_of: Some("[Parent Index](/folder/parent.md)".to_string()),
contents: Some(vec".to_string()]),
audience: None,
description: Some("A description".to_string()),
attachments: None,
updated: None,
extra: HashMap::new(),
};
let yaml = fm.to_yaml();
assert!(yaml.contains("title: Test Title"));
assert!(yaml.contains("part_of: \"[Parent Index](/folder/parent.md)\""));
assert!(yaml.contains("contents:"));
assert!(yaml.contains(" - \"[Child](/folder/child.md)\""));
assert!(yaml.contains("description: A description"));
}
#[test]
fn test_empty_contents_written_as_empty_array() {
let fm = FrontmatterMetadata {
title: Some("Root Index".to_string()),
part_of: None,
contents: Some(vec![]), audience: None,
description: None,
attachments: None,
updated: None,
extra: HashMap::new(),
};
let yaml = fm.to_yaml();
assert!(
yaml.contains("contents: []"),
"Empty contents should be written as 'contents: []', got: {}",
yaml
);
}
#[test]
fn test_none_contents_not_written() {
let fm = FrontmatterMetadata {
title: Some("Regular File".to_string()),
part_of: Some("parent.md".to_string()),
contents: None, audience: None,
description: None,
attachments: None,
updated: None,
extra: HashMap::new(),
};
let yaml = fm.to_yaml();
assert!(
!yaml.contains("contents"),
"None contents should not be written, got: {}",
yaml
);
}
#[test]
fn test_from_json_with_markdown_links_formats_part_of() {
let json = serde_json::json!({
"title": "Child File",
"part_of": "folder/parent.md",
});
let fm = FrontmatterMetadata::from_json_with_file_path(&json, Some("folder/child.md"));
assert_eq!(fm.part_of, Some("[Parent](/folder/parent.md)".to_string()));
}
#[test]
fn test_from_json_with_markdown_links_formats_contents() {
let json = serde_json::json!({
"title": "Parent Index",
"contents": ["folder/child1.md", "folder/sub/child2.md"],
});
let fm = FrontmatterMetadata::from_json_with_file_path(&json, Some("folder/index.md"));
assert_eq!(
fm.contents,
Some(vec".to_string(),
"[Child2](/folder/sub/child2.md)".to_string()
])
);
}
#[test]
fn test_from_json_with_custom_title_resolver() {
let json = serde_json::json!({
"title": "Index",
"part_of": "root/parent.md",
"contents": ["root/child.md"],
});
let fm = FrontmatterMetadata::from_json_with_markdown_links(&json, |path| {
if path == "root/parent.md" {
"My Custom Parent Title".to_string()
} else if path == "root/child.md" {
"My Custom Child Title".to_string()
} else {
link_parser::path_to_title(path)
}
});
assert_eq!(
fm.part_of,
Some("[My Custom Parent Title](/root/parent.md)".to_string())
);
assert_eq!(
fm.contents,
Some(vec".to_string()])
);
}
#[test]
fn test_yaml_string_quotes_markdown_links() {
let link = "[Title](/path/to/file.md)";
let quoted = yaml_string(link);
assert_eq!(quoted, "\"[Title](/path/to/file.md)\"");
}
#[test]
fn test_roundtrip_markdown_link_to_yaml() {
let fm = FrontmatterMetadata {
title: Some("Test Entry".to_string()),
part_of: Some("[Parent Index](/Folder/index.md)".to_string()),
contents: Some(vec".to_string()]),
audience: None,
description: None,
attachments: None,
updated: None,
extra: HashMap::new(),
};
let yaml = fm.to_yaml();
assert!(yaml.contains("part_of: \"[Parent Index](/Folder/index.md)\""));
assert!(yaml.contains(" - \"[Child Entry](/Folder/child.md)\""));
}
#[test]
fn test_from_json_with_already_formatted_markdown_links() {
let json = serde_json::json!({
"title": "Child File",
"part_of": "[Parent Index](/folder/parent.md)",
"contents": [
"[Child A](/folder/child_a.md)",
"[Child B](/folder/child_b.md)",
],
});
let fm = FrontmatterMetadata::from_json_with_file_path(&json, Some("folder/child.md"));
assert_eq!(
fm.part_of,
Some("[Parent Index](/folder/parent.md)".to_string())
);
assert_eq!(
fm.contents,
Some(vec".to_string(),
"[Child B](/folder/child_b.md)".to_string(),
])
);
}
#[test]
fn test_from_json_handles_mixed_formats() {
let json = serde_json::json!({
"title": "Mixed Index",
"contents": [
"folder/plain_path.md",
"[Already Formatted](/folder/formatted.md)",
],
});
let fm = FrontmatterMetadata::from_json_with_file_path(&json, Some("folder/index.md"));
assert_eq!(
fm.contents,
Some(vec".to_string(),
"[Already Formatted](/folder/formatted.md)".to_string(),
])
);
}
#[test]
fn test_write_file_with_metadata_tolerates_not_found_during_backup_move_once() {
let fs = SyncToAsyncFs::new(NotFoundOnBackupMoveFs::fail_once());
let path = Path::new("README.md");
block_on_test(fs.write_file(path, "original")).unwrap();
let metadata = serde_json::json!({ "title": "My Journal" });
block_on_test(write_file_with_metadata(
&fs,
path,
&metadata,
"# first edit",
))
.unwrap();
let updated = block_on_test(fs.read_to_string(path)).unwrap();
assert!(updated.contains("# first edit"));
}
#[test]
fn test_write_file_with_metadata_tolerates_not_found_during_backup_move_every_time() {
let fs = SyncToAsyncFs::new(NotFoundOnBackupMoveFs::fail_always());
let path = Path::new("README.md");
block_on_test(fs.write_file(path, "original")).unwrap();
let metadata = serde_json::json!({ "title": "My Journal" });
block_on_test(write_file_with_metadata(&fs, path, &metadata, "# edit one")).unwrap();
block_on_test(write_file_with_metadata(&fs, path, &metadata, "# edit two")).unwrap();
let updated = block_on_test(fs.read_to_string(path)).unwrap();
assert!(updated.contains("# edit two"));
}
}