use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use tokio::io::AsyncReadExt;
use turbovault_core::prelude::*;
use turbovault_vault::VaultManager;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WriteMode {
#[default]
Overwrite,
Append,
Prepend,
}
impl WriteMode {
pub fn from_str_opt(s: Option<&str>) -> Result<Self> {
match s {
None | Some("overwrite") => Ok(Self::Overwrite),
Some("append") => Ok(Self::Append),
Some("prepend") => Ok(Self::Prepend),
Some(other) => Err(Error::config_error(format!(
"Invalid write mode '{}'. Must be 'overwrite', 'append', or 'prepend'",
other
))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NoteInfo {
pub path: String,
pub exists: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_frontmatter: Option<bool>,
}
#[derive(Clone)]
pub struct FileTools {
pub manager: Arc<VaultManager>,
}
impl FileTools {
pub fn new(manager: Arc<VaultManager>) -> Self {
Self { manager }
}
pub async fn read_file(&self, path: &str) -> Result<String> {
let file_path = PathBuf::from(path);
self.manager.read_file(&file_path).await
}
pub async fn write_file_with_mode(
&self,
path: &str,
content: &str,
mode: WriteMode,
expected_hash: Option<&str>,
) -> Result<()> {
match mode {
WriteMode::Overwrite => {
let file_path = PathBuf::from(path);
self.manager
.write_file(&file_path, content, expected_hash)
.await
}
WriteMode::Append => {
let existing = self.read_file(path).await.unwrap_or_default();
let combined = if existing.is_empty() {
content.to_string()
} else {
format!("{}\n{}", existing, content)
};
let file_path = PathBuf::from(path);
self.manager
.write_file(&file_path, &combined, expected_hash)
.await
}
WriteMode::Prepend => {
let existing = self.read_file(path).await.unwrap_or_default();
if existing.is_empty() {
let file_path = PathBuf::from(path);
return self
.manager
.write_file(&file_path, content, expected_hash)
.await;
}
let combined = if existing.starts_with("---\n") || existing.starts_with("---\r\n") {
if let Some(end_idx) = find_frontmatter_end(&existing) {
let (frontmatter_block, body) = existing.split_at(end_idx);
format!(
"{}\n{}\n{}",
frontmatter_block.trim_end(),
content,
body.trim_start()
)
} else {
format!("{}\n{}", content, existing)
}
} else {
format!("{}\n{}", content, existing)
};
let file_path = PathBuf::from(path);
self.manager
.write_file(&file_path, &combined, expected_hash)
.await
}
}
}
pub async fn write_file(&self, path: &str, content: &str) -> Result<()> {
self.write_file_with_mode(path, content, WriteMode::Overwrite, None)
.await
}
pub async fn edit_file(
&self,
path: &str,
edits: &str,
expected_hash: Option<&str>,
dry_run: bool,
) -> Result<turbovault_vault::EditResult> {
let file_path = PathBuf::from(path);
self.manager
.edit_file(&file_path, edits, expected_hash, dry_run)
.await
}
pub async fn delete_file(&self, path: &str) -> Result<()> {
self.manager.delete_file(&PathBuf::from(path), None).await
}
pub async fn delete_file_with_hash(
&self,
path: &str,
expected_hash: Option<&str>,
) -> Result<()> {
self.manager
.delete_file(&PathBuf::from(path), expected_hash)
.await
}
pub async fn move_file(&self, from: &str, to: &str) -> Result<()> {
self.manager
.move_file(&PathBuf::from(from), &PathBuf::from(to), None)
.await
}
pub async fn move_file_with_hash(
&self,
from: &str,
to: &str,
expected_hash: Option<&str>,
) -> Result<()> {
self.manager
.move_file(&PathBuf::from(from), &PathBuf::from(to), expected_hash)
.await
}
pub async fn copy_file(&self, from: &str, to: &str) -> Result<()> {
let from_path = self.manager.resolve_path(&PathBuf::from(from))?;
let to_path = self.manager.resolve_path(&PathBuf::from(to))?;
if let Some(parent) = to_path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(Error::io)?;
}
tokio::fs::copy(&from_path, &to_path)
.await
.map_err(Error::io)?;
Ok(())
}
pub async fn get_notes_info(&self, paths: &[String]) -> Result<Vec<NoteInfo>> {
let mut results = Vec::with_capacity(paths.len());
for path in paths {
let full_path = match self.manager.resolve_path(&PathBuf::from(path.as_str())) {
Ok(p) => p,
Err(_) => {
results.push(NoteInfo {
path: path.clone(),
exists: false,
size_bytes: None,
modified_at: None,
has_frontmatter: None,
});
continue;
}
};
match tokio::fs::metadata(&full_path).await {
Ok(meta) => {
let size = meta.len();
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| {
chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())
.map(|dt| dt.to_rfc3339())
.unwrap_or_default()
});
let has_fm = if size >= 4 {
match tokio::fs::File::open(&full_path).await {
Ok(mut file) => {
let mut buf = [0u8; 5];
let n = file.read(&mut buf).await.unwrap_or(0);
let slice = &buf[..n];
Some(slice.starts_with(b"---\n") || slice.starts_with(b"---\r\n"))
}
Err(_) => None,
}
} else {
Some(false)
};
results.push(NoteInfo {
path: path.clone(),
exists: true,
size_bytes: Some(size),
modified_at: modified,
has_frontmatter: has_fm,
});
}
Err(_) => {
results.push(NoteInfo {
path: path.clone(),
exists: false,
size_bytes: None,
modified_at: None,
has_frontmatter: None,
});
}
}
}
Ok(results)
}
}
pub fn obsidian_uri(vault_name: &str, file_path: &str) -> String {
let file = file_path.strip_suffix(".md").unwrap_or(file_path);
format!(
"obsidian://open?vault={}&file={}",
urlencoding::encode(vault_name),
urlencoding::encode(file)
)
}
fn find_frontmatter_end(content: &str) -> Option<usize> {
let start = if content.starts_with("---\r\n") {
5
} else if content.starts_with("---\n") {
4
} else {
return None;
};
let bytes = content.as_bytes();
let check_closing = |pos: usize| -> Option<usize> {
if !bytes[pos..].starts_with(b"---") {
return None;
}
let after_dashes = pos + 3;
if after_dashes >= bytes.len() {
return Some(after_dashes); }
match bytes[after_dashes] {
b'\n' => Some(after_dashes + 1),
b'\r' if after_dashes + 1 < bytes.len() && bytes[after_dashes + 1] == b'\n' => {
Some(after_dashes + 2)
}
_ => None, }
};
if let Some(end) = check_closing(start) {
return Some(end);
}
let mut i = start;
while i < bytes.len() {
let nl_pos = match memchr_newline(bytes, i) {
Some(pos) => pos,
None => break,
};
let line_start =
if bytes[nl_pos] == b'\r' && nl_pos + 1 < bytes.len() && bytes[nl_pos + 1] == b'\n' {
nl_pos + 2
} else {
nl_pos + 1
};
if line_start >= bytes.len() {
break;
}
if let Some(end) = check_closing(line_start) {
return Some(end);
}
i = line_start;
}
None
}
fn memchr_newline(bytes: &[u8], start: usize) -> Option<usize> {
bytes[start..]
.iter()
.position(|&b| b == b'\n' || b == b'\r')
.map(|p| start + p)
}
pub fn split_frontmatter(content: &str) -> (Option<String>, String) {
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return (None, content.to_string());
}
let start = if content.starts_with("---\r\n") { 5 } else { 4 };
if let Some(end) = find_frontmatter_end(content) {
let body = &content[end..];
let closing_start = if end >= 5
&& content.as_bytes()[end - 1] == b'\n'
&& content.as_bytes()[end - 2] == b'\r'
{
end - 5
} else if end >= 4 && content.as_bytes()[end - 1] == b'\n' {
end - 4
} else {
end - 3
};
let yaml = content[start..closing_start].trim().to_string();
(
if yaml.is_empty() { None } else { Some(yaml) },
body.to_string(),
)
} else {
(None, content.to_string())
}
}
pub fn reconstruct_content(
frontmatter: Option<&serde_json::Map<String, serde_json::Value>>,
body: &str,
) -> String {
match frontmatter {
Some(fm) if !fm.is_empty() => {
let yaml = serde_yaml::to_string(&fm).unwrap_or_default();
format!("---\n{}---\n{}", yaml, body)
}
_ => body.to_string(),
}
}
pub fn deep_merge(base: &mut serde_json::Value, overlay: serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
for (key, value) in overlay_map {
let entry = base_map.entry(key).or_insert(serde_json::Value::Null);
if entry.is_object() && value.is_object() {
deep_merge(entry, value);
} else {
*entry = value;
}
}
}
(base, overlay) => {
*base = overlay;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_write_mode_from_str() {
assert_eq!(WriteMode::from_str_opt(None).unwrap(), WriteMode::Overwrite);
assert_eq!(
WriteMode::from_str_opt(Some("overwrite")).unwrap(),
WriteMode::Overwrite
);
assert_eq!(
WriteMode::from_str_opt(Some("append")).unwrap(),
WriteMode::Append
);
assert_eq!(
WriteMode::from_str_opt(Some("prepend")).unwrap(),
WriteMode::Prepend
);
assert!(WriteMode::from_str_opt(Some("invalid")).is_err());
}
#[test]
fn test_split_frontmatter_with_fm() {
let content = "---\ntitle: Test\ntags: [a, b]\n---\n# Body\nHello";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_some());
assert!(fm.unwrap().contains("title: Test"));
assert!(body.contains("# Body"));
}
#[test]
fn test_split_frontmatter_without_fm() {
let content = "# No Frontmatter\nJust content";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_none());
assert_eq!(body, content);
}
#[test]
fn test_obsidian_uri() {
let uri = obsidian_uri("My Vault", "daily/2024-01-15.md");
assert_eq!(
uri,
"obsidian://open?vault=My%20Vault&file=daily%2F2024-01-15"
);
}
#[test]
fn test_obsidian_uri_no_extension() {
let uri = obsidian_uri("vault", "folder/note");
assert_eq!(uri, "obsidian://open?vault=vault&file=folder%2Fnote");
}
#[test]
fn test_deep_merge() {
let mut base = serde_json::json!({"a": 1, "b": {"c": 2, "d": 3}});
let overlay = serde_json::json!({"b": {"c": 99, "e": 4}, "f": 5});
deep_merge(&mut base, overlay);
assert_eq!(base["a"], 1);
assert_eq!(base["b"]["c"], 99);
assert_eq!(base["b"]["d"], 3);
assert_eq!(base["b"]["e"], 4);
assert_eq!(base["f"], 5);
}
#[test]
fn test_reconstruct_content() {
let mut fm = serde_json::Map::new();
fm.insert("title".to_string(), serde_json::json!("Test"));
let result = reconstruct_content(Some(&fm), "# Body\nContent\n");
assert!(result.starts_with("---\n"));
assert!(result.contains("title: Test"));
assert!(result.contains("---\n# Body"));
}
#[test]
fn test_reconstruct_content_no_fm() {
let result = reconstruct_content(None, "# Just body\n");
assert_eq!(result, "# Just body\n");
}
#[test]
fn test_split_frontmatter_with_dashes_in_yaml_value() {
let content = "---\ntitle: foo---bar\nstatus: draft\n---\n# Body";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_some());
let yaml = fm.unwrap();
assert!(yaml.contains("foo---bar"));
assert!(yaml.contains("status: draft"));
assert_eq!(body, "# Body");
}
#[test]
fn test_split_frontmatter_empty_frontmatter() {
let content = "---\n---\n# Body";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_none());
assert_eq!(body, "# Body");
}
#[test]
fn test_find_frontmatter_end_at_eof() {
let content = "---\ntitle: Test\n---";
let end = find_frontmatter_end(content);
assert_eq!(end, Some(content.len()));
}
#[test]
fn test_find_frontmatter_end_crlf_opening_and_closing() {
let content = "---\r\ntitle: Test\r\n---\r\n# Body";
let end = find_frontmatter_end(content);
assert_eq!(end, Some(23));
assert_eq!(&content[end.unwrap()..], "# Body");
}
#[test]
fn test_find_frontmatter_end_crlf_opening_lf_closing() {
let content = "---\r\ntitle: Test\n---\n# Body";
let end = find_frontmatter_end(content);
assert!(end.is_some());
assert_eq!(&content[end.unwrap()..], "# Body");
}
#[test]
fn test_find_frontmatter_end_no_closing_delimiter() {
let content = "---\ntitle: Test\nno closing here\n";
let end = find_frontmatter_end(content);
assert_eq!(end, None);
}
#[test]
fn test_find_frontmatter_end_not_frontmatter() {
let content = "# Just a heading\nNot frontmatter";
let end = find_frontmatter_end(content);
assert_eq!(end, None);
}
#[test]
fn test_find_frontmatter_end_dashes_mid_line_not_delimiter() {
let content = "---\ntitle: Test\nfoo---bar\n---\n# Body";
let end = find_frontmatter_end(content);
assert!(end.is_some());
assert_eq!(&content[end.unwrap()..], "# Body");
}
#[test]
fn test_find_frontmatter_end_dashes_with_trailing_chars() {
let content = "---\ntitle: Test\n---x\n---\n# Body";
let end = find_frontmatter_end(content);
assert!(end.is_some());
assert_eq!(&content[end.unwrap()..], "# Body");
}
#[test]
fn test_find_frontmatter_end_immediate_close() {
let content = "---\n---\n# Body";
let end = find_frontmatter_end(content);
assert_eq!(end, Some(8));
assert_eq!(&content[8..], "# Body");
}
#[test]
fn test_memchr_newline_finds_lf() {
let bytes = b"hello\nworld";
assert_eq!(memchr_newline(bytes, 0), Some(5));
}
#[test]
fn test_memchr_newline_finds_cr() {
let bytes = b"hello\rworld";
assert_eq!(memchr_newline(bytes, 0), Some(5));
}
#[test]
fn test_memchr_newline_no_newline() {
let bytes = b"hello world";
assert_eq!(memchr_newline(bytes, 0), None);
}
#[test]
fn test_memchr_newline_start_offset() {
let bytes = b"a\nb\nc";
assert_eq!(memchr_newline(bytes, 2), Some(3)); }
#[test]
fn test_memchr_newline_start_at_end() {
let bytes = b"hello";
assert_eq!(memchr_newline(bytes, 5), None); }
#[test]
fn test_split_frontmatter_crlf_only() {
let content = "---\r\ntitle: Test\r\n---\r\n# Body";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_some());
assert!(fm.unwrap().contains("title: Test"));
assert_eq!(body, "# Body");
}
#[test]
fn test_split_frontmatter_whitespace_only_yaml() {
let content = "---\n \n---\n# Body";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_none()); assert_eq!(body, "# Body");
}
#[test]
fn test_split_frontmatter_body_starts_with_dashes() {
let content = "---\ntitle: Test\n---\n---\nMore content";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_some());
assert!(fm.unwrap().contains("title: Test"));
assert_eq!(body, "---\nMore content");
}
#[test]
fn test_split_frontmatter_no_closing_returns_none() {
let content = "---\ntitle: Test\nno closing\n";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_none());
assert_eq!(body, content);
}
#[test]
fn test_split_frontmatter_closing_at_eof_no_newline() {
let content = "---\ntitle: Test\n---";
let (fm, body) = split_frontmatter(content);
assert!(fm.is_some());
assert!(fm.unwrap().contains("title: Test"));
assert_eq!(body, "");
}
#[test]
fn test_reconstruct_content_empty_map() {
let fm = serde_json::Map::new();
let result = reconstruct_content(Some(&fm), "# Body\n");
assert_eq!(result, "# Body\n");
}
#[test]
fn test_reconstruct_content_roundtrip() {
let mut fm = serde_json::Map::new();
fm.insert("title".to_string(), serde_json::json!("Test"));
fm.insert("tags".to_string(), serde_json::json!(["a", "b"]));
let body = "# Body\nContent\n";
let content = reconstruct_content(Some(&fm), body);
let (parsed_fm, parsed_body) = split_frontmatter(&content);
assert!(parsed_fm.is_some());
let parsed: serde_json::Map<String, serde_json::Value> =
serde_yaml::from_str(&parsed_fm.unwrap()).unwrap();
assert_eq!(parsed["title"], "Test");
assert_eq!(parsed_body.trim(), body.trim());
}
#[test]
fn test_reconstruct_content_special_yaml_chars() {
let mut fm = serde_json::Map::new();
fm.insert("url".to_string(), serde_json::json!("http://example.com"));
fm.insert(
"desc".to_string(),
serde_json::json!("has: colons [and] brackets"),
);
let result = reconstruct_content(Some(&fm), "body\n");
assert!(result.starts_with("---\n"));
let (parsed_fm, _) = split_frontmatter(&result);
assert!(parsed_fm.is_some());
let parsed: serde_json::Map<String, serde_json::Value> =
serde_yaml::from_str(&parsed_fm.unwrap()).unwrap();
assert_eq!(parsed["url"], "http://example.com");
}
#[test]
fn test_deep_merge_scalar_overlay_replaces_object() {
let mut base = serde_json::json!({"a": {"nested": 1}});
let overlay = serde_json::json!({"a": 42});
deep_merge(&mut base, overlay);
assert_eq!(base["a"], 42);
}
#[test]
fn test_deep_merge_object_overlay_replaces_scalar() {
let mut base = serde_json::json!({"a": 42});
let overlay = serde_json::json!({"a": {"nested": 1}});
deep_merge(&mut base, overlay);
assert_eq!(base["a"]["nested"], 1);
}
#[test]
fn test_deep_merge_null_overlay() {
let mut base = serde_json::json!({"a": 1});
let overlay = serde_json::json!({"a": null});
deep_merge(&mut base, overlay);
assert!(base["a"].is_null());
}
#[test]
fn test_deep_merge_array_replaced_not_appended() {
let mut base = serde_json::json!({"tags": ["a", "b"]});
let overlay = serde_json::json!({"tags": ["c"]});
deep_merge(&mut base, overlay);
assert_eq!(base["tags"], serde_json::json!(["c"]));
}
#[test]
fn test_deep_merge_non_object_base() {
let mut base = serde_json::json!(null);
let overlay = serde_json::json!({"a": 1});
deep_merge(&mut base, overlay);
assert_eq!(base["a"], 1);
}
}