fix: rustfmt formatting

This commit is contained in:
lucaronin
2026-02-23 11:28:11 +01:00
parent 62860b3118
commit 61a596f777
9 changed files with 628 additions and 246 deletions

View File

@@ -49,7 +49,10 @@ fn get_api_key() -> Result<String, String> {
fn build_request(req: &AiChatRequest) -> AnthropicRequest {
AnthropicRequest {
model: req.model.clone().unwrap_or_else(|| "claude-3-5-haiku-20241022".to_string()),
model: req
.model
.clone()
.unwrap_or_else(|| "claude-3-5-haiku-20241022".to_string()),
max_tokens: req.max_tokens.unwrap_or(4096),
messages: req.messages.clone(),
system: req.system.clone(),
@@ -137,8 +140,12 @@ mod tests {
fn test_extract_response_text() {
let resp = AnthropicResponse {
content: vec![
ContentBlock { text: Some("Hello ".to_string()) },
ContentBlock { text: Some("world".to_string()) },
ContentBlock {
text: Some("Hello ".to_string()),
},
ContentBlock {
text: Some("world".to_string()),
},
ContentBlock { text: None },
],
model: "test".to_string(),

View File

@@ -54,14 +54,20 @@ impl FrontmatterValue {
pub fn to_yaml_value(&self) -> String {
match self {
FrontmatterValue::String(s) => {
if needs_yaml_quoting(s) { quote_yaml_string(s) } else { s.clone() }
if needs_yaml_quoting(s) {
quote_yaml_string(s)
} else {
s.clone()
}
}
FrontmatterValue::Number(n) => format_yaml_number(*n),
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
FrontmatterValue::List(items) => {
items.iter().map(|item| format_list_item(item)).collect::<Vec<_>>().join("\n")
}
FrontmatterValue::List(items) => items
.iter()
.map(|item| format_list_item(item))
.collect::<Vec<_>>()
.join("\n"),
FrontmatterValue::Null => "null".to_string(),
}
}
@@ -69,7 +75,8 @@ impl FrontmatterValue {
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
fn needs_key_quoting(key: &str) -> bool {
key.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
key.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
}
/// Format a key for YAML output (quote if necessary)
@@ -84,21 +91,21 @@ pub fn format_yaml_key(key: &str) -> String {
/// Check if a line defines a specific key (handles quoted and unquoted keys)
fn line_is_key(line: &str, key: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
return true;
}
let dq = format!("\"{}\":", key);
if trimmed.starts_with(&dq) {
return true;
}
let sq = format!("'{}\':", key);
if trimmed.starts_with(&sq) {
return true;
}
false
}
@@ -121,7 +128,8 @@ fn is_list_continuation(line: &str) -> bool {
/// Split content into frontmatter body and the rest after the closing `---`.
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
let fm_end = content[4..].find("\n---")
let fm_end = content[4..]
.find("\n---")
.map(|i| i + 4)
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
Ok((&content[4..fm_end], &content[fm_end + 4..]))
@@ -168,7 +176,11 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(content: &str, key: &str, value: Option<FrontmatterValue>) -> Result<String, String> {
pub fn update_frontmatter_content(
content: &str,
key: &str,
value: Option<FrontmatterValue>,
) -> Result<String, String> {
if !content.starts_with("---\n") {
return match value {
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
@@ -192,15 +204,14 @@ where
if !file_path.exists() {
return Err(format!("File does not exist: {}", path));
}
let content = fs::read_to_string(file_path)
.map_err(|e| format!("Failed to read {}: {}", path, e))?;
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))?;
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
Ok(updated)
}
@@ -211,7 +222,12 @@ mod tests {
#[test]
fn test_update_frontmatter_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Status", Some(FrontmatterValue::String("Active".to_string()))).unwrap();
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
assert!(updated.contains("Status: Active"));
assert!(!updated.contains("Status: Draft"));
}
@@ -219,7 +235,12 @@ mod tests {
#[test]
fn test_update_frontmatter_add_new_key() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Owner", Some(FrontmatterValue::String("Luca".to_string()))).unwrap();
let updated = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(updated.contains("Owner: Luca"));
assert!(updated.contains("Status: Draft"));
}
@@ -227,7 +248,12 @@ mod tests {
#[test]
fn test_update_frontmatter_quoted_key() {
let content = "---\n\"Is A\": Note\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Is A", Some(FrontmatterValue::String("Project".to_string()))).unwrap();
let updated = update_frontmatter_content(
content,
"Is A",
Some(FrontmatterValue::String("Project".to_string())),
)
.unwrap();
assert!(updated.contains("\"Is A\": Project"));
assert!(!updated.contains("Note"));
}
@@ -235,7 +261,15 @@ mod tests {
#[test]
fn test_update_frontmatter_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "aliases", Some(FrontmatterValue::List(vec!["Alias1".to_string(), "Alias2".to_string()]))).unwrap();
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"Alias1".to_string(),
"Alias2".to_string(),
])),
)
.unwrap();
assert!(updated.contains("aliases:"));
assert!(updated.contains(" - \"Alias1\""));
assert!(updated.contains(" - \"Alias2\""));
@@ -244,7 +278,12 @@ mod tests {
#[test]
fn test_update_frontmatter_replace_list() {
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "aliases", Some(FrontmatterValue::List(vec!["New1".to_string()]))).unwrap();
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec!["New1".to_string()])),
)
.unwrap();
assert!(updated.contains(" - \"New1\""));
assert!(!updated.contains("Old1"));
assert!(!updated.contains("Old2"));
@@ -271,7 +310,12 @@ mod tests {
#[test]
fn test_update_frontmatter_no_existing() {
let content = "# Test\n\nSome content here.";
let updated = update_frontmatter_content(content, "Status", Some(FrontmatterValue::String("Draft".to_string()))).unwrap();
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Draft".to_string())),
)
.unwrap();
assert!(updated.starts_with("---\n"));
assert!(updated.contains("Status: Draft"));
assert!(updated.contains("# Test"));
@@ -280,7 +324,9 @@ mod tests {
#[test]
fn test_update_frontmatter_bool() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true))).unwrap();
let updated =
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
.unwrap();
assert!(updated.contains("Reviewed: true"));
}
@@ -324,19 +370,34 @@ mod tests {
#[test]
fn test_to_yaml_value_string_needs_quoting_bool_like() {
assert_eq!(FrontmatterValue::String("true".to_string()).to_yaml_value(), "\"true\"");
assert_eq!(FrontmatterValue::String("false".to_string()).to_yaml_value(), "\"false\"");
assert_eq!(
FrontmatterValue::String("true".to_string()).to_yaml_value(),
"\"true\""
);
assert_eq!(
FrontmatterValue::String("false".to_string()).to_yaml_value(),
"\"false\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_null_like() {
assert_eq!(FrontmatterValue::String("null".to_string()).to_yaml_value(), "\"null\"");
assert_eq!(
FrontmatterValue::String("null".to_string()).to_yaml_value(),
"\"null\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_number_like() {
assert_eq!(FrontmatterValue::String("42".to_string()).to_yaml_value(), "\"42\"");
assert_eq!(FrontmatterValue::String("3.14".to_string()).to_yaml_value(), "\"3.14\"");
assert_eq!(
FrontmatterValue::String("42".to_string()).to_yaml_value(),
"\"42\""
);
assert_eq!(
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
"\"3.14\""
);
}
#[test]
@@ -379,35 +440,46 @@ mod tests {
#[test]
fn test_update_frontmatter_number() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0))).unwrap();
let updated =
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
.unwrap();
assert!(updated.contains("Priority: 5"));
}
#[test]
fn test_update_frontmatter_number_float() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5))).unwrap();
let updated =
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
.unwrap();
assert!(updated.contains("Score: 9.5"));
}
#[test]
fn test_update_frontmatter_null() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
let updated =
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
assert!(updated.contains("ClearMe: null"));
}
#[test]
fn test_update_frontmatter_empty_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![]))).unwrap();
let updated =
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
.unwrap();
assert!(updated.contains("tags: []"));
}
#[test]
fn test_update_frontmatter_malformed_no_closing_fence() {
let content = "---\nStatus: Draft\nNo closing fence here";
let result = update_frontmatter_content(content, "Status", Some(FrontmatterValue::String("Active".to_string())));
let result = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Malformed frontmatter"));
}
@@ -472,7 +544,12 @@ mod tests {
#[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 updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
// Parse back with gray_matter
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
@@ -487,7 +564,15 @@ mod tests {
#[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 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();
@@ -508,7 +593,12 @@ mod tests {
#[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();
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"));

View File

@@ -181,8 +181,8 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
let status_out = String::from_utf8_lossy(&status.stdout);
if status_out.starts_with("??") {
// Untracked file: show entire content as added
let content = std::fs::read_to_string(file)
.map_err(|e| format!("Failed to read file: {}", e))?;
let content =
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
return Ok(format!(
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
@@ -197,7 +197,11 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
}
/// Get git diff for a specific file at a given commit (compared to its parent).
pub fn get_file_diff_at_commit(vault_path: &str, file_path: &str, commit_hash: &str) -> Result<String, String> {
pub fn get_file_diff_at_commit(
vault_path: &str,
file_path: &str,
commit_hash: &str,
) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
@@ -211,7 +215,13 @@ pub fn get_file_diff_at_commit(vault_path: &str, file_path: &str, commit_hash: &
// Show diff between commit^ and commit for this file
let output = Command::new("git")
.args(["diff", &format!("{}^", commit_hash), commit_hash, "--", relative_str])
.args([
"diff",
&format!("{}^", commit_hash),
commit_hash,
"--",
relative_str,
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {}", e))?;
@@ -356,11 +366,7 @@ mod tests {
.output()
.unwrap();
let history = get_file_history(
vault.to_str().unwrap(),
file.to_str().unwrap(),
)
.unwrap();
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].message, "Update test");
@@ -377,11 +383,7 @@ mod tests {
let file = vault.join("new.md");
fs::write(&file, "# New\n").unwrap();
let history = get_file_history(
vault.to_str().unwrap(),
file.to_str().unwrap(),
)
.unwrap();
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(history.is_empty());
}
@@ -436,11 +438,7 @@ mod tests {
fs::write(&file, "# Test\n\nModified content.").unwrap();
let diff = get_file_diff(
vault.to_str().unwrap(),
file.to_str().unwrap(),
)
.unwrap();
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
@@ -485,12 +483,8 @@ mod tests {
.unwrap();
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
let diff = get_file_diff_at_commit(
vault.to_str().unwrap(),
file.to_str().unwrap(),
&hash,
)
.unwrap();
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
@@ -522,12 +516,8 @@ mod tests {
.unwrap();
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
let diff = get_file_diff_at_commit(
vault.to_str().unwrap(),
file.to_str().unwrap(),
&hash,
)
.unwrap();
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("+# Initial"));

View File

@@ -125,7 +125,12 @@ pub async fn github_create_repo(
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
if dest.exists() && dest.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) {
if dest.exists()
&& dest
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false)
{
return Err(format!(
"Destination '{}' already exists and is not empty",
local_path
@@ -163,7 +168,10 @@ fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
// Handle URLs that already have a host
Ok(format!("https://oauth2:{}@{}", token, rest))
} else {
Err(format!("Unsupported URL format: {}. Use an HTTPS URL.", url))
Err(format!(
"Unsupported URL format: {}. Use an HTTPS URL.",
url
))
}
}
@@ -245,7 +253,11 @@ mod tests {
let path = dir.path();
std::fs::write(path.join("existing.txt"), "data").unwrap();
let result = clone_repo("https://github.com/test/repo.git", "token", path.to_str().unwrap());
let result = clone_repo(
"https://github.com/test/repo.git",
"token",
path.to_str().unwrap(),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not empty"));
}
@@ -255,7 +267,11 @@ mod tests {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("new-clone");
let result = clone_repo("git@github.com:user/repo.git", "token", dest.to_str().unwrap());
let result = clone_repo(
"git@github.com:user/repo.git",
"token",
dest.to_str().unwrap(),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported URL format"));
}
@@ -268,7 +284,11 @@ mod tests {
std::fs::create_dir(&dest).unwrap();
// This will fail at the git clone step (invalid URL) but should pass the directory check
let result = clone_repo("https://github.com/nonexistent/repo.git", "token", dest.to_str().unwrap());
let result = clone_repo(
"https://github.com/nonexistent/repo.git",
"token",
dest.to_str().unwrap(),
);
assert!(result.is_err());
// Should fail at git clone, not at directory check
assert!(result.unwrap_err().contains("git clone failed"));
@@ -280,9 +300,21 @@ mod tests {
let path = dir.path();
// Initialize a git repo
StdCommand::new("git").args(["init"]).current_dir(path).output().unwrap();
StdCommand::new("git").args(["remote", "add", "origin", "https://github.com/user/repo.git"])
.current_dir(path).output().unwrap();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/user/repo.git",
])
.current_dir(path)
.output()
.unwrap();
let result = configure_remote_auth(
path.to_str().unwrap(),

View File

@@ -7,11 +7,11 @@ pub mod settings;
pub mod vault;
use ai_chat::{AiChatRequest, AiChatResponse};
use frontmatter::FrontmatterValue;
use git::{GitCommit, ModifiedFile};
use github::GithubRepo;
use settings::Settings;
use vault::{VaultEntry, RenameResult};
use frontmatter::FrontmatterValue;
use vault::{RenameResult, VaultEntry};
#[tauri::command]
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
@@ -24,7 +24,11 @@ fn get_note_content(path: String) -> Result<String, String> {
}
#[tauri::command]
fn update_frontmatter(path: String, key: String, value: FrontmatterValue) -> Result<String, String> {
fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
vault::update_frontmatter(&path, &key, value)
}
@@ -49,7 +53,11 @@ fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
}
#[tauri::command]
fn get_file_diff_at_commit(vault_path: String, path: String, commit_hash: String) -> Result<String, String> {
fn get_file_diff_at_commit(
vault_path: String,
path: String,
commit_hash: String,
) -> Result<String, String> {
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
@@ -74,7 +82,11 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result<Stri
}
#[tauri::command]
fn rename_note(vault_path: String, old_path: String, new_title: String) -> Result<RenameResult, String> {
fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
) -> Result<RenameResult, String> {
vault::rename_note(&vault_path, &old_path, &new_title)
}
@@ -99,7 +111,11 @@ async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
}
#[tauri::command]
async fn github_create_repo(token: String, name: String, private: bool) -> Result<GithubRepo, String> {
async fn github_create_repo(
token: String,
name: String,
private: bool,
) -> Result<GithubRepo, String> {
github::github_create_repo(&token, &name, private).await
}
@@ -122,7 +138,8 @@ pub fn run() {
#[cfg(desktop)]
{
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
menu::setup_menu(app)?;
}

View File

@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
laputa_lib::run();
laputa_lib::run();
}

View File

@@ -1,4 +1,7 @@
use tauri::{menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}, App, Emitter};
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
App, Emitter,
};
const VIEW_ITEMS: [(&str, &str, &str); 3] = [
("view-editor-only", "Editor Only", "CmdOrCtrl+1"),
@@ -17,9 +20,7 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
}
let view_submenu = view_menu.build()?;
let menu = MenuBuilder::new(app)
.item(&view_submenu)
.build()?;
let menu = MenuBuilder::new(app).item(&view_submenu).build()?;
app.set_menu(menu)?;

View File

@@ -20,10 +20,9 @@ fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
if !path.exists() {
return Ok(Settings::default());
}
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse settings: {}", e))
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read settings: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e))
}
fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
@@ -34,16 +33,27 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
// Trim whitespace and convert empty strings to None
let cleaned = Settings {
anthropic_key: settings.anthropic_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
openai_key: settings.openai_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
google_key: settings.google_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
github_token: settings.github_token.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()),
anthropic_key: settings
.anthropic_key
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty()),
openai_key: settings
.openai_key
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty()),
google_key: settings
.google_key
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty()),
github_token: settings
.github_token
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty()),
};
let json = serde_json::to_string_pretty(&cleaned)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
fs::write(path, json)
.map_err(|e| format!("Failed to write settings: {}", e))
fs::write(path, json).map_err(|e| format!("Failed to write settings: {}", e))
}
pub fn get_settings() -> Result<Settings, String> {
@@ -71,7 +81,15 @@ mod tests {
let s = Settings::default();
assert_eq!(
format!("{:?}", s),
format!("{:?}", Settings { anthropic_key: None, openai_key: None, google_key: None, github_token: None })
format!(
"{:?}",
Settings {
anthropic_key: None,
openai_key: None,
google_key: None,
github_token: None
}
)
);
}
@@ -140,9 +158,19 @@ mod tests {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("dir").join("settings.json");
save_settings_at(&path, Settings { anthropic_key: Some("key".to_string()), ..Default::default() }).unwrap();
save_settings_at(
&path,
Settings {
anthropic_key: Some("key".to_string()),
..Default::default()
},
)
.unwrap();
assert!(path.exists());
assert_eq!(get_settings_at(&path).unwrap().anthropic_key.as_deref(), Some("key"));
assert_eq!(
get_settings_at(&path).unwrap().anthropic_key.as_deref(),
Some("key")
);
}
#[test]

View File

@@ -8,7 +8,7 @@ use std::path::Path;
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
use crate::frontmatter::{with_frontmatter, update_frontmatter_content};
use crate::frontmatter::{update_frontmatter_content, with_frontmatter};
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct VaultEntry {
@@ -178,7 +178,9 @@ fn without_h1_line(s: &str) -> Option<&str> {
fn collect_until(chars: &mut impl Iterator<Item = char>, delimiter: char) -> String {
let mut buf = String::new();
for c in chars.by_ref() {
if c == delimiter { break; }
if c == delimiter {
break;
}
buf.push(c);
}
buf
@@ -187,7 +189,9 @@ fn collect_until(chars: &mut impl Iterator<Item = char>, delimiter: char) -> Str
/// Skip all chars until a delimiter (consuming the delimiter).
fn skip_until(chars: &mut impl Iterator<Item = char>, delimiter: char) {
for c in chars.by_ref() {
if c == delimiter { break; }
if c == delimiter {
break;
}
}
}
@@ -219,19 +223,26 @@ fn strip_markdown_chars(s: &str) -> String {
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
// Convert HashMap to serde_json::Value for deserialization
let value = serde_json::Value::Object(
data.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
let value =
serde_json::Value::Object(data.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
serde_json::from_value(value).unwrap_or_default()
}
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a", "aliases", "status", "cadence", "archived", "trashed", "trashed at",
"created at", "created time", "icon", "color", "order",
"is a",
"aliases",
"status",
"cadence",
"archived",
"trashed",
"trashed at",
"created at",
"created time",
"icon",
"color",
"order",
];
/// Check if a string contains a wikilink pattern `[[...]]`.
@@ -243,7 +254,9 @@ fn contains_wikilink(s: &str) -> bool {
/// Returns a HashMap where each key is the original frontmatter field name
/// and the value is a Vec of wikilink strings found in that field.
/// Handles both single string values and arrays of strings.
fn extract_relationships(data: &HashMap<String, serde_json::Value>) -> HashMap<String, Vec<String>> {
fn extract_relationships(
data: &HashMap<String, serde_json::Value>,
) -> HashMap<String, Vec<String>> {
let mut relationships = HashMap::new();
for (key, value) in data {
@@ -296,7 +309,8 @@ fn infer_type_from_folder(folder: &str) -> String {
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return capitalize_first(folder),
}.to_string()
}
.to_string()
}
/// Resolve `is_a` from frontmatter, falling back to parent folder inference.
@@ -312,28 +326,35 @@ fn resolve_is_a(fm_is_a: Option<StringOrList>, path: &Path) -> Option<String> {
/// Parse created_at from frontmatter (prefer "Created at" over "Created time").
fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
fm.created_at.as_ref().and_then(|s| parse_iso_date(s))
fm.created_at
.as_ref()
.and_then(|s| parse_iso_date(s))
.or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s)))
}
/// Extract frontmatter and relationships from parsed gray_matter data.
fn extract_fm_and_rels(data: Option<gray_matter::Pod>) -> (Frontmatter, HashMap<String, Vec<String>>) {
fn extract_fm_and_rels(
data: Option<gray_matter::Pod>,
) -> (Frontmatter, HashMap<String, Vec<String>>) {
let hash = match data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return (Frontmatter::default(), HashMap::new()),
};
let json_map: HashMap<String, serde_json::Value> = hash
.into_iter()
.map(|(k, v)| (k, pod_to_json(v)))
.collect();
(parse_frontmatter(&json_map), extract_relationships(&json_map))
let json_map: HashMap<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
(
parse_frontmatter(&json_map),
extract_relationships(&json_map),
)
}
/// Read file metadata (modified_at timestamp, file size).
fn read_file_metadata(path: &Path) -> Result<(Option<u64>, u64), String> {
let metadata = fs::metadata(path)
.map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
let modified_at = metadata.modified().ok()
let metadata =
fs::metadata(path).map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Ok((modified_at, metadata.len()))
@@ -343,7 +364,8 @@ fn read_file_metadata(path: &Path) -> Result<(Option<u64>, u64), String> {
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let filename = path.file_name()
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
@@ -373,17 +395,32 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
Ok(VaultEntry {
path: path.to_string_lossy().to_string(),
filename, title, is_a, snippet, relationships,
aliases: frontmatter.aliases.map(|a| a.into_vec()).unwrap_or_default(),
belongs_to: frontmatter.belongs_to.map(|b| b.into_vec()).unwrap_or_default(),
related_to: frontmatter.related_to.map(|r| r.into_vec()).unwrap_or_default(),
filename,
title,
is_a,
snippet,
relationships,
aliases: frontmatter
.aliases
.map(|a| a.into_vec())
.unwrap_or_default(),
belongs_to: frontmatter
.belongs_to
.map(|b| b.into_vec())
.unwrap_or_default(),
related_to: frontmatter
.related_to
.map(|r| r.into_vec())
.unwrap_or_default(),
status: frontmatter.status,
owner: frontmatter.owner,
cadence: frontmatter.cadence,
archived: frontmatter.archived.unwrap_or(false),
trashed: frontmatter.trashed.unwrap_or(false),
trashed_at: frontmatter.trashed_at.as_deref().and_then(parse_iso_date),
modified_at, created_at, file_size,
modified_at,
created_at,
file_size,
icon: frontmatter.icon,
color: frontmatter.color,
order: frontmatter.order,
@@ -435,10 +472,8 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> = map
.into_iter()
.map(|(k, v)| (k, pod_to_json(v)))
.collect();
let obj: serde_json::Map<String, serde_json::Value> =
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
@@ -469,12 +504,7 @@ pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file()
|| path
.extension()
.map(|ext| ext != "md")
.unwrap_or(true)
{
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
@@ -528,8 +558,7 @@ pub fn get_note_content(path: &str) -> Result<String, String> {
if !file_path.is_file() {
return Err(format!("Path is not a file: {}", path));
}
fs::read_to_string(file_path)
.map_err(|e| format!("Failed to read {}: {}", path, e))
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))
}
/// Scan a directory recursively for .md files and return VaultEntry for each.
@@ -601,7 +630,9 @@ fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
/// Parse a git status porcelain line into (status_code, file_path).
fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
if line.len() < 3 { return None; }
if line.len() < 3 {
return None;
}
Some((&line[..2], line[3..].trim().to_string()))
}
@@ -612,7 +643,8 @@ fn is_new_file_status(status: &str) -> bool {
/// Extract .md file paths from git diff --name-only output.
fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
stdout.lines()
stdout
.lines()
.filter(|line| !line.is_empty() && line.ends_with(".md"))
.map(|line| line.to_string())
.collect()
@@ -620,7 +652,8 @@ fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
/// Extract .md file paths from git status --porcelain output.
fn collect_md_paths_from_porcelain(stdout: &str) -> Vec<String> {
stdout.lines()
stdout
.lines()
.filter_map(parse_porcelain_line)
.filter(|(_, path)| path.ends_with(".md"))
.map(|(_, path)| path)
@@ -651,7 +684,8 @@ fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
Some(s) => s,
None => return Vec::new(),
};
stdout.lines()
stdout
.lines()
.filter_map(parse_porcelain_line)
.filter(|(status, path)| path.ends_with(".md") && is_new_file_status(status))
.map(|(_, path)| path)
@@ -673,7 +707,8 @@ fn write_cache(vault: &Path, cache: &VaultCache) {
fn to_relative_path(abs_path: &str, vault: &Path) -> String {
let vault_str = vault.to_string_lossy();
let with_slash = format!("{}/", vault_str);
abs_path.strip_prefix(&with_slash)
abs_path
.strip_prefix(&with_slash)
.or_else(|| abs_path.strip_prefix(vault_str.as_ref()))
.unwrap_or(abs_path)
.to_string()
@@ -681,10 +716,15 @@ fn to_relative_path(abs_path: &str, vault: &Path) -> String {
/// Parse .md files from a list of relative paths, skipping any that don't exist.
fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
rel_paths.iter()
rel_paths
.iter()
.filter_map(|rel| {
let abs = vault.join(rel);
if abs.is_file() { parse_md_file(&abs).ok() } else { None }
if abs.is_file() {
parse_md_file(&abs).ok()
} else {
None
}
})
.collect()
}
@@ -692,7 +732,13 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
/// Sort entries by modified_at descending and write the cache.
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
write_cache(vault, &VaultCache { commit_hash: hash, entries: entries.clone() });
write_cache(
vault,
&VaultCache {
commit_hash: hash,
entries: entries.clone(),
},
);
entries
}
@@ -700,7 +746,8 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
let new_files = git_uncommitted_new_files(vault);
let mut entries = cache.entries;
let existing: std::collections::HashSet<String> = entries.iter()
let existing: std::collections::HashSet<String> = entries
.iter()
.map(|e| to_relative_path(&e.path, vault))
.collect();
@@ -716,11 +763,17 @@ fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
}
/// Handle different-commit cache: incremental update via git diff.
fn update_different_commit(vault: &Path, cache: VaultCache, current_hash: String) -> Vec<VaultEntry> {
fn update_different_commit(
vault: &Path,
cache: VaultCache,
current_hash: String,
) -> Vec<VaultEntry> {
let changed_files = git_changed_files(vault, &cache.commit_hash, &current_hash);
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
let mut entries: Vec<VaultEntry> = cache.entries.into_iter()
let mut entries: Vec<VaultEntry> = cache
.entries
.into_iter()
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
.collect();
entries.extend(parse_files_at(vault, &changed_files));
@@ -733,7 +786,10 @@ fn update_different_commit(vault: &Path, cache: VaultCache, current_hash: String
pub fn scan_vault_cached(vault_path: &str) -> Result<Vec<VaultEntry>, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!("Vault path does not exist or is not a directory: {}", vault_path));
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let current_hash = match git_head_hash(vault) {
@@ -758,13 +814,21 @@ pub fn scan_vault_cached(vault_path: &str) -> Result<Vec<VaultEntry>, String> {
pub use crate::frontmatter::FrontmatterValue;
/// 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())))
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))
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, None)
})
}
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
@@ -802,8 +866,7 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String
.decode(data)
.map_err(|e| format!("Invalid base64 data: {}", e))?;
fs::write(&target_path, bytes)
.map_err(|e| format!("Failed to write image: {}", e))?;
fs::write(&target_path, bytes).map_err(|e| format!("Failed to write image: {}", e))?;
Ok(target_path.to_string_lossy().to_string())
}
@@ -837,9 +900,16 @@ fn update_h1_title(content: &str, new_title: &str) -> String {
return content.to_string();
}
let result: Vec<String> = content.lines().map(|l| {
if l.trim().starts_with("# ") { format!("# {}", new_title) } else { l.to_string() }
}).collect();
let result: Vec<String> = content
.lines()
.map(|l| {
if l.trim().starts_with("# ") {
format!("# {}", new_title)
} else {
l.to_string()
}
})
.collect();
let joined = result.join("\n");
if content.ends_with('\n') && !joined.ends_with('\n') {
@@ -861,9 +931,7 @@ fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex>
/// Check if a path is a vault markdown file eligible for wikilink replacement.
fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
path.is_file()
&& path != exclude
&& path.extension().is_some_and(|ext| ext == "md")
path.is_file() && path != exclude && path.extension().is_some_and(|ext| ext == "md")
}
/// Replace wikilink references in a single file's content. Returns updated content if changed.
@@ -871,13 +939,15 @@ fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> O
if !re.is_match(content) {
return None;
}
let replaced = re.replace_all(content, |caps: &regex::Captures| {
match caps.get(1) {
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
None => format!("[[{}]]", new_title),
}
let replaced = re.replace_all(content, |caps: &regex::Captures| match caps.get(1) {
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
None => format!("[[{}]]", new_title),
});
if replaced != content { Some(replaced.into_owned()) } else { None }
if replaced != content {
Some(replaced.into_owned())
} else {
None
}
}
/// Parameters for a vault-wide wikilink replacement.
@@ -908,16 +978,19 @@ fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
};
let files = collect_md_files(params.vault_path, params.exclude_path);
files.iter().filter(|path| {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
match replace_wikilinks_in_content(&content, &re, params.new_title) {
Some(new_content) => fs::write(path, &new_content).is_ok(),
None => false,
}
}).count()
files
.iter()
.filter(|path| {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
match replace_wikilinks_in_content(&content, &re, params.new_title) {
Some(new_content) => fs::write(path, &new_content).is_ok(),
None => false,
}
})
.count()
}
/// Check if frontmatter contains a `title:` key.
@@ -925,11 +998,15 @@ fn frontmatter_has_title_key(content: &str) -> bool {
if !content.starts_with("---\n") {
return false;
}
content[4..].split("\n---").next()
.map(|fm| fm.lines().any(|l| {
let t = l.trim_start();
t.starts_with("title:") || t.starts_with("\"title\":")
}))
content[4..]
.split("\n---")
.next()
.map(|fm| {
fm.lines().any(|l| {
let t = l.trim_start();
t.starts_with("title:") || t.starts_with("\"title\":")
})
})
.unwrap_or(false)
}
@@ -955,7 +1032,11 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
}
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result<RenameResult, String> {
pub fn rename_note(
vault_path: &str,
old_path: &str,
new_title: &str,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
@@ -967,21 +1048,28 @@ pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result<
return Err("New title cannot be empty".to_string());
}
let content = fs::read_to_string(old_file)
.map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let old_filename = old_file.file_name()
.map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let old_title = extract_title(&content, &old_filename);
if old_title == new_title {
return Ok(RenameResult { new_path: old_path.to_string(), updated_files: 0 });
return Ok(RenameResult {
new_path: old_path.to_string(),
updated_files: 0,
});
}
// Update content (H1 + frontmatter title)
let updated_content = update_note_title_in_content(&content, new_title);
// Compute new path and write file
let parent_dir = old_file.parent().ok_or("Cannot determine parent directory")?;
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
let new_path_str = new_file.to_string_lossy().to_string();
@@ -996,10 +1084,17 @@ pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result<
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault, old_title: &old_title, new_title, old_path_stem, exclude_path: &new_file,
vault_path: vault,
old_title: &old_title,
new_title,
old_path_stem,
exclude_path: &new_file,
});
Ok(RenameResult { new_path: new_path_str, updated_files })
Ok(RenameResult {
new_path: new_path_str,
updated_files,
})
}
#[cfg(test)]
@@ -1027,7 +1122,10 @@ mod tests {
#[test]
fn test_extract_title_fallback_to_filename() {
let content = "Just some content without a heading.";
assert_eq!(extract_title(content, "fallback-title.md"), "fallback-title");
assert_eq!(
extract_title(content, "fallback-title.md"),
"fallback-title"
);
}
#[test]
@@ -1073,7 +1171,11 @@ mod tests {
#[test]
fn test_parse_empty_frontmatter() {
let dir = TempDir::new().unwrap();
let entry = parse_test_entry(&dir, "empty-fm.md", "---\n---\n# Just a Title\n\nNo frontmatter fields.");
let entry = parse_test_entry(
&dir,
"empty-fm.md",
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
);
assert_eq!(entry.title, "Just a Title");
assert!(entry.aliases.is_empty());
@@ -1106,7 +1208,11 @@ mod tests {
fn test_scan_vault_recursive() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "root.md", "# Root Note\n");
create_test_file(dir.path(), "sub/nested.md", "---\nIs A: Task\n---\n# Nested\n");
create_test_file(
dir.path(),
"sub/nested.md",
"---\nIs A: Task\n---\n# Nested\n",
);
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
let entries = scan_vault(dir.path().to_str().unwrap()).unwrap();
@@ -1219,13 +1325,33 @@ mod tests {
let vault = dir.path();
// Init git repo
std::process::Command::new("git").args(["init"]).current_dir(vault).output().unwrap();
std::process::Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(vault).output().unwrap();
std::process::Command::new("git").args(["config", "user.name", "Test"]).current_dir(vault).output().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "note.md", "# Note\n\nFirst version.");
std::process::Command::new("git").args(["add", "."]).current_dir(vault).output().unwrap();
std::process::Command::new("git").args(["commit", "-m", "init"]).current_dir(vault).output().unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// First call: full scan, writes cache
let entries = scan_vault_cached(vault.to_str().unwrap()).unwrap();
@@ -1259,7 +1385,10 @@ Status: Active
assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type
assert_eq!(
entry.relationships.get("Has").unwrap(),
&vec!["[[essay/foo|Foo Essay]]".to_string(), "[[essay/bar|Bar Essay]]".to_string()]
&vec![
"[[essay/foo|Foo Essay]]".to_string(),
"[[essay/bar|Bar Essay]]".to_string()
]
);
assert_eq!(
entry.relationships.get("Topics").unwrap(),
@@ -1343,7 +1472,10 @@ Custom Field: just a plain string
fn test_parse_relationships_owner_and_notes() {
let rels = parse_big_project_rels();
assert_eq!(rels.get("Notes").unwrap().len(), 3);
assert_eq!(rels.get("Owner").unwrap(), &vec!["[[person/alice]]".to_string()]);
assert_eq!(
rels.get("Owner").unwrap(),
&vec!["[[person/alice]]".to_string()]
);
}
#[test]
@@ -1385,7 +1517,10 @@ Context: "[[area/research]]"
// Array → Vec with multiple elements
assert_eq!(
entry.relationships.get("Reviewers").unwrap(),
&vec!["[[person/carol]]".to_string(), "[[person/dave]]".to_string()]
&vec![
"[[person/carol]]".to_string(),
"[[person/dave]]".to_string()
]
);
// Another single string
assert_eq!(
@@ -1422,7 +1557,10 @@ Context: "[[area/research]]"
#[test]
fn test_skip_keys_real_relation_included() {
let (rels, len) = parse_skip_keys_rels();
assert_eq!(rels.get("Real Relation").unwrap(), &vec!["[[note/important]]".to_string()]);
assert_eq!(
rels.get("Real Relation").unwrap(),
&vec!["[[note/important]]".to_string()]
);
// "Real Relation" + auto-generated "Type" (from is_a: "[[type/project]]")
assert_eq!(len, 2);
assert_eq!(
@@ -1451,7 +1589,10 @@ References:
// Only the wikilink entries should be captured
assert_eq!(
entry.relationships.get("References").unwrap(),
&vec!["[[source/paper-a]]".to_string(), "[[source/paper-b]]".to_string()]
&vec![
"[[source/paper-a]]".to_string(),
"[[source/paper-b]]".to_string()
]
);
}
@@ -1508,7 +1649,10 @@ References:
#[test]
fn test_strip_markdown_chars_emphasis() {
assert_eq!(strip_markdown_chars("**bold** and *italic*"), "bold and italic");
assert_eq!(
strip_markdown_chars("**bold** and *italic*"),
"bold and italic"
);
}
#[test]
@@ -1523,7 +1667,10 @@ References:
#[test]
fn test_strip_markdown_chars_link_with_url() {
assert_eq!(strip_markdown_chars("[click here](https://example.com)"), "click here");
assert_eq!(
strip_markdown_chars("[click here](https://example.com)"),
"click here"
);
}
#[test]
@@ -1591,7 +1738,13 @@ References:
for (folder, expected_type) in known_folders {
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
assert_eq!(entry.is_a, Some(expected_type.to_string()), "folder '{}' should infer type '{}'", folder, expected_type);
assert_eq!(
entry.is_a,
Some(expected_type.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected_type
);
}
}
@@ -1606,7 +1759,11 @@ References:
#[test]
fn test_infer_type_frontmatter_overrides_folder() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "person/test.md", "---\nIs A: Custom\n---\n# Test\n");
create_test_file(
dir.path(),
"person/test.md",
"---\nIs A: Custom\n---\n# Test\n",
);
let entry = parse_md_file(&dir.path().join("person/test.md")).unwrap();
assert_eq!(entry.is_a, Some("Custom".to_string()));
}
@@ -1715,13 +1872,33 @@ References:
let vault = dir.path();
// Init git repo
std::process::Command::new("git").args(["init"]).current_dir(vault).output().unwrap();
std::process::Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(vault).output().unwrap();
std::process::Command::new("git").args(["config", "user.name", "Test"]).current_dir(vault).output().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "first.md", "# First\n\nFirst note.");
std::process::Command::new("git").args(["add", "."]).current_dir(vault).output().unwrap();
std::process::Command::new("git").args(["commit", "-m", "first"]).current_dir(vault).output().unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "first"])
.current_dir(vault)
.output()
.unwrap();
// Build cache
let entries = scan_vault_cached(vault.to_str().unwrap()).unwrap();
@@ -1729,8 +1906,16 @@ References:
// Add a second file and commit
create_test_file(vault, "second.md", "# Second\n\nSecond note.");
std::process::Command::new("git").args(["add", "."]).current_dir(vault).output().unwrap();
std::process::Command::new("git").args(["commit", "-m", "second"]).current_dir(vault).output().unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "second"])
.current_dir(vault)
.output()
.unwrap();
// Incremental update: cache has old commit, new commit adds second.md
let entries2 = scan_vault_cached(vault.to_str().unwrap()).unwrap();
@@ -1886,7 +2071,10 @@ References:
"---\nTrashed at: \"2025-01-01\"\n---\n# Old Trash\n",
);
// File trashed recently — should be kept
let recent = chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string();
let recent = chrono::Utc::now()
.date_naive()
.format("%Y-%m-%d")
.to_string();
create_test_file(
dir.path(),
"recent-trash.md",
@@ -1939,14 +2127,16 @@ References:
#[test]
fn test_purge_trash_exactly_30_days_not_deleted() {
let dir = TempDir::new().unwrap();
let thirty_days_ago = (chrono::Utc::now().date_naive()
- chrono::Duration::days(30))
let thirty_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(30))
.format("%Y-%m-%d")
.to_string();
create_test_file(
dir.path(),
"borderline.md",
&format!("---\nTrashed at: \"{}\"\n---\n# Borderline\n", thirty_days_ago),
&format!(
"---\nTrashed at: \"{}\"\n---\n# Borderline\n",
thirty_days_ago
),
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
@@ -1957,14 +2147,16 @@ References:
#[test]
fn test_purge_trash_31_days_deleted() {
let dir = TempDir::new().unwrap();
let thirty_one_days_ago = (chrono::Utc::now().date_naive()
- chrono::Duration::days(31))
let thirty_one_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(31))
.format("%Y-%m-%d")
.to_string();
create_test_file(
dir.path(),
"expired.md",
&format!("---\nTrashed at: \"{}\"\n---\n# Expired\n", thirty_one_days_ago),
&format!(
"---\nTrashed at: \"{}\"\n---\n# Expired\n",
thirty_one_days_ago
),
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
@@ -2008,14 +2200,19 @@ References:
fn test_rename_note_basic() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n");
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
).unwrap();
)
.unwrap();
assert!(result.new_path.ends_with("sprint-retrospective.md"));
assert!(!old_path.exists());
@@ -2029,16 +2226,29 @@ References:
fn test_rename_note_updates_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n");
create_test_file(vault, "note/other.md", "---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n");
create_test_file(vault, "project/my-project.md", "---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n");
create_test_file(
vault,
"note/weekly-review.md",
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
);
create_test_file(
vault,
"note/other.md",
"---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
);
create_test_file(
vault,
"project/my-project.md",
"---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
).unwrap();
)
.unwrap();
assert_eq!(result.updated_files, 2);
@@ -2061,7 +2271,8 @@ References:
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
).unwrap();
)
.unwrap();
assert_eq!(result.new_path, old_path.to_str().unwrap());
assert_eq!(result.updated_files, 0);
@@ -2074,11 +2285,7 @@ References:
create_test_file(vault, "note/test.md", "# Test\n");
let old_path = vault.join("note/test.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
" ",
);
let result = rename_note(vault.to_str().unwrap(), old_path.to_str().unwrap(), " ");
assert!(result.is_err());
}
@@ -2087,14 +2294,19 @@ References:
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[Weekly Review|my review]] for info.\n");
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[Weekly Review|my review]] for info.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retro",
).unwrap();
)
.unwrap();
assert_eq!(result.updated_files, 1);
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
@@ -2105,14 +2317,19 @@ References:
fn test_rename_note_updates_title_frontmatter() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/old.md", "---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n");
create_test_file(
vault,
"note/old.md",
"---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n",
);
let old_path = vault.join("note/old.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"New Name",
).unwrap();
)
.unwrap();
let content = fs::read_to_string(&result.new_path).unwrap();
assert!(content.contains("title: New Name"));