feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

View file

@ -0,0 +1,472 @@
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
#[cfg(desktop)]
use crate::ai_models::{AiModelProviderTestRequest, AiModelStreamRequest};
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
use crate::vault::WorkspaceAiGuidanceStatus;
use super::expand_tilde;
#[cfg(desktop)]
type StreamEmitter<Event> = Box<dyn Fn(Event) + Send>;
#[cfg(desktop)]
const AGENT_DOCS_RESOURCE_DIR: &str = "agent-docs";
#[cfg(desktop)]
struct DesktopStreamScope {
event_name: String,
stream_id: Option<String>,
}
#[cfg(desktop)]
impl DesktopStreamScope {
fn shared(event_name: impl Into<String>) -> Self {
Self {
event_name: event_name.into(),
stream_id: None,
}
}
fn cancellable(event_name: impl Into<String>) -> Self {
let event_name = event_name.into();
Self {
stream_id: Some(event_name.clone()),
event_name,
}
}
}
#[cfg(desktop)]
async fn run_desktop_stream<Event, Request, Runner>(
app_handle: tauri::AppHandle,
scope: DesktopStreamScope,
request: Request,
runner: Runner,
) -> Result<String, String>
where
Event: serde::Serialize + Send + 'static,
Request: Send + 'static,
Runner: FnOnce(Request, StreamEmitter<Event>) -> Result<String, String> + Send + 'static,
{
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
let DesktopStreamScope {
event_name,
stream_id,
} = scope;
let run = || {
runner(
request,
Box::new(move |event| {
let _ = app_handle.emit(event_name.as_str(), &event);
}),
)
};
match stream_id {
Some(stream_id) => crate::ai_agent_processes::with_stream_id(stream_id, run),
None => run(),
}
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[cfg(desktop)]
macro_rules! define_desktop_stream_command {
($name:ident, $request:ty, $event_name:literal, $runner:path) => {
#[tauri::command]
pub async fn $name(
app_handle: tauri::AppHandle,
request: $request,
) -> Result<String, String> {
run_desktop_stream(
app_handle,
DesktopStreamScope::shared($event_name),
request,
$runner,
)
.await
}
};
}
#[cfg(desktop)]
fn is_scoped_stream_event_name(default_event_name: &str, event_name: &str) -> bool {
event_name
.strip_prefix(default_event_name)
.and_then(|suffix| suffix.strip_prefix('-'))
.is_some_and(|suffix| {
!suffix.is_empty()
&& suffix
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
})
}
#[cfg(desktop)]
fn stream_event_name(default_event_name: &'static str, requested: Option<&str>) -> String {
requested
.filter(|event_name| is_scoped_stream_event_name(default_event_name, event_name))
.unwrap_or(default_event_name)
.to_string()
}
// ── Claude CLI commands (desktop) ───────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub fn check_claude_cli() -> ClaudeCliStatus {
crate::claude_cli::check_cli()
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status().await
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_agent_docs_path(app_handle: tauri::AppHandle) -> Result<String, String> {
use std::path::PathBuf;
use tauri::path::BaseDirectory;
use tauri::Manager;
let mut candidates = Vec::new();
if let Ok(resource_path) = app_handle
.path()
.resolve(AGENT_DOCS_RESOURCE_DIR, BaseDirectory::Resource)
{
candidates.push(resource_path);
}
candidates.push(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join(AGENT_DOCS_RESOURCE_DIR),
);
candidates
.into_iter()
.find(|path| path.join("index.md").is_file())
.map(|path| path.to_string_lossy().into_owned())
.ok_or_else(|| "Tolaria agent docs are not bundled in this build.".to_string())
}
#[tauri::command]
pub fn get_vault_ai_guidance_status(
vault_path: String,
) -> Result<WorkspaceAiGuidanceStatus, String> {
let vault_path = expand_tilde(&vault_path);
crate::vault::get_ai_guidance_status(vault_path.as_ref())
}
#[tauri::command]
pub fn restore_vault_ai_guidance(vault_path: String) -> Result<WorkspaceAiGuidanceStatus, String> {
let vault_path = expand_tilde(&vault_path);
crate::vault::restore_ai_guidance_files(vault_path.as_ref())
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_claude_chat,
ChatStreamRequest,
"claude-stream",
crate::claude_cli::run_chat_stream
);
#[cfg(desktop)]
fn normalize_agent_request(mut request: AiAgentStreamRequest) -> AiAgentStreamRequest {
request.vault_path = expand_tilde(&request.vault_path).into_owned();
request.vault_paths = request
.vault_paths
.into_iter()
.map(|path| expand_tilde(&path).into_owned())
.collect();
request
}
#[cfg(desktop)]
fn run_normalized_ai_agent_stream(
request: AiAgentStreamRequest,
emitter: StreamEmitter<crate::ai_agents::AiAgentStreamEvent>,
) -> Result<String, String> {
crate::ai_agents::run_ai_agent_stream(normalize_agent_request(request), emitter)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn stream_ai_agent(
app_handle: tauri::AppHandle,
request: AiAgentStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-agent-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
DesktopStreamScope::cancellable(event_name),
request,
run_normalized_ai_agent_stream,
)
.await
}
#[cfg(desktop)]
#[tauri::command]
pub fn abort_ai_agent_stream(event_name: String) -> Result<bool, String> {
if !is_scoped_stream_event_name("ai-agent-stream", &event_name) {
return Err("Invalid AI agent stream id".into());
}
crate::ai_agent_processes::abort_stream(&event_name)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn stream_ai_model(
app_handle: tauri::AppHandle,
request: AiModelStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-model-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
DesktopStreamScope::shared(event_name),
request,
crate::ai_models::run_ai_model_stream,
)
.await
}
#[cfg(desktop)]
#[tauri::command]
pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
crate::ai_models::save_provider_api_key(provider_id, api_key)
}
#[cfg(desktop)]
#[tauri::command]
pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> {
crate::ai_models::delete_provider_api_key(provider_id)
}
#[cfg(desktop)]
#[tauri::command]
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
crate::ai_models::test_ai_model_provider(request)
}
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub fn check_claude_cli() -> ClaudeCliStatus {
ClaudeCliStatus {
installed: false,
version: None,
}
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_ai_agents_status() -> AiAgentsStatus {
AiAgentsStatus {
claude_code: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
codex: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
copilot: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
opencode: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
pi: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
antigravity: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
kiro: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
hermes: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_agent_docs_path() -> Result<String, String> {
Err("Bundled agent docs are only available in the desktop app.".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_claude_chat(
_app_handle: tauri::AppHandle,
_request: ChatStreamRequest,
) -> Result<String, String> {
Err("Claude CLI is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_ai_agent(
_app_handle: tauri::AppHandle,
_request: AiAgentStreamRequest,
) -> Result<String, String> {
Err("CLI AI agents are not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn abort_ai_agent_stream(_event_name: String) -> Result<bool, String> {
Err("CLI AI agents are not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_ai_model(
_app_handle: tauri::AppHandle,
_request: crate::ai_models::AiModelStreamRequest,
) -> Result<String, String> {
Err("Direct AI model chat is not available in this mobile build yet.".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn save_ai_model_provider_api_key(
_provider_id: String,
_api_key: String,
) -> Result<(), String> {
Err("Local AI provider secret storage is only available in the desktop app.".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> {
Err("Local AI provider secret storage is only available in the desktop app.".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn test_ai_model_provider(
_request: crate::ai_models::AiModelProviderTestRequest,
) -> Result<String, String> {
Err("Direct AI model tests are not available in this mobile build yet.".into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::AiGuidanceFileState;
#[cfg(desktop)]
#[test]
fn normalize_agent_request_expands_tilde_in_vault_path() {
use crate::ai_agents::AiAgentId;
let home = dirs::home_dir().unwrap();
let request = AiAgentStreamRequest {
agent: AiAgentId::ClaudeCode,
message: "hi".into(),
system_prompt: None,
vault_path: "~/Vaults/content".into(),
vault_paths: vec!["~/Vaults/secondary".into()],
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
assert_eq!(
normalized.vault_path,
format!("{}/Vaults/content", home.display()),
"vault_path must be tilde-expanded so spawned agents can chdir into it",
);
assert_eq!(
normalized.vault_paths,
vec![format!("{}/Vaults/secondary", home.display())],
"vault_paths must be tilde-expanded so spawned agents can access every active vault",
);
}
#[cfg(desktop)]
#[test]
fn normalize_agent_request_leaves_absolute_vault_path_untouched() {
use crate::ai_agents::AiAgentId;
let request = AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "hi".into(),
system_prompt: None,
vault_path: "/Users/example/vault".into(),
vault_paths: Vec::new(),
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
assert_eq!(normalized.vault_path, "/Users/example/vault");
}
#[cfg(desktop)]
#[test]
fn stream_event_name_accepts_only_scoped_names() {
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream-chat-123")),
"ai-agent-stream-chat-123",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-model-stream-chat-123")),
"ai-agent-stream",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream/../bad")),
"ai-agent-stream",
);
}
#[cfg(desktop)]
#[test]
fn abort_ai_agent_stream_rejects_unscoped_names() {
let result = abort_ai_agent_stream("ai-model-stream-chat-123".into());
assert!(matches!(result, Err(message) if message.contains("Invalid AI agent stream id")));
}
#[test]
fn guidance_commands_report_and_restore_vault_guidance_files() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_string_lossy().to_string();
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
assert!(initial.can_restore);
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed);
assert!(!restored.can_restore);
assert!(dir.path().join("AGENTS.md").exists());
assert!(dir.path().join("CLAUDE.md").exists());
assert!(dir.path().join("GEMINI.md").exists());
}
}

View file

@ -0,0 +1,11 @@
#[cfg(desktop)]
#[tauri::command]
pub fn update_app_icon(app_handle: tauri::AppHandle, theme_mode: String) -> Result<(), String> {
crate::app_icon::update_app_icon_for_theme(&app_handle, &theme_mode)
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_app_icon(_theme_mode: String) -> Result<(), String> {
Ok(())
}

View file

@ -0,0 +1,207 @@
#[cfg(desktop)]
use std::io::Write;
#[cfg(desktop)]
use std::process::{Child, Command, Output, Stdio};
#[cfg(desktop)]
use std::thread;
#[cfg(desktop)]
use std::time::{Duration, Instant};
#[cfg(desktop)]
const NATIVE_CLIPBOARD_COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(desktop)]
const NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
#[cfg(target_os = "macos")]
fn clipboard_command() -> Command {
crate::hidden_command("pbcopy")
}
#[cfg(target_os = "macos")]
fn clipboard_read_command() -> Command {
crate::hidden_command("pbpaste")
}
#[cfg(target_os = "windows")]
fn clipboard_command() -> Command {
crate::hidden_command("clip.exe")
}
#[cfg(target_os = "windows")]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("powershell.exe");
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_command() -> Command {
let mut command = crate::hidden_command("sh");
command.args([
"-c",
"if command -v wl-copy >/dev/null 2>&1; then wl-copy; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --input; else exit 127; fi",
]);
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("sh");
command.args([
"-c",
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
]);
command
}
#[cfg(desktop)]
fn clipboard_failure_message(stderr: &[u8]) -> String {
let message = String::from_utf8_lossy(stderr).trim().to_string();
if message.is_empty() {
"Native clipboard command failed".to_string()
} else {
format!("Native clipboard command failed: {message}")
}
}
#[cfg(desktop)]
fn clipboard_timeout_message(timeout: Duration) -> String {
format!(
"Native clipboard command timed out after {}ms",
timeout.as_millis()
)
}
#[cfg(desktop)]
fn wait_for_native_clipboard_output(mut child: Child, timeout: Duration) -> Result<Output, String> {
let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_status)) => {
return child
.wait_with_output()
.map_err(|e| format!("Native clipboard command did not finish: {e}"));
}
Ok(None) if started.elapsed() >= timeout => {
let _ = child.kill();
let _ = child.wait();
return Err(clipboard_timeout_message(timeout));
}
Ok(None) => thread::sleep(NATIVE_CLIPBOARD_COMMAND_POLL_INTERVAL),
Err(e) => return Err(format!("Native clipboard command did not finish: {e}")),
}
}
}
#[cfg(desktop)]
fn write_native_clipboard_with_timeout(
mut command: Command,
text: &str,
timeout: Duration,
) -> Result<(), String> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to open native clipboard command: {e}"))?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Native clipboard command did not expose stdin".to_string())?;
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Failed to write native clipboard text: {e}"))?;
drop(stdin);
let output = wait_for_native_clipboard_output(child, timeout)?;
if output.status.success() {
Ok(())
} else {
Err(clipboard_failure_message(&output.stderr))
}
}
#[cfg(desktop)]
fn write_native_clipboard(command: Command, text: &str) -> Result<(), String> {
write_native_clipboard_with_timeout(command, text, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
}
#[cfg(desktop)]
fn read_native_clipboard_with_timeout(
mut command: Command,
timeout: Duration,
) -> Result<String, String> {
let child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
let output = wait_for_native_clipboard_output(child, timeout)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(clipboard_failure_message(&output.stderr))
}
}
#[cfg(desktop)]
fn read_native_clipboard(command: Command) -> Result<String, String> {
read_native_clipboard_with_timeout(command, NATIVE_CLIPBOARD_COMMAND_TIMEOUT)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn copy_text_to_clipboard(text: String) -> Result<(), String> {
tokio::task::spawn_blocking(move || write_native_clipboard(clipboard_command(), &text))
.await
.map_err(|e| format!("Native clipboard task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn read_text_from_clipboard() -> Result<String, String> {
tokio::task::spawn_blocking(move || read_native_clipboard(clipboard_read_command()))
.await
.map_err(|e| format!("Native clipboard task failed: {e}"))?
}
#[cfg(mobile)]
#[tauri::command]
pub async fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn read_text_from_clipboard() -> Result<String, String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(desktop, unix))]
#[test]
fn native_clipboard_write_times_out_slow_commands() {
let mut command = Command::new("sh");
command.args(["-c", "cat >/dev/null; sleep 2"]);
let started = Instant::now();
let result =
write_native_clipboard_with_timeout(command, "copy me", Duration::from_millis(50));
let error = result.expect_err("slow clipboard command should time out");
assert!(
error.contains("timed out"),
"unexpected clipboard timeout error: {error}"
);
assert!(
started.elapsed() < Duration::from_secs(1),
"clipboard timeout should return promptly"
);
}
}

View file

@ -0,0 +1,44 @@
use crate::vault;
use super::vault::VaultBoundary;
#[tauri::command]
pub async fn batch_delete_notes_async(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<Vec<String>, String> {
let boundary = VaultBoundary::from_request(vault_path.as_deref())?;
let validated_paths = boundary.validate_existing_paths(&paths)?;
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&validated_paths))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn batch_delete_notes_async_validates_and_deletes_inside_vault() {
let dir = tempfile::TempDir::new().unwrap();
let first = dir.path().join("first.md");
let second = dir.path().join("nested/second.md");
std::fs::create_dir_all(second.parent().unwrap()).unwrap();
std::fs::write(&first, "# First\n").unwrap();
std::fs::write(&second, "# Second\n").unwrap();
let deleted = batch_delete_notes_async(
vec![
first.to_string_lossy().to_string(),
"nested/second.md".to_string(),
],
Some(dir.path().to_string_lossy().to_string()),
)
.await
.unwrap();
assert_eq!(deleted.len(), 2);
assert!(!first.exists());
assert!(!second.exists());
}
}

View file

@ -0,0 +1,50 @@
use crate::vault::{self, FolderRenameResult};
use super::expand_tilde;
#[tauri::command]
pub fn rename_vault_folder(
vault_path: String,
folder_path: String,
new_name: String,
) -> Result<FolderRenameResult, String> {
let vault_path = expand_tilde(&vault_path);
vault::rename_folder(
std::path::Path::new(vault_path.as_ref()),
&folder_path,
&new_name,
)
}
#[tauri::command]
pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folder_commands_route_through_vault_path_boundary() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_string_lossy().to_string();
let folder = dir.path().join("Inbox");
std::fs::create_dir(&folder).unwrap();
std::fs::write(folder.join("note.md"), "# Note\n").unwrap();
let renamed = rename_vault_folder(
vault_path.clone(),
"Inbox".to_string(),
"Organized".to_string(),
)
.unwrap();
assert!(renamed.new_path.ends_with("Organized"));
assert!(dir.path().join("Organized/note.md").exists());
let deleted = delete_vault_folder(vault_path, "Organized".to_string()).unwrap();
assert_eq!(deleted, "Organized");
assert!(!dir.path().join("Organized").exists());
}
}

View file

@ -0,0 +1,646 @@
use crate::git::{
GitAuthorIdentity, GitCommit, GitProviderProbe, GitProviderStatus, GitPullResult,
GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile, PulseCommit,
};
use super::expand_tilde;
type VaultPathArg = String;
type NotePathArg = String;
type CommitHashArg = String;
type CommitMessageArg = String;
type ConflictStrategyArg = String;
const GIT_PROVIDER_PROBE_TIMEOUT_SECONDS: u64 = 12;
// ── Git commands (desktop) ──────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_history(
vault_path: VaultPathArg,
path: NotePathArg,
) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
crate::git::get_file_history(&vault_path, &path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_modified_files(
vault_path: VaultPathArg,
include_stats: Option<bool>,
) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if include_stats.unwrap_or(false) {
crate::git::get_modified_files_with_stats(&vault_path)
} else {
crate::git::get_modified_files(&vault_path)
}
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_diff(vault_path: VaultPathArg, path: NotePathArg) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
crate::git::get_file_diff(&vault_path, &path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_file_diff_at_commit(
vault_path: VaultPathArg,
path: NotePathArg,
commit_hash: CommitHashArg,
) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
crate::git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_vault_pulse(
vault_path: VaultPathArg,
limit: Option<usize>,
skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let limit = limit.unwrap_or(20);
let skip = skip.unwrap_or(0);
crate::git::get_vault_pulse(vault_path.as_ref(), limit, skip)
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_commit(vault_path: VaultPathArg, message: CommitMessageArg) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::git_commit(&vault_path, &message)
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_author_identity(vault_path: VaultPathArg) -> Result<GitAuthorIdentity, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::git_author_identity(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_last_commit_info(vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::get_last_commit_info(vault_path.as_ref())
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_pull(vault_path: VaultPathArg) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if !crate::git::is_inside_work_tree(std::path::Path::new(&vault_path)) {
return Ok(GitPullResult {
status: "no_remote".to_string(),
message: "No remote configured".to_string(),
updated_files: vec![],
conflict_files: vec![],
});
}
crate::git::git_pull(&vault_path)
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_conflict_files(vault_path: VaultPathArg) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::get_conflict_files(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn get_conflict_mode(vault_path: VaultPathArg) -> String {
let vault_path = expand_tilde(&vault_path);
crate::git::get_conflict_mode(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_resolve_conflict(
vault_path: VaultPathArg,
file: NotePathArg,
strategy: ConflictStrategyArg,
) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_commit_conflict_resolution(vault_path: VaultPathArg) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
crate::git::git_commit_conflict_resolution(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_push(vault_path: VaultPathArg) -> Result<GitPushResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if !crate::git::is_inside_work_tree(std::path::Path::new(&vault_path)) {
return Ok(GitPushResult {
status: "no_remote".to_string(),
message: "No remote configured".to_string(),
});
}
crate::git::git_push(&vault_path)
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if !crate::git::is_inside_work_tree(std::path::Path::new(&vault_path)) {
return Ok(GitRemoteStatus {
branch: String::new(),
has_remote: false,
has_upstream: false,
upstream: None,
ahead: 0,
behind: 0,
});
}
crate::git::git_remote_status(&vault_path)
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_file_url(
vault_path: VaultPathArg,
path: NotePathArg,
) -> Result<Option<String>, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let path = expand_tilde(&path).into_owned();
tokio::task::spawn_blocking(move || crate::git::git_file_url(&vault_path, &path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_discard_file(
vault_path: VaultPathArg,
relative_path: NotePathArg,
) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::discard_file_changes(&vault_path, &relative_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
let vault_path = expand_tilde(&vault_path);
crate::git::is_inside_work_tree(std::path::Path::new(vault_path.as_ref()))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_provider_status() -> Result<GitProviderStatus, String> {
tokio::time::timeout(
std::time::Duration::from_secs(GIT_PROVIDER_PROBE_TIMEOUT_SECONDS),
tokio::task::spawn_blocking(crate::git::git_provider_status),
)
.await
.map_err(|_| "Git provider detection timed out".to_string())?
.map_err(|e| format!("Task panicked: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn test_git_provider(
provider: String,
distro: Option<String>,
vault_path: Option<String>,
) -> Result<GitProviderProbe, String> {
tokio::time::timeout(
std::time::Duration::from_secs(GIT_PROVIDER_PROBE_TIMEOUT_SECONDS),
tokio::task::spawn_blocking(move || {
crate::git::test_git_provider(&provider, distro.as_deref(), vault_path.as_deref())
}),
)
.await
.map_err(|_| "Git provider test timed out".to_string())?
.map_err(|e| format!("Task panicked: {e}"))
}
#[cfg(desktop)]
fn validate_git_init_target(vault_path: &str) -> Result<(), String> {
let path = std::path::Path::new(vault_path);
if !path.exists() {
return Err("Choose an existing vault folder before initializing Git".to_string());
}
if !path.is_dir() {
return Err("Choose a folder before initializing Git".to_string());
}
if is_broad_personal_folder(path) && !has_tolaria_vault_marker(path) {
return Err(format!(
"Choose a dedicated vault folder before initializing Git. '{}' looks like a broad personal folder; create or select a subfolder such as '{}' instead.",
path.display(),
path.join("Tolaria").display()
));
}
if crate::git::is_inside_work_tree(path) && !crate::git::has_direct_git_metadata(path) {
return Err(
"This vault is already inside a Git work tree. Tolaria will use the parent repository instead of creating an embedded repository."
.to_string(),
);
}
Ok(())
}
#[cfg(desktop)]
fn is_broad_personal_folder(path: &std::path::Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
matches!(
name.to_ascii_lowercase().as_str(),
"desktop"
| "documents"
| "downloads"
| "movies"
| "music"
| "pictures"
| "public"
| "templates"
| "videos"
)
}
#[cfg(desktop)]
fn has_tolaria_vault_marker(path: &std::path::Path) -> bool {
["AGENTS.md", "CLAUDE.md", "type.md", "note.md"]
.iter()
.any(|file| path.join(file).is_file())
|| ["attachments", "type", "views"]
.iter()
.any(|dir| path.join(dir).is_dir())
}
#[cfg(desktop)]
#[tauri::command]
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
validate_git_init_target(&vault_path)?;
crate::git::init_repo(std::path::Path::new(vault_path.as_ref()))
}
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_history(
_vault_path: VaultPathArg,
_path: NotePathArg,
) -> Result<Vec<GitCommit>, String> {
Err("Git history is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_modified_files(
_vault_path: VaultPathArg,
_include_stats: Option<bool>,
) -> Result<Vec<ModifiedFile>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff(_vault_path: VaultPathArg, _path: NotePathArg) -> Result<String, String> {
Err("Git diff is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_file_diff_at_commit(
_vault_path: VaultPathArg,
_path: NotePathArg,
_commit_hash: CommitHashArg,
) -> Result<String, String> {
Err("Git diff is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_vault_pulse(
_vault_path: VaultPathArg,
_limit: Option<usize>,
_skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit(_vault_path: VaultPathArg, _message: CommitMessageArg) -> Result<String, String> {
Err("Git commit is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_author_identity(_vault_path: VaultPathArg) -> Result<GitAuthorIdentity, String> {
Err("Git author identity is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_last_commit_info(_vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_pull(_vault_path: VaultPathArg) -> Result<GitPullResult, String> {
Err("Git pull is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_files(_vault_path: VaultPathArg) -> Result<Vec<String>, String> {
Ok(vec![])
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_conflict_mode(_vault_path: VaultPathArg) -> String {
"none".to_string()
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_resolve_conflict(
_vault_path: VaultPathArg,
_file: NotePathArg,
_strategy: ConflictStrategyArg,
) -> Result<(), String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_commit_conflict_resolution(_vault_path: VaultPathArg) -> Result<String, String> {
Err("Git conflict resolution is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_push(_vault_path: VaultPathArg) -> Result<GitPushResult, String> {
Err("Git push is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
Ok(GitRemoteStatus {
branch: String::new(),
has_remote: false,
has_upstream: false,
upstream: None,
ahead: 0,
behind: 0,
})
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_file_url(
_vault_path: VaultPathArg,
_path: NotePathArg,
) -> Result<Option<String>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_discard_file(
_vault_path: VaultPathArg,
_relative_path: NotePathArg,
) -> Result<(), String> {
Err("Git discard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn is_git_repo(_vault_path: VaultPathArg) -> bool {
false
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_provider_status() -> Result<GitProviderStatus, String> {
Ok(crate::git::git_provider_status())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn test_git_provider(
provider: String,
distro: Option<String>,
vault_path: Option<String>,
) -> Result<GitProviderProbe, String> {
Ok(crate::git::test_git_provider(
&provider,
distro.as_deref(),
vault_path.as_deref(),
))
}
#[cfg(mobile)]
#[tauri::command]
pub fn init_git_repo(_vault_path: VaultPathArg) -> Result<(), String> {
Err("Git init is not available on mobile".into())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn vault_path(dir: &TempDir) -> String {
dir.path().to_string_lossy().into_owned()
}
fn note_path(dir: &TempDir, name: &str) -> String {
dir.path().join(name).to_string_lossy().into_owned()
}
fn create_initialized_vault() -> (TempDir, String) {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
let vault = vault_path(&dir);
init_git_repo(vault.clone()).unwrap();
(dir, vault)
}
#[tokio::test]
async fn desktop_git_commands_route_to_git_backend() {
let (dir, vault) = create_initialized_vault();
let note = note_path(&dir, "note.md");
assert!(is_git_repo(vault.clone()));
fs::write(dir.path().join("note.md"), "# Updated\n").unwrap();
let modified = get_modified_files(vault.clone(), None).await.unwrap();
assert!(modified.iter().any(|file| file.relative_path == "note.md"));
let diff = get_file_diff(vault.clone(), note.clone()).unwrap();
assert!(diff.contains("# Updated"));
git_commit(vault.clone(), "Update note".to_string()).unwrap();
let history = get_file_history(vault.clone(), note.clone()).unwrap();
assert!(history.iter().any(|commit| commit.message == "Update note"));
let last_commit = get_last_commit_info(vault.clone()).unwrap().unwrap();
assert!(!last_commit.short_hash.is_empty());
let commit_diff = get_file_diff_at_commit(
vault.clone(),
note.clone(),
history.first().unwrap().hash.clone(),
)
.unwrap();
assert!(commit_diff.contains("# Updated"));
let pulse = get_vault_pulse(vault.clone(), Some(5), Some(0)).unwrap();
assert!(!pulse.is_empty());
fs::write(dir.path().join("note.md"), "# Discard me\n").unwrap();
git_discard_file(vault.clone(), "note.md".to_string()).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join("note.md")).unwrap(),
"# Updated\n"
);
assert!(get_conflict_files(vault.clone()).unwrap().is_empty());
assert_eq!(get_conflict_mode(vault.clone()), "none");
assert!(
git_resolve_conflict(vault.clone(), "note.md".to_string(), "invalid".to_string(),)
.is_err()
);
}
#[test]
fn init_git_repo_rejects_broad_personal_folders() {
let dir = TempDir::new().unwrap();
let documents = dir.path().join("Documents");
fs::create_dir_all(&documents).unwrap();
fs::write(documents.join("unrelated.txt"), "not a vault").unwrap();
let err = init_git_repo(documents.to_string_lossy().into_owned())
.expect_err("expected Documents itself to be rejected before git init");
assert!(err.contains("dedicated vault folder"));
assert!(!documents.join(".git").exists());
}
#[test]
fn init_git_repo_allows_named_vault_subfolder_under_documents() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("Documents").join("Tolaria");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
let vault = vault.to_string_lossy().into_owned();
init_git_repo(vault.clone()).unwrap();
assert!(is_git_repo(vault));
}
#[test]
fn is_git_repo_accepts_vault_nested_inside_parent_worktree() {
let parent = TempDir::new().unwrap();
fs::write(parent.path().join("README.md"), "# Parent\n").unwrap();
crate::git::init_repo(parent.path()).unwrap();
let nested_vault = parent.path().join("demo-vault-v2");
fs::create_dir_all(&nested_vault).unwrap();
fs::write(nested_vault.join("note.md"), "# Nested\n").unwrap();
assert!(is_git_repo(nested_vault.to_string_lossy().into_owned()));
assert!(!nested_vault.join(".git").exists());
}
#[test]
fn init_git_repo_rejects_nested_worktree_vault_without_direct_git_metadata() {
let parent = TempDir::new().unwrap();
fs::write(parent.path().join("README.md"), "# Parent\n").unwrap();
crate::git::init_repo(parent.path()).unwrap();
let nested_vault = parent.path().join("demo-vault-v2");
fs::create_dir_all(&nested_vault).unwrap();
fs::write(nested_vault.join("note.md"), "# Nested\n").unwrap();
let err = init_git_repo(nested_vault.to_string_lossy().into_owned())
.expect_err("expected nested vault to reuse the parent worktree");
assert!(err.contains("inside a Git work tree"));
assert!(!nested_vault.join(".git").exists());
}
#[tokio::test]
async fn desktop_remote_commands_report_no_remote() {
let (_dir, vault) = create_initialized_vault();
let pull = git_pull(vault.clone()).await.unwrap();
assert_eq!(pull.status, "no_remote");
let push = git_push(vault.clone()).await.unwrap();
assert_eq!(push.status, "no_remote");
let status = git_remote_status(vault.clone()).await.unwrap();
assert!(!status.has_remote);
assert_eq!((status.ahead, status.behind), (0, 0));
}
#[tokio::test]
async fn desktop_remote_commands_report_no_remote_for_gitless_vault() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
let vault = vault_path(&dir);
let pull = git_pull(vault.clone()).await.unwrap();
assert_eq!(pull.status, "no_remote");
assert!(pull.updated_files.is_empty());
assert!(pull.conflict_files.is_empty());
let push = git_push(vault.clone()).await.unwrap();
assert_eq!(push.status, "no_remote");
let status = git_remote_status(vault).await.unwrap();
assert!(!status.has_remote);
assert_eq!(status.branch, "");
assert_eq!((status.ahead, status.behind), (0, 0));
}
}

View file

@ -0,0 +1,18 @@
use super::expand_tilde;
#[cfg(desktop)]
#[tauri::command]
pub async fn clone_git_repo(url: String, local_path: String) -> Result<String, String> {
let url = crate::git::validate_user_remote_url(&url)?.to_string();
let local_path = expand_tilde(&local_path).into_owned();
tokio::task::spawn_blocking(move || crate::git::clone_repo(&url, &local_path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(mobile)]
#[tauri::command]
pub async fn clone_git_repo(_url: String, _local_path: String) -> Result<String, String> {
Err("Git clone is not available on mobile".into())
}

View file

@ -0,0 +1,36 @@
use crate::git::GitAddRemoteResult;
use serde::Deserialize;
use super::expand_tilde;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitAddRemoteRequest {
vault_path: String,
remote_url: String,
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_add_remote(request: GitAddRemoteRequest) -> Result<GitAddRemoteResult, String> {
let vault_path = expand_tilde(&request.vault_path).into_owned();
let remote_url = match crate::git::validate_user_remote_url(&request.remote_url) {
Ok(url) => url.to_string(),
Err(message) => {
return Ok(GitAddRemoteResult {
status: "error".to_string(),
message,
});
}
};
tokio::task::spawn_blocking(move || crate::git::git_add_remote(&vault_path, &remote_url))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_add_remote(_request: GitAddRemoteRequest) -> Result<GitAddRemoteResult, String> {
Err("Adding git remotes is not available on mobile".into())
}

View file

@ -0,0 +1,167 @@
use serde::Serialize;
use std::process::Command;
const WEBKIT_AUX_PID_WINDOW: u32 = 512;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessMemoryEntry {
pub pid: u32,
pub parent_pid: u32,
pub rss_bytes: u64,
pub role: String,
pub command: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessMemorySnapshot {
pub current_pid: u32,
pub total_rss_bytes: u64,
pub entries: Vec<ProcessMemoryEntry>,
}
struct ProcessRow {
pid: u32,
parent_pid: u32,
rss_kib: u64,
command: String,
}
#[tauri::command]
pub fn get_process_memory_snapshot() -> Result<ProcessMemorySnapshot, String> {
let current_pid = std::process::id();
let entries = collect_related_process_memory(current_pid)?;
let total_rss_bytes = entries.iter().map(|entry| entry.rss_bytes).sum();
Ok(ProcessMemorySnapshot {
current_pid,
total_rss_bytes,
entries,
})
}
fn collect_related_process_memory(current_pid: u32) -> Result<Vec<ProcessMemoryEntry>, String> {
let rows = read_process_rows()?;
Ok(rows
.into_iter()
.filter_map(|row| related_process_entry(row, current_pid))
.collect())
}
fn related_process_entry(row: ProcessRow, current_pid: u32) -> Option<ProcessMemoryEntry> {
let role = classify_related_process(&row, current_pid)?;
Some(ProcessMemoryEntry {
pid: row.pid,
parent_pid: row.parent_pid,
rss_bytes: row.rss_kib.saturating_mul(1024),
role,
command: row.command,
})
}
fn classify_related_process(row: &ProcessRow, current_pid: u32) -> Option<String> {
if row.pid == current_pid {
return Some("app".to_string());
}
if !is_nearby_webkit_auxiliary(row, current_pid) {
return None;
}
if row.command.contains("WebKit.WebContent") {
return Some("webkit-webcontent".to_string());
}
if row.command.contains("WebKit.GPU") {
return Some("webkit-gpu".to_string());
}
if row.command.contains("WebKit.Networking") {
return Some("webkit-networking".to_string());
}
Some("webkit".to_string())
}
fn is_nearby_webkit_auxiliary(row: &ProcessRow, current_pid: u32) -> bool {
row.pid > current_pid
&& row.pid.saturating_sub(current_pid) <= WEBKIT_AUX_PID_WINDOW
&& row.command.contains("com.apple.WebKit.")
}
fn parse_process_row(line: &str) -> Option<ProcessRow> {
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse().ok()?;
let parent_pid = fields.next()?.parse().ok()?;
let rss_kib = fields.next()?.parse().ok()?;
let command = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
return None;
}
Some(ProcessRow {
pid,
parent_pid,
rss_kib,
command,
})
}
#[cfg(unix)]
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
let output = Command::new("ps")
.args(["-axo", "pid=,ppid=,rss=,command="])
.output()
.map_err(|error| format!("Failed to sample process memory: {error}"))?;
if !output.status.success() {
return Err("Failed to sample process memory with ps".to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().filter_map(parse_process_row).collect())
}
#[cfg(not(unix))]
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
Err("Process memory snapshots are only implemented on Unix platforms".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ps_rows_with_spaced_commands() {
let row = parse_process_row(" 42 1 1024 /System/WebKit WebContent").unwrap();
assert_eq!(row.pid, 42);
assert_eq!(row.parent_pid, 1);
assert_eq!(row.rss_kib, 1024);
assert_eq!(row.command, "/System/WebKit WebContent");
}
#[test]
fn classifies_nearby_webkit_auxiliaries() {
let row = ProcessRow {
pid: 120,
parent_pid: 1,
rss_kib: 10,
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
};
assert_eq!(
classify_related_process(&row, 100),
Some("webkit-webcontent".to_string()),
);
}
#[test]
fn ignores_unrelated_webkit_auxiliaries() {
let row = ProcessRow {
pid: 900,
parent_pid: 1,
rss_kib: 10,
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
};
assert_eq!(classify_related_process(&row, 100), None);
}
}

View file

@ -0,0 +1,102 @@
mod ai;
mod app_icon;
mod clipboard;
mod delete;
mod folders;
mod git;
pub mod git_clone;
mod git_connect;
mod memory;
mod pdf_export;
mod runtime;
mod sheet;
mod system;
mod vault;
mod version;
use std::borrow::Cow;
pub use ai::*;
pub use app_icon::*;
pub use clipboard::*;
pub use delete::*;
pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use memory::*;
pub use pdf_export::*;
pub use runtime::*;
pub use sheet::*;
pub use system::*;
pub use vault::*;
pub use version::*;
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
/// home directory cannot be determined.
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
let Some(home) = dirs::home_dir() else {
return Cow::Borrowed(path);
};
match path {
"~" => Cow::Owned(home.to_string_lossy().into_owned()),
_ => path
.strip_prefix("~/")
.map(|rest| Cow::Owned(home.join(rest).to_string_lossy().into_owned()))
.unwrap_or(Cow::Borrowed(path)),
}
}
fn is_numeric_version_part(part: &str) -> bool {
!part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())
}
fn is_legacy_build_version(minor: &str, patch: &str) -> bool {
minor.len() >= 6 && is_numeric_version_part(minor) && is_numeric_version_part(patch)
}
fn parse_legacy_build_label(version: &str) -> Option<String> {
let parts: Vec<&str> = version.split('.').collect();
match parts.as_slice() {
[_, minor, patch] if is_legacy_build_version(minor, patch) => Some(format!("b{}", patch)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_with_subpath() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~/Documents/vault");
assert_eq!(result, format!("{}/Documents/vault", home.display()));
}
#[test]
fn expand_tilde_alone() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~");
assert_eq!(result, home.to_string_lossy());
}
#[test]
fn expand_tilde_noop_for_absolute_path() {
let result = expand_tilde("/usr/local/bin");
assert_eq!(result, "/usr/local/bin");
}
#[test]
fn expand_tilde_noop_for_relative_path() {
let result = expand_tilde("some/relative/path");
assert_eq!(result, "some/relative/path");
}
#[test]
fn expand_tilde_noop_for_tilde_in_middle() {
let result = expand_tilde("/home/~user/path");
assert_eq!(result, "/home/~user/path");
}
}

View file

@ -0,0 +1,141 @@
#[tauri::command]
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
native::export_current_webview_pdf(window, output_path)
}
#[tauri::command]
pub fn can_export_current_webview_pdf() -> bool {
native::can_export_current_webview_pdf()
}
#[cfg(target_os = "macos")]
mod native {
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use objc2::runtime::ProtocolObject;
use objc2::ClassType;
use objc2_app_kit::{NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
use objc2_foundation::{NSString, NSURL};
use objc2_web_kit::WKWebView;
const PDF_EXPORT_TIMEOUT: Duration = Duration::from_secs(15);
pub fn can_export_current_webview_pdf() -> bool {
true
}
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
validate_output_path(&output_path)?;
let (sender, receiver) = mpsc::channel();
window
.with_webview(move |webview| {
let result = save_webview_pdf(webview, &output_path);
let _ = sender.send(result);
})
.map_err(|error| format!("Failed to access the current webview: {error}"))?;
receiver
.recv_timeout(PDF_EXPORT_TIMEOUT)
.map_err(|_| "Timed out while exporting the current note as PDF".to_string())?
}
fn validate_output_path(output_path: &str) -> Result<(), String> {
if output_path.trim().is_empty() {
return Err("Missing PDF export path".to_string());
}
let path = Path::new(output_path);
if path.file_name().is_none() {
return Err("PDF export path must include a file name".to_string());
}
Ok(())
}
fn save_webview_pdf(
webview: tauri::webview::PlatformWebview,
output_path: &str,
) -> Result<(), String> {
let output = NSString::from_str(output_path);
let output_url = NSURL::fileURLWithPath(&output);
let print_info = NSPrintInfo::sharedPrintInfo();
let print_settings = unsafe { print_info.dictionary() };
let previous_job_disposition = print_info.jobDisposition();
print_info.setJobDisposition(unsafe { NSPrintSaveJob });
unsafe {
print_settings.setObject_forKey(
output_url.as_super().as_super(),
ProtocolObject::from_ref(NSPrintJobSavingURL),
);
}
let webview: &WKWebView = unsafe { &*webview.inner().cast() };
let window = webview
.window()
.ok_or_else(|| "Failed to access the webview window for PDF export".to_string())?;
let operation = unsafe { webview.printOperationWithPrintInfo(&print_info) };
operation.setShowsPrintPanel(false);
operation.setShowsProgressPanel(false);
operation.setCanSpawnSeparateThread(true);
unsafe {
operation.runOperationModalForWindow_delegate_didRunSelector_contextInfo(
&window,
None,
None,
std::ptr::null_mut(),
);
}
print_info.setJobDisposition(&previous_job_disposition);
unsafe {
print_settings.removeObjectForKey(NSPrintJobSavingURL);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::validate_output_path;
#[test]
fn output_path_requires_a_non_blank_value() {
assert!(validate_output_path("").is_err());
assert!(validate_output_path(" ").is_err());
}
#[test]
fn output_path_requires_a_file_name() {
assert!(validate_output_path("/").is_err());
}
#[test]
fn output_path_accepts_a_pdf_file_path() {
assert!(validate_output_path("/tmp/tolaria-note.pdf").is_ok());
}
}
}
#[cfg(not(target_os = "macos"))]
mod native {
pub fn can_export_current_webview_pdf() -> bool {
false
}
pub fn export_current_webview_pdf(
_window: tauri::WebviewWindow,
_output_path: String,
) -> Result<(), String> {
Err("Direct PDF export is currently only supported on macOS".to_string())
}
}

View file

@ -0,0 +1,55 @@
fn should_use_external_media_preview_for_appimage(is_linux_appimage: bool) -> bool {
is_linux_appimage
}
fn map_print_result<E: std::fmt::Display>(result: Result<(), E>) -> Result<(), String> {
result.map_err(|error| format!("Failed to open the system print dialog: {error}"))
}
#[cfg(all(desktop, target_os = "linux"))]
fn linux_appimage_running() -> bool {
crate::linux_appimage::is_running()
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn linux_appimage_running() -> bool {
false
}
#[tauri::command]
pub fn should_use_external_media_preview() -> bool {
should_use_external_media_preview_for_appimage(linux_appimage_running())
}
#[tauri::command]
#[cfg(desktop)]
pub fn print_current_webview(window: tauri::WebviewWindow) -> Result<(), String> {
map_print_result(window.print())
}
#[tauri::command]
#[cfg(mobile)]
pub fn print_current_webview() -> Result<(), String> {
Err("System printing is not available in this mobile build yet.".into())
}
#[cfg(test)]
mod tests {
use super::{map_print_result, should_use_external_media_preview_for_appimage};
#[test]
fn external_media_preview_is_limited_to_linux_appimage() {
assert!(should_use_external_media_preview_for_appimage(true));
assert!(!should_use_external_media_preview_for_appimage(false));
}
#[test]
fn print_errors_are_formatted_for_the_renderer() {
let result = map_print_result::<&str>(Err("printer unavailable"));
assert_eq!(
result,
Err("Failed to open the system print dialog: printer unavailable".to_string())
);
}
}

View file

@ -0,0 +1,679 @@
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use ironcalc_base::Model;
use regex::Regex;
use serde::{Deserialize, Serialize};
const SHEET_INDEX: u32 = 0;
const DEFAULT_MAX_DEPTH: usize = 4;
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SheetDependencyContent {
pub path: String,
pub content: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SheetExternalReferenceLink {
pub source_path: String,
pub target: String,
pub target_path: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveSheetExternalFormulaInputsRequest {
pub content: String,
pub current_path: String,
pub dependencies: Vec<SheetDependencyContent>,
pub links: Vec<SheetExternalReferenceLink>,
pub max_depth: Option<usize>,
pub timezone: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedSheetExternalFormulaInput {
pub cell: String,
pub evaluated: String,
pub source: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveSheetExternalFormulaInputsResponse {
pub inputs: Vec<ResolvedSheetExternalFormulaInput>,
}
#[tauri::command]
pub async fn resolve_sheet_external_formula_inputs(
request: ResolveSheetExternalFormulaInputsRequest,
) -> Result<ResolveSheetExternalFormulaInputsResponse, String> {
tokio::task::spawn_blocking(move || resolve_sheet_external_formula_inputs_sync(request))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
fn resolve_sheet_external_formula_inputs_sync(
request: ResolveSheetExternalFormulaInputsRequest,
) -> Result<ResolveSheetExternalFormulaInputsResponse, String> {
let mut resolver = ExternalFormulaResolver::new(request);
resolver.resolve_current_sheet()
}
struct ExternalFormulaResolver {
content_by_path: HashMap<String, String>,
current_path: String,
link_targets: HashMap<String, String>,
max_depth: usize,
sheet_literal_cache: HashMap<String, HashMap<String, String>>,
timezone: String,
}
struct ResolveStack {
depth: usize,
paths: HashSet<String>,
}
struct SheetBuildInputs {
external_inputs: HashMap<String, String>,
unresolved_external_cells: HashSet<String>,
}
struct SheetRows<'a> {
path: &'a str,
rows: &'a [Vec<String>],
}
impl ResolveStack {
fn new(root_path: &str) -> Self {
Self {
depth: 0,
paths: HashSet::from([root_path.to_string()]),
}
}
fn can_enter(&self, path: &str, max_depth: usize) -> bool {
self.depth < max_depth && !self.paths.contains(path)
}
fn enter(&mut self, path: &str) {
self.depth += 1;
self.paths.insert(path.to_string());
}
fn exit(&mut self, path: &str) {
self.depth = self.depth.saturating_sub(1);
self.paths.remove(path);
}
}
impl ExternalFormulaResolver {
fn new(request: ResolveSheetExternalFormulaInputsRequest) -> Self {
let mut content_by_path = HashMap::from([(request.current_path.clone(), request.content)]);
for dependency in request.dependencies {
content_by_path.insert(dependency.path, dependency.content);
}
let link_targets = request
.links
.into_iter()
.map(|link| (link_key(&link.source_path, &link.target), link.target_path))
.collect();
Self {
content_by_path,
current_path: request.current_path,
link_targets,
max_depth: request.max_depth.unwrap_or(DEFAULT_MAX_DEPTH),
sheet_literal_cache: HashMap::new(),
timezone: request.timezone.unwrap_or_else(|| "UTC".to_string()),
}
}
fn resolve_current_sheet(
&mut self,
) -> Result<ResolveSheetExternalFormulaInputsResponse, String> {
let content = self
.content_by_path
.get(&self.current_path)
.cloned()
.ok_or_else(|| "Current sheet content is missing".to_string())?;
let rows = parse_sheet_rows(&content);
let mut inputs = Vec::new();
for (row_index, row) in rows.iter().enumerate() {
for (column_index, value) in row.iter().enumerate() {
let source = parse_sheet_markdown_cell_value(value);
if !is_external_formula_input(&source) {
continue;
}
let mut stack = ResolveStack::new(&self.current_path);
if let Some(evaluated) = self.resolve_external_formula_input(
&source,
&self.current_path.clone(),
&mut stack,
)? {
inputs.push(ResolvedSheetExternalFormulaInput {
cell: cell_address(row_index + 1, column_index + 1),
evaluated,
source,
});
}
}
}
Ok(ResolveSheetExternalFormulaInputsResponse { inputs })
}
fn resolve_external_formula_input(
&mut self,
value: &str,
source_path: &str,
stack: &mut ResolveStack,
) -> Result<Option<String>, String> {
if !is_external_formula_input(value) {
return Ok(None);
}
let mut unresolved = false;
let evaluated = external_ref_regex()
.replace_all(value, |captures: &regex::Captures<'_>| {
let raw_target = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
let column_absolute = captures.get(2).map(|m| m.as_str()).unwrap_or_default();
let raw_column = captures.get(3).map(|m| m.as_str()).unwrap_or_default();
let row_absolute = captures.get(4).map(|m| m.as_str()).unwrap_or_default();
let raw_row = captures.get(5).map(|m| m.as_str()).unwrap_or_default();
let target = wikilink_target(raw_target);
let Some(target_path) = self
.link_targets
.get(&link_key(source_path, &target))
.cloned()
else {
unresolved = true;
return captures[0].to_string();
};
if target_path == source_path {
return format!(
"{}{}{}{}",
column_absolute,
raw_column.to_ascii_uppercase(),
row_absolute,
raw_row,
);
}
let Some(row) = raw_row.parse::<usize>().ok() else {
unresolved = true;
return captures[0].to_string();
};
let Some(column) = column_index_from_name(raw_column) else {
unresolved = true;
return captures[0].to_string();
};
let address = cell_address(row, column);
match self.resolve_external_cell_literal(&target_path, &address, stack) {
Ok(Some(literal)) => literal,
_ => {
unresolved = true;
captures[0].to_string()
}
}
})
.to_string();
if unresolved || evaluated == value {
Ok(None)
} else {
Ok(Some(evaluated))
}
}
fn resolve_external_cell_literal(
&mut self,
path: &str,
address: &str,
stack: &mut ResolveStack,
) -> Result<Option<String>, String> {
if !stack.can_enter(path, self.max_depth) {
return Ok(None);
}
if let Some(cached_sheet) = self.sheet_literal_cache.get(path) {
return Ok(cached_sheet.get(address).cloned());
}
let Some(content) = self.content_by_path.get(path).cloned() else {
return Ok(None);
};
stack.enter(path);
let result = self.build_sheet_literal_cache(path, &content, stack);
stack.exit(path);
result?;
Ok(self
.sheet_literal_cache
.get(path)
.and_then(|sheet| sheet.get(address).cloned()))
}
fn build_sheet_literal_cache(
&mut self,
path: &str,
content: &str,
stack: &mut ResolveStack,
) -> Result<(), String> {
let rows = parse_sheet_rows(content);
let workbook_name = workbook_name_from_path(path);
let timezone = self.timezone.clone();
let mut model = Model::new_empty(workbook_name.as_str(), "en", timezone.as_str(), "en")?;
let sheet_rows = SheetRows { path, rows: &rows };
let build_inputs = self.populate_model_from_rows(&mut model, &sheet_rows, stack)?;
model.evaluate();
self.sheet_literal_cache.insert(
path.to_string(),
collect_sheet_literals(&model, &rows, &build_inputs),
);
Ok(())
}
fn populate_model_from_rows(
&mut self,
model: &mut Model<'_>,
sheet_rows: &SheetRows<'_>,
stack: &mut ResolveStack,
) -> Result<SheetBuildInputs, String> {
let mut external_inputs = HashMap::<String, String>::new();
let mut unresolved_external_cells = HashSet::<String>::new();
for (row_index, row) in sheet_rows.rows.iter().enumerate() {
for (column_index, value) in row.iter().enumerate() {
let source = parse_sheet_markdown_cell_value(value);
if source.is_empty() {
continue;
}
let address = cell_address(row_index + 1, column_index + 1);
let model_input =
match self.resolve_external_formula_input(&source, sheet_rows.path, stack)? {
Some(evaluated) => {
external_inputs.insert(address, evaluated.clone());
evaluated
}
None => {
if is_external_formula_input(&source) {
unresolved_external_cells.insert(address);
}
source
}
};
model.set_user_input(
SHEET_INDEX,
row_index as i32 + 1,
column_index as i32 + 1,
model_input,
)?;
}
}
Ok(SheetBuildInputs {
external_inputs,
unresolved_external_cells,
})
}
}
fn collect_sheet_literals(
model: &Model<'_>,
rows: &[Vec<String>],
build_inputs: &SheetBuildInputs,
) -> HashMap<String, String> {
let mut literals = HashMap::new();
for (row_index, row) in rows.iter().enumerate() {
collect_sheet_row_literals(model, row_index, row, build_inputs, &mut literals);
}
literals
}
fn collect_sheet_row_literals(
model: &Model<'_>,
row_index: usize,
row: &[String],
build_inputs: &SheetBuildInputs,
literals: &mut HashMap<String, String>,
) {
for (column_index, _value) in row.iter().enumerate() {
let row_number = row_index as i32 + 1;
let column_number = column_index as i32 + 1;
let address = cell_address(row_index + 1, column_index + 1);
if build_inputs.unresolved_external_cells.contains(&address) {
continue;
}
let content = build_inputs
.external_inputs
.get(&address)
.cloned()
.unwrap_or_else(|| {
model
.get_localized_cell_content(SHEET_INDEX, row_number, column_number)
.unwrap_or_default()
});
literals.insert(
address,
external_cell_formula_literal(model, row_number, column_number, &content),
);
}
}
fn external_ref_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\[\[([^\]\n]+?)\]\]\.(\$?)([A-Za-z]+)(\$?)([1-9]\d*)").unwrap())
}
fn numeric_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^-?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?$").unwrap())
}
fn percent_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^-?[$€£]?\s*[\d,]+(?:\.\d+)?%$").unwrap())
}
fn is_external_formula_input(value: &str) -> bool {
value.trim_start().starts_with('=') && external_ref_regex().is_match(value)
}
fn normalize_target(target: &str) -> String {
target.trim().to_lowercase()
}
fn wikilink_target(raw: &str) -> String {
raw.split_once('|')
.map(|(target, _)| target)
.unwrap_or(raw)
.to_string()
}
fn link_key(source_path: &str, target: &str) -> String {
format!("{}\n{}", source_path, normalize_target(target))
}
fn workbook_name_from_path(path: &str) -> String {
path.rsplit(['/', '\\'])
.next()
.unwrap_or("Tolaria Sheet")
.trim_end_matches(".md")
.to_string()
}
fn first_line_break_len(content: &str, index: usize) -> usize {
let bytes = content.as_bytes();
match (bytes.get(index), bytes.get(index + 1)) {
(Some(b'\r'), Some(b'\n')) => 2,
(Some(b'\n' | b'\r'), _) => 1,
_ => 0,
}
}
fn is_frontmatter_delimiter(line: &str) -> bool {
line.strip_prefix("---")
.map(|rest| rest.chars().all(|ch| ch == ' ' || ch == '\t'))
.unwrap_or(false)
}
fn split_sheet_body(content: &str) -> &str {
if !content.starts_with("---") {
return content;
}
let opening_line_break = first_line_break_len(content, 3);
if opening_line_break == 0 {
return content;
}
let mut line_start = 3 + opening_line_break;
while line_start < content.len() {
let mut line_end = line_start;
while line_end < content.len() && !matches!(content.as_bytes()[line_end], b'\n' | b'\r') {
line_end += 1;
}
if is_frontmatter_delimiter(&content[line_start..line_end]) {
let closing_line_break = first_line_break_len(content, line_end);
return &content[line_end + closing_line_break..];
}
let line_break = first_line_break_len(content, line_end);
if line_break == 0 {
break;
}
line_start = line_end + line_break;
}
content
}
fn parse_sheet_rows(content: &str) -> Vec<Vec<String>> {
parse_csv_rows(split_sheet_body(content).trim_end())
}
fn parse_csv_rows(source: &str) -> Vec<Vec<String>> {
if source.is_empty() {
return Vec::new();
}
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(false)
.from_reader(source.as_bytes());
reader
.records()
.filter_map(Result::ok)
.map(|record| record.iter().map(str::to_string).collect())
.collect()
}
fn strip_symmetric_markup(value: &str, marker: &str) -> Option<String> {
let inner = value.strip_prefix(marker)?.strip_suffix(marker)?;
let formula_candidate = inner.trim_start_matches(['*', '_', '~']).trim_start();
if formula_candidate.starts_with('=') || inner.is_empty() {
return None;
}
Some(inner.to_string())
}
fn parse_sheet_markdown_cell_value(value: &str) -> String {
if value.starts_with('=') {
return value.to_string();
}
for marker in ["***", "**", "__", "_", "*", "~~"] {
if let Some(inner) = strip_symmetric_markup(value, marker) {
return inner;
}
}
value.to_string()
}
fn column_index_from_name(name: &str) -> Option<usize> {
let mut value = 0usize;
for ch in name.chars() {
if !ch.is_ascii_alphabetic() {
return None;
}
value = value * 26 + (ch.to_ascii_uppercase() as usize - 'A' as usize + 1);
}
(value > 0).then_some(value)
}
fn column_name_from_index(mut index: usize) -> String {
let mut name = String::new();
while index > 0 {
let remainder = (index - 1) % 26;
name.insert(0, (b'A' + remainder as u8) as char);
index = (index - 1) / 26;
}
name
}
fn cell_address(row: usize, column: usize) -> String {
format!("{}{}", column_name_from_index(column), row)
}
fn normalized_numeric_formula_literal(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Some("0".to_string());
}
if numeric_regex().is_match(trimmed) {
return Some(trimmed.to_string());
}
if percent_regex().is_match(trimmed) {
let normalized = trimmed
.chars()
.filter(|ch| !matches!(ch, '$' | '€' | '£' | ',' | ' ' | '%'))
.collect::<String>();
if let Ok(parsed) = normalized.parse::<f64>() {
return Some((parsed / 100.0).to_string());
}
}
let normalized = trimmed
.trim_start_matches(['$', '€', '£'])
.trim_start()
.replace(',', "");
if numeric_regex().is_match(&normalized) {
return Some(normalized);
}
None
}
fn text_formula_literal(value: &str) -> String {
format!(
"\"{}\"",
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
)
}
fn external_cell_formula_literal(
model: &Model<'_>,
row: i32,
column: i32,
raw_content: &str,
) -> String {
if !raw_content.trim_start().starts_with('=') {
return normalized_numeric_formula_literal(raw_content)
.unwrap_or_else(|| text_formula_literal(raw_content));
}
let formatted = model
.get_formatted_cell_value(SHEET_INDEX, row, column)
.unwrap_or_default();
normalized_numeric_formula_literal(&formatted)
.unwrap_or_else(|| text_formula_literal(&formatted))
}
#[cfg(test)]
mod tests {
use super::*;
fn request(
content: &str,
dependencies: Vec<SheetDependencyContent>,
links: Vec<SheetExternalReferenceLink>,
) -> ResolveSheetExternalFormulaInputsRequest {
ResolveSheetExternalFormulaInputsRequest {
content: content.to_string(),
current_path: "/vault/a.md".to_string(),
dependencies,
links,
max_depth: Some(4),
timezone: Some("UTC".to_string()),
}
}
fn dependency(path: &str, content: &str) -> SheetDependencyContent {
SheetDependencyContent {
path: path.to_string(),
content: content.to_string(),
}
}
fn link(source_path: &str, target: &str, target_path: &str) -> SheetExternalReferenceLink {
SheetExternalReferenceLink {
source_path: source_path.to_string(),
target: target.to_string(),
target_path: target_path.to_string(),
}
}
#[test]
fn resolves_direct_external_formula_input() {
let response = resolve_sheet_external_formula_inputs_sync(request(
"Total\n=[[b]].A1+5",
vec![dependency("/vault/b.md", "40")],
vec![link("/vault/a.md", "b", "/vault/b.md")],
))
.unwrap();
assert_eq!(
response.inputs,
vec![ResolvedSheetExternalFormulaInput {
cell: "A2".to_string(),
evaluated: "=40+5".to_string(),
source: "=[[b]].A1+5".to_string(),
}],
);
}
#[test]
fn resolves_transitive_external_formula_input() {
let response = resolve_sheet_external_formula_inputs_sync(request(
"=[[b]].A1*2",
vec![
dependency("/vault/b.md", "=[[c]].A1+1"),
dependency("/vault/c.md", "20"),
],
vec![
link("/vault/a.md", "b", "/vault/b.md"),
link("/vault/b.md", "c", "/vault/c.md"),
],
))
.unwrap();
assert_eq!(response.inputs[0].evaluated, "=21*2");
}
#[test]
fn leaves_cycles_unresolved() {
let response = resolve_sheet_external_formula_inputs_sync(request(
"=[[b]].A1",
vec![dependency("/vault/b.md", "=[[a]].A1")],
vec![
link("/vault/a.md", "b", "/vault/b.md"),
link("/vault/b.md", "a", "/vault/a.md"),
],
))
.unwrap();
assert!(response.inputs.is_empty());
}
}

View file

@ -0,0 +1,683 @@
#[cfg(desktop)]
use std::process::Command;
#[cfg(desktop)]
use crate::menu;
use crate::settings::Settings;
use crate::vault_list;
use crate::vault_list::VaultList;
use serde::Deserialize;
#[cfg(desktop)]
use tauri::ipc::Channel;
#[cfg(desktop)]
use tauri::LogicalSize;
#[cfg(desktop)]
use tauri::Window;
use super::parse_build_label;
#[cfg(desktop)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TitleBarDoubleClickAction {
Fill,
Minimize,
None,
}
#[cfg(desktop)]
fn parse_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
match value.trim().to_ascii_lowercase().as_str() {
"fill" | "zoom" | "maximize" => Some(TitleBarDoubleClickAction::Fill),
"minimize" => Some(TitleBarDoubleClickAction::Minimize),
"none" | "no action" | "do nothing" => Some(TitleBarDoubleClickAction::None),
_ => None,
}
}
#[cfg(desktop)]
fn parse_legacy_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" => Some(TitleBarDoubleClickAction::Minimize),
"0" | "false" | "no" => Some(TitleBarDoubleClickAction::Fill),
_ => None,
}
}
#[cfg(desktop)]
fn read_global_defaults_value(key: &str) -> Option<String> {
let output = Command::new("defaults")
.args(["read", "-g", key])
.output()
.ok()?;
parse_defaults_read_output(output)
}
#[cfg(desktop)]
fn resolve_title_bar_double_click_action(
read_value: impl Fn(&str) -> Option<String>,
) -> TitleBarDoubleClickAction {
read_value("AppleActionOnDoubleClick")
.as_deref()
.and_then(parse_title_bar_double_click_action)
.or_else(|| {
read_value("AppleMiniaturizeOnDoubleClick")
.as_deref()
.and_then(parse_legacy_title_bar_double_click_action)
})
.unwrap_or(TitleBarDoubleClickAction::Fill)
}
#[cfg(desktop)]
fn parse_defaults_read_output(output: std::process::Output) -> Option<String> {
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
}
#[cfg(desktop)]
fn apply_title_bar_double_click_action(
action: TitleBarDoubleClickAction,
is_maximized: impl FnOnce() -> Result<bool, String>,
maximize: impl FnOnce() -> Result<(), String>,
unmaximize: impl FnOnce() -> Result<(), String>,
minimize: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
match action {
TitleBarDoubleClickAction::Fill => {
if is_maximized()? {
unmaximize()
} else {
maximize()
}
}
TitleBarDoubleClickAction::Minimize => minimize(),
TitleBarDoubleClickAction::None => Ok(()),
}
}
// ── MCP commands (desktop) ──────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path))
.await
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn remove_mcp_tools() -> Result<String, String> {
tokio::task::spawn_blocking(crate::mcp::remove_mcp)
.await
.map_err(|e| format!("Removal task failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatus, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::check_mcp_status(&vault_path))
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_opencode_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::opencode_mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("OpenCode MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
app: tauri::AppHandle,
vault_path: Option<String>,
vault_paths: Option<Vec<String>>,
) -> Result<String, String> {
let expanded_vault_path = vault_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(|path| super::expand_tilde(path).into_owned());
let vault_path = expanded_vault_path.as_deref().map(std::path::Path::new);
let expanded_vault_paths = vault_paths
.unwrap_or_default()
.into_iter()
.map(|path| super::expand_tilde(path.trim()).into_owned())
.filter(|path| !path.is_empty())
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
crate::sync_ws_bridge_for_vault(&app, vault_path, &expanded_vault_paths).map(str::to_string)
}
// ── MCP commands (mobile stubs) ─────────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub async fn register_mcp_tools(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn remove_mcp_tools() -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStatus, String> {
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_opencode_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
_vault_path: Option<String>,
_vault_paths: Option<Vec<String>>,
) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
// ── Menu commands ───────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MenuStateUpdate {
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
has_no_remote: Option<bool>,
note_list_search_enabled: Option<bool>,
editor_find_enabled: Option<bool>,
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
state: MenuStateUpdate,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, state.has_active_note);
if let Some(v) = state.has_modified_files {
menu::set_git_commit_items_enabled(&app_handle, v);
}
if let Some(v) = state.has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
if let Some(v) = state.has_restorable_deleted_note {
menu::set_restore_deleted_item_enabled(&app_handle, v);
}
if let Some(v) = state.has_no_remote {
menu::set_git_no_remote_items_enabled(&app_handle, v);
}
if let Some(v) = state.note_list_search_enabled {
menu::set_note_list_search_items_enabled(&app_handle, v);
}
if let Some(v) = state.editor_find_enabled {
menu::set_editor_find_items_enabled(&app_handle, v);
}
Ok(())
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_menu_state(
_app_handle: tauri::AppHandle,
_state: MenuStateUpdate,
) -> Result<(), String> {
Ok(())
}
#[cfg(desktop)]
#[tauri::command]
pub fn trigger_menu_command(app_handle: tauri::AppHandle, id: String) -> Result<(), String> {
menu::emit_custom_menu_event(&app_handle, &id)
}
#[cfg(mobile)]
#[tauri::command]
pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Result<(), String> {
Err("Native menu commands are not available on mobile".into())
}
#[cfg(desktop)]
fn should_apply_window_min_size_constraints(
is_windows: bool,
is_fullscreen: bool,
is_maximized: bool,
) -> bool {
!(is_windows && (is_fullscreen || is_maximized))
}
#[cfg(desktop)]
fn should_skip_window_min_size_update(window: &Window) -> Result<bool, String> {
if !cfg!(target_os = "windows") {
return Ok(false);
}
let is_fullscreen = window.is_fullscreen().map_err(|e| e.to_string())?;
let is_maximized = window.is_maximized().map_err(|e| e.to_string())?;
Ok(!should_apply_window_min_size_constraints(
true,
is_fullscreen,
is_maximized,
))
}
#[cfg(desktop)]
fn apply_window_min_size_update(
window: &Window,
min_width: f64,
min_height: f64,
grow_to_fit: bool,
) -> Result<(), String> {
window
.set_min_size(Some(LogicalSize::new(min_width, min_height)))
.map_err(|e| e.to_string())?;
if !grow_to_fit {
return Ok(());
}
let scale_factor = window.scale_factor().map_err(|e| e.to_string())?;
let current_size = window
.inner_size()
.map_err(|e| e.to_string())?
.to_logical::<f64>(scale_factor);
let next_width = current_size.width.max(min_width);
let next_height = current_size.height.max(min_height);
if next_width == current_size.width && next_height == current_size.height {
return Ok(());
}
window
.set_size(LogicalSize::new(next_width, next_height))
.map_err(|e| e.to_string())
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_current_window_min_size(
window: Window,
min_width: f64,
min_height: f64,
grow_to_fit: bool,
) -> Result<(), String> {
if should_skip_window_min_size_update(&window)? {
return Ok(());
}
apply_window_min_size_update(&window, min_width, min_height, grow_to_fit)
}
#[cfg(desktop)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> {
let action = resolve_title_bar_double_click_action(read_global_defaults_value);
apply_title_bar_double_click_action(
action,
|| window.is_maximized().map_err(|e| e.to_string()),
|| window.maximize().map_err(|e| e.to_string()),
|| window.unmaximize().map_err(|e| e.to_string()),
|| window.minimize().map_err(|e| e.to_string()),
)
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_current_window_min_size(
_window: tauri::Window,
_min_width: f64,
_min_height: f64,
_grow_to_fit: bool,
) -> Result<(), String> {
Ok(())
}
#[cfg(mobile)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(_window: tauri::Window) -> Result<(), String> {
Ok(())
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]
pub fn get_build_number(app_handle: tauri::AppHandle) -> String {
let version = app_handle.package_info().version.to_string();
parse_build_label(&version)
}
#[tauri::command]
pub fn get_settings() -> Result<Settings, String> {
crate::settings::get_settings()
}
#[tauri::command]
pub fn save_settings(settings: Settings) -> Result<(), String> {
crate::settings::save_settings(settings)
}
#[tauri::command]
pub fn get_ai_workspace_sessions() -> Result<serde_json::Value, String> {
crate::settings::get_ai_workspace_sessions()
}
#[tauri::command]
pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> {
crate::settings::save_ai_workspace_sessions(sessions)
}
#[tauri::command]
pub fn append_hldp_heartbeat(
input: crate::hldp_runtime::HldpHeartbeatInput,
) -> Result<crate::hldp_runtime::HldpWriteReceipt, String> {
crate::hldp_runtime::append_hldp_heartbeat(input)
}
#[tauri::command]
pub fn read_hldp_session(
session_id: String,
limit: Option<usize>,
) -> Result<Vec<serde_json::Value>, String> {
crate::hldp_runtime::read_hldp_session(&session_id, limit)
}
#[tauri::command]
pub fn finalize_hldp_session(
input: crate::hldp_runtime::HldpFinalizeInput,
) -> Result<crate::hldp_runtime::HldpWriteReceipt, String> {
crate::hldp_runtime::finalize_hldp_session(input)
}
#[tauri::command]
pub fn prune_hldp_session(session_id: String) -> Result<(), String> {
crate::hldp_runtime::prune_hldp_session(&session_id)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_for_app_update(
app_handle: tauri::AppHandle,
release_channel: Option<String>,
) -> Result<Option<crate::app_updater::AppUpdateMetadata>, String> {
crate::app_updater::check_for_app_update(app_handle, release_channel).await
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_for_app_update(
_app_handle: tauri::AppHandle,
_release_channel: Option<String>,
) -> Result<Option<crate::app_updater::AppUpdateMetadata>, String> {
Ok(None)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn download_and_install_app_update(
app_handle: tauri::AppHandle,
release_channel: Option<String>,
expected_version: String,
on_event: Channel<crate::app_updater::AppUpdateDownloadEvent>,
) -> Result<(), String> {
crate::app_updater::download_and_install_app_update(
app_handle,
release_channel,
expected_version,
on_event,
)
.await
}
#[cfg(mobile)]
#[tauri::command]
pub async fn download_and_install_app_update(
_app_handle: tauri::AppHandle,
_release_channel: Option<String>,
_expected_version: String,
_on_event: tauri::ipc::Channel<crate::app_updater::AppUpdateDownloadEvent>,
) -> Result<(), String> {
Err("App updates are not available on mobile".into())
}
#[tauri::command]
pub fn reinit_telemetry() {
crate::telemetry::reinit_sentry();
}
#[tauri::command]
pub fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(desktop)]
use std::cell::RefCell;
#[cfg(desktop)]
use std::os::unix::process::ExitStatusExt;
#[cfg(desktop)]
use std::process::{ExitStatus, Output};
#[cfg(desktop)]
use std::rc::Rc;
#[test]
fn parses_title_bar_action_values() {
for (value, expected) in [
("Fill", Some(TitleBarDoubleClickAction::Fill)),
("zoom", Some(TitleBarDoubleClickAction::Fill)),
("Minimize", Some(TitleBarDoubleClickAction::Minimize)),
("No Action", Some(TitleBarDoubleClickAction::None)),
("tile", None),
] {
assert_eq!(parse_title_bar_double_click_action(value), expected);
}
for (value, expected) in [
("1", Some(TitleBarDoubleClickAction::Minimize)),
("false", Some(TitleBarDoubleClickAction::Fill)),
("maybe", None),
] {
assert_eq!(parse_legacy_title_bar_double_click_action(value), expected);
}
}
#[test]
fn resolves_title_bar_action_preferences() {
assert_eq!(
resolve_with(&[
("AppleActionOnDoubleClick", "No Action"),
("AppleMiniaturizeOnDoubleClick", "1"),
]),
TitleBarDoubleClickAction::None
);
assert_eq!(
resolve_with(&[("AppleMiniaturizeOnDoubleClick", "1")]),
TitleBarDoubleClickAction::Minimize
);
assert_eq!(
resolve_with(&[
("AppleActionOnDoubleClick", "tile"),
("AppleMiniaturizeOnDoubleClick", "1"),
]),
TitleBarDoubleClickAction::Minimize
);
assert_eq!(resolve_with(&[]), TitleBarDoubleClickAction::Fill);
}
#[test]
fn parses_defaults_output_variants() {
for (code, stdout, expected) in [
(0, b" Maximize \n".to_vec(), Some("Maximize")),
(1, b"Minimize\n".to_vec(), None),
(0, b" \n".to_vec(), None),
(0, vec![0xff], None),
] {
assert_eq!(
parse_defaults_read_output(output(code, stdout)),
expected.map(str::to_string)
);
}
}
#[test]
fn routes_title_bar_actions_to_expected_window_calls() {
for (action, state, expected_calls) in [
(
TitleBarDoubleClickAction::Fill,
Ok(false),
vec!["is_maximized", "maximize"],
),
(
TitleBarDoubleClickAction::Fill,
Ok(true),
vec!["is_maximized", "unmaximize"],
),
(
TitleBarDoubleClickAction::Minimize,
Ok(false),
vec!["minimize"],
),
(TitleBarDoubleClickAction::None, Ok(false), Vec::new()),
] {
let (result, calls) = run_action(action, state, Ok(()), Ok(()), Ok(()));
assert_eq!(result, Ok(()));
assert_eq!(calls, expected_calls);
}
}
#[test]
fn skips_min_size_updates_for_windows_fullscreen_or_maximized_windows() {
for (is_fullscreen, is_maximized) in [(true, false), (false, true), (true, true)] {
assert!(!should_apply_window_min_size_constraints(
true,
is_fullscreen,
is_maximized
));
}
assert!(should_apply_window_min_size_constraints(true, false, false));
assert!(should_apply_window_min_size_constraints(false, true, true));
}
#[test]
fn propagates_title_bar_action_errors() {
for (state, maximize, unmaximize, expected) in [
(Err("state"), Ok(()), Ok(()), "state"),
(Ok(false), Err("maximize"), Ok(()), "maximize"),
(Ok(true), Ok(()), Err("unmaximize"), "unmaximize"),
] {
let (result, _) = run_action(
TitleBarDoubleClickAction::Fill,
state,
maximize,
unmaximize,
Ok(()),
);
assert_eq!(result, Err(expected.to_string()));
}
}
fn exit_status(code: i32) -> ExitStatus {
ExitStatus::from_raw(code << 8)
}
fn output(code: i32, stdout: Vec<u8>) -> Output {
Output {
status: exit_status(code),
stdout,
stderr: Vec::new(),
}
}
fn resolve_with(values: &[(&str, &str)]) -> TitleBarDoubleClickAction {
resolve_title_bar_double_click_action(|key| {
values
.iter()
.find(|(candidate, _)| *candidate == key)
.map(|(_, value)| (*value).to_string())
})
}
fn run_action(
action: TitleBarDoubleClickAction,
state: Result<bool, &'static str>,
maximize: Result<(), &'static str>,
unmaximize: Result<(), &'static str>,
minimize: Result<(), &'static str>,
) -> (Result<(), String>, Vec<&'static str>) {
let calls = Rc::new(RefCell::new(Vec::new()));
let state_calls = Rc::clone(&calls);
let maximize_calls = Rc::clone(&calls);
let unmaximize_calls = Rc::clone(&calls);
let minimize_calls = Rc::clone(&calls);
let result = apply_title_bar_double_click_action(
action,
move || {
state_calls.borrow_mut().push("is_maximized");
state.map_err(str::to_string)
},
move || {
maximize_calls.borrow_mut().push("maximize");
maximize.map_err(str::to_string)
},
move || {
unmaximize_calls.borrow_mut().push("unmaximize");
unmaximize.map_err(str::to_string)
},
move || {
minimize_calls.borrow_mut().push("minimize");
minimize.map_err(str::to_string)
},
);
let call_log = calls.borrow().clone();
(result, call_log)
}
}

View file

@ -0,0 +1,397 @@
mod boundary;
mod file_cmds;
mod frontmatter_cmds;
mod lifecycle_cmds;
mod rename_cmds;
mod scan_cmds;
mod view_cmds;
pub(super) use boundary::VaultBoundary;
pub use file_cmds::*;
pub use frontmatter_cmds::*;
pub use lifecycle_cmds::*;
pub use rename_cmds::*;
pub use scan_cmds::*;
pub use view_cmds::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::ViewDefinition;
use std::path::Path;
const ACTIVE_VAULT_PATH_ERROR: &str = super::boundary::ACTIVE_VAULT_PATH_ERROR;
const INVALID_VIEW_FILENAME_ERROR: &str = super::boundary::INVALID_VIEW_FILENAME_ERROR;
fn vault_path_arg(vault_path: &Path) -> Option<std::path::PathBuf> {
Some(vault_path.to_path_buf())
}
fn vault_path_string_arg(vault_path: &Path) -> Option<String> {
Some(vault_path.to_string_lossy().to_string())
}
fn assert_note_write_rejects_escape<T: std::fmt::Debug>(
action: impl FnOnce(std::path::PathBuf, String, Option<std::path::PathBuf>) -> Result<T, String>,
) {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = action(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal write to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
fn sample_view_definition() -> ViewDefinition {
ViewDefinition {
name: "Inbox".to_string(),
icon: None,
color: None,
order: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),
}
}
fn assert_save_view_cmd_rejects_invalid_filename(filename: &str) {
let dir = tempfile::TempDir::new().unwrap();
let err = save_view_cmd(
dir.path().to_string_lossy().to_string(),
filename.to_string(),
sample_view_definition(),
)
.expect_err("expected invalid filename to be rejected");
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
}
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
std::fs::write(&note, body).unwrap();
(dir, note)
}
fn assert_paths_exist(root: &Path, paths: &[&str]) {
for path in paths {
assert!(root.join(path).exists(), "{path} should exist");
}
}
fn assert_paths_absent(root: &Path, paths: &[&str]) {
for path in paths {
assert!(!root.join(path).exists(), "{path} should be absent");
}
}
fn assert_seeded_guidance_content(vault_path: &Path) {
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
let claude = std::fs::read_to_string(vault_path.join("CLAUDE.md")).unwrap();
assert!(agents.contains("Use the first H1 as the note title."));
assert!(agents.contains("HoloLake Era reads notes recursively from all folders"));
assert!(agents.contains("views/*.yml"));
assert!(claude.starts_with("---\ntype: Note\n_organized: true\n---"));
assert!(claude.contains("@AGENTS.md"));
assert!(claude.contains("only a Claude Code compatibility shim"));
assert!(!claude.contains("# CLAUDE.md"));
}
fn assert_seeded_type_scaffolding(vault_path: &Path) {
let type_definition = std::fs::read_to_string(vault_path.join("type.md")).unwrap();
assert!(type_definition.contains("visible: false"));
assert!(type_definition.contains("# Type"));
}
#[test]
fn test_batch_archive_notes() {
let (dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
assert_eq!(
batch_archive_notes(
vec![note.to_str().unwrap().to_string()],
vault_path_string_arg(dir.path()),
)
.unwrap(),
1
);
let content = std::fs::read_to_string(&note).unwrap();
assert!(content.contains("_archived: true"));
assert!(content.contains("Status: Active"));
}
#[test]
fn test_reload_vault_entry_reads_from_disk() {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("test.md");
std::fs::write(&note, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap();
let entry = reload_vault_entry(note.clone(), vault_path_arg(dir.path())).unwrap();
assert_eq!(entry.title, "Test");
assert_eq!(entry.status, Some("Active".to_string()));
std::fs::write(&note, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap();
let fresh = reload_vault_entry(note, vault_path_arg(dir.path())).unwrap();
assert_eq!(fresh.status, Some("Done".to_string()));
}
#[test]
fn test_reload_vault_entry_nonexistent() {
let result = reload_vault_entry("/nonexistent/path.md".into(), None);
assert!(result.is_err());
}
#[test]
fn test_get_note_content_rejects_path_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let inside = vault_path.join("inside.md");
let outside_dir = tempfile::TempDir::new().unwrap();
let outside = outside_dir.path().join("outside.md");
std::fs::write(&inside, "# Inside\n").unwrap();
std::fs::write(&outside, "# Outside\n").unwrap();
let err = get_note_content(outside, vault_path_arg(vault_path))
.expect_err("expected out-of-vault read to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[tokio::test]
async fn test_save_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = save_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.await
.expect_err("expected traversal write to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_note_content_rejects_traversal_outside_active_vault() {
assert_note_write_rejects_escape(create_note_content);
}
#[test]
fn test_create_vault_folder_rejects_escape_path() {
let dir = tempfile::TempDir::new().unwrap();
let err = create_vault_folder(dir.path().into(), "../escape".into(), None)
.expect_err("expected escaping folder path to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_vault_folder_rejects_windows_invalid_names() {
let dir = tempfile::TempDir::new().unwrap();
let err = create_vault_folder(dir.path().into(), "con".into(), None)
.expect_err("expected Windows-invalid folder name to be rejected");
assert_eq!(err, "Invalid folder name");
}
#[test]
fn test_create_vault_folder_nests_inside_parent_path() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("Projects")).unwrap();
let name = create_vault_folder(
dir.path().into(),
"Laputa".into(),
Some(std::path::PathBuf::from("Projects")),
)
.expect("expected nested folder to be created");
assert_eq!(name, "Laputa");
assert!(dir.path().join("Projects").join("Laputa").is_dir());
}
#[test]
fn test_create_vault_folder_rejects_escape_via_parent_path() {
let dir = tempfile::TempDir::new().unwrap();
let err = create_vault_folder(
dir.path().into(),
"Laputa".into(),
Some(std::path::PathBuf::from("../escape")),
)
.expect_err("expected escaping parent path to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_vault_folder_treats_empty_parent_as_root() {
let dir = tempfile::TempDir::new().unwrap();
let name = create_vault_folder(
dir.path().into(),
"Inbox".into(),
Some(std::path::PathBuf::from("")),
)
.expect("expected empty parent to fall back to vault root");
assert_eq!(name, "Inbox");
assert!(dir.path().join("Inbox").is_dir());
}
#[test]
fn test_save_view_cmd_rejects_nested_filename() {
assert_save_view_cmd_rejects_invalid_filename("../escape.yml");
}
#[test]
fn test_save_view_cmd_rejects_windows_invalid_filename() {
assert_save_view_cmd_rejects_invalid_filename("con.yml");
}
#[tokio::test]
async fn test_reload_vault_invalidates_cache_and_rescans() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "t@t.com"])
.current_dir(vault_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "T"])
.current_dir(vault_path)
.output()
.unwrap();
let cache_dir = tempfile::TempDir::new().unwrap();
std::env::set_var(
"LAPUTA_CACHE_DIR",
cache_dir.path().to_string_lossy().as_ref(),
);
std::fs::write(
vault_path.join("note.md"),
"---\n_archived: false\n---\n# Note\n",
)
.unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault_path)
.output()
.unwrap();
let entries = list_vault(vault_path.into()).await.unwrap();
assert!(!entries[0].archived);
std::fs::write(
vault_path.join("note.md"),
"---\n_archived: true\n---\n# Note\n",
)
.unwrap();
let vp_str = vault_path.to_str().unwrap();
crate::vault::invalidate_cache(std::path::Path::new(vp_str));
let fresh = crate::vault::scan_vault_cached(std::path::Path::new(vp_str)).unwrap();
assert!(
fresh[0].archived,
"reload_vault must reflect disk state after archiving"
);
}
#[test]
fn test_check_vault_exists_false() {
assert!(!check_vault_exists("/nonexistent/path/abc123".to_string()));
}
#[test]
fn test_get_default_vault_path_returns_ok() {
let result = get_default_vault_path();
assert!(result.is_ok());
}
#[test]
fn test_repair_vault_migrates_is_a_to_type() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let note_dir = vault_path.join("note");
std::fs::create_dir_all(&note_dir).unwrap();
std::fs::write(note_dir.join("hello.md"), "---\nis_a: Note\n---\n# Hello\n").unwrap();
let result = repair_vault(vault_path.to_str().unwrap().to_string());
assert!(result.is_ok());
assert!(note_dir.join("hello.md").exists());
let content = std::fs::read_to_string(note_dir.join("hello.md")).unwrap();
assert!(content.contains("type: Note"));
assert!(!content.contains("is_a:"));
}
#[test]
fn test_repair_vault_creates_config_files() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let result = repair_vault(vault_path.to_str().unwrap().to_string());
assert!(result.is_ok());
assert_paths_exist(
vault_path,
&["AGENTS.md", "CLAUDE.md", "type.md", "note.md", ".gitignore"],
);
assert_paths_absent(vault_path, &["config.md"]);
}
#[test]
fn test_create_empty_vault_seeds_agents_and_type_scaffolding() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("fresh-vault");
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
assert!(result.is_ok());
assert_paths_exist(
&vault_path,
&[".git", "AGENTS.md", "CLAUDE.md", "type.md", "note.md"],
);
assert_paths_absent(&vault_path, &["config.md"]);
assert_seeded_guidance_content(&vault_path);
assert_seeded_type_scaffolding(&vault_path);
}
#[test]
fn test_create_empty_vault_rejects_nonempty_target() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("existing-folder");
std::fs::create_dir_all(&vault_path).unwrap();
std::fs::write(vault_path.join("keep.txt"), "keep").unwrap();
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
let err = result.expect_err("expected non-empty folder to be rejected");
assert_eq!(err, "Choose an empty folder to create a new vault");
assert_paths_exist(&vault_path, &["keep.txt"]);
assert_paths_absent(&vault_path, &[".git", "AGENTS.md"]);
}
}

View file

@ -0,0 +1,460 @@
use crate::commands::expand_tilde;
use crate::vault::filename_rules::validate_view_filename_stem;
use crate::vault_list;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
pub(crate) const ACTIVE_VAULT_PATH_ERROR: &str = "Path must stay inside the active vault";
const ACTIVE_VAULT_MISMATCH_ERROR: &str = "Vault path must match the active vault";
const ACTIVE_VAULT_UNAVAILABLE_ERROR: &str = "Active vault is not available";
const NO_ACTIVE_VAULT_ERROR: &str = "No active vault selected";
pub(crate) const INVALID_VIEW_FILENAME_ERROR: &str = "Invalid view filename";
#[derive(Clone, Debug)]
struct VaultRootPaths {
requested: PathBuf,
canonical: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct VaultBoundary {
requested_root: PathBuf,
canonical_root: PathBuf,
}
impl VaultBoundary {
pub(crate) fn from_request(requested_vault_path: Option<&str>) -> Result<Self, String> {
let configured_root = if cfg!(test) {
None
} else {
load_configured_active_vault_root()?
};
let requested_root = requested_vault_path
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()?;
let root = match (configured_root, requested_root) {
(Some(configured), Some(requested)) => {
if configured.canonical != requested.canonical
&& !is_registered_vault_root(&requested)?
{
return Err(ACTIVE_VAULT_MISMATCH_ERROR.to_string());
}
requested
}
(Some(configured), None) => configured,
(None, Some(requested)) => requested,
(None, None) => return Err(NO_ACTIVE_VAULT_ERROR.to_string()),
};
Ok(Self {
requested_root: root.requested,
canonical_root: root.canonical,
})
}
pub(crate) fn requested_root(&self) -> &Path {
&self.requested_root
}
fn requested_root_str(&self) -> String {
path_to_string(&self.requested_root)
}
fn validate_existing_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, false)
}
pub(crate) fn validate_existing_paths(
&self,
raw_paths: &[String],
) -> Result<Vec<String>, String> {
raw_paths
.iter()
.map(|path| self.validate_existing_path(path))
.collect()
}
fn validate_writable_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, true)
}
pub(crate) fn child_path(&self, relative_path: &str) -> Result<PathBuf, String> {
validate_relative_child_path(relative_path)?;
let requested = self.requested_root.join(relative_path);
let canonical = canonicalize_candidate_for_write(&requested)?;
self.ensure_within_root(&canonical)?;
Ok(requested)
}
fn validate_path(&self, raw_path: &str, allow_missing_leaf: bool) -> Result<String, String> {
let requested = self.requested_path(raw_path);
let canonical = if allow_missing_leaf {
canonicalize_candidate_for_write(&requested)?
} else {
requested
.canonicalize()
.map_err(|_| "File does not exist".to_string())?
};
self.ensure_within_root(&canonical)?;
Ok(path_to_string(&requested))
}
fn requested_path(&self, raw_path: &str) -> PathBuf {
let expanded = PathBuf::from(expand_tilde(raw_path).into_owned());
if expanded.is_absolute() {
expanded
} else {
self.requested_root.join(expanded)
}
}
fn ensure_within_root(&self, candidate: &Path) -> Result<(), String> {
candidate
.strip_prefix(&self.canonical_root)
.map(|_| ())
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())
}
}
fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
list.active_vault
.as_deref()
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()
}
fn load_registered_vault_roots() -> Result<Vec<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
Ok(registered_vault_roots(&list))
}
fn push_unique_vault_root_path(paths: &mut Vec<String>, path: String) {
if path.trim().is_empty() || paths.iter().any(|existing| existing == &path) {
return;
}
paths.push(path);
}
#[cfg(all(debug_assertions, not(test)))]
fn local_dev_demo_vault_path() -> Option<String> {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.map(|root| root.join("demo-vault-v2").to_string_lossy().into_owned())
}
#[cfg(not(all(debug_assertions, not(test))))]
fn local_dev_demo_vault_path() -> Option<String> {
None
}
fn configured_vault_root_paths(list: &vault_list::VaultList) -> Vec<String> {
let mut paths = Vec::new();
for entry in &list.vaults {
push_unique_vault_root_path(&mut paths, entry.path.clone());
}
if let Some(active_vault) = &list.active_vault {
push_unique_vault_root_path(&mut paths, active_vault.clone());
}
for hidden_default in &list.hidden_defaults {
push_unique_vault_root_path(&mut paths, hidden_default.clone());
}
#[cfg(not(test))]
if let Ok(default_path) = crate::vault::default_vault_path() {
push_unique_vault_root_path(&mut paths, default_path.to_string_lossy().into_owned());
}
if let Some(dev_demo_path) = local_dev_demo_vault_path() {
push_unique_vault_root_path(&mut paths, dev_demo_path);
}
paths
}
fn registered_vault_roots(list: &vault_list::VaultList) -> Vec<VaultRootPaths> {
configured_vault_root_paths(list)
.into_iter()
.filter(|path| !path.trim().is_empty())
.filter_map(|path| build_vault_root_paths(&path).ok())
.collect()
}
fn is_registered_vault_root(requested: &VaultRootPaths) -> Result<bool, String> {
let list = vault_list::load_vault_list()?;
for root in registered_vault_roots(&list) {
if root.canonical == requested.canonical {
return Ok(true);
}
}
Ok(false)
}
fn find_registered_root_for_absolute_path(
raw_path: &str,
) -> Result<Option<VaultRootPaths>, String> {
let requested = PathBuf::from(expand_tilde(raw_path).into_owned());
if !requested.is_absolute() {
return Ok(None);
}
let canonical = canonicalize_candidate_for_write(&requested)?;
let roots = match load_registered_vault_roots() {
Ok(roots) => roots,
Err(_) => return Ok(None),
};
let root = roots
.into_iter()
.filter(|root| canonical.starts_with(&root.canonical))
.max_by_key(|root| root.canonical.components().count());
Ok(root)
}
fn build_vault_root_paths(raw_vault_path: &str) -> Result<VaultRootPaths, String> {
let requested = PathBuf::from(expand_tilde(raw_vault_path).into_owned());
let canonical = requested
.canonicalize()
.map_err(|_| ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string())?;
if !canonical.is_dir() {
return Err(ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string());
}
Ok(VaultRootPaths {
requested,
canonical,
})
}
fn canonicalize_candidate_for_write(path: &Path) -> Result<PathBuf, String> {
let (ancestor, tail) = find_existing_ancestor(path)?;
Ok(tail
.into_iter()
.fold(ancestor, |current, segment| current.join(segment)))
}
fn find_existing_ancestor(path: &Path) -> Result<(PathBuf, Vec<OsString>), String> {
let mut current = path;
let mut tail = Vec::new();
loop {
if current.exists() {
let canonical = current
.canonicalize()
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.reverse();
return Ok((canonical, tail));
}
let file_name = current
.file_name()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.push(file_name.to_os_string());
current = current
.parent()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
}
}
fn validate_relative_child_path(relative_path: &str) -> Result<(), String> {
if relative_path.trim().is_empty() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
let path = Path::new(relative_path);
if path.is_absolute() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
if path.components().any(|component| {
matches!(
component,
Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
Ok(())
}
pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> {
if !filename.ends_with(".yml") {
return Err("Filename must end with .yml".to_string());
}
let path = Path::new(filename);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(value)), None) => {
let stem = value.to_string_lossy();
let stem = stem.strip_suffix(".yml").unwrap_or(&stem);
validate_view_filename_stem(stem)
}
_ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()),
}
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub(crate) fn with_boundary<T>(
requested_vault_path: Option<&str>,
action: impl FnOnce(&VaultBoundary) -> Result<T, String>,
) -> Result<T, String> {
let boundary = VaultBoundary::from_request(requested_vault_path)?;
action(&boundary)
}
pub(crate) enum ValidatedPathMode {
Existing,
Writable,
}
pub(crate) fn with_validated_path<T>(
path: &str,
vault_path: Option<&str>,
mode: ValidatedPathMode,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
if vault_path.is_none() {
if let Some(root) = find_registered_root_for_absolute_path(path)? {
let boundary = VaultBoundary {
requested_root: root.requested,
canonical_root: root.canonical,
};
let validated_path = match mode {
ValidatedPathMode::Existing => boundary.validate_existing_path(path)?,
ValidatedPathMode::Writable => boundary.validate_writable_path(path)?,
};
return action(&validated_path);
}
}
with_boundary(vault_path, |boundary| {
let validated_path = match mode {
ValidatedPathMode::Existing => boundary.validate_existing_path(path)?,
ValidatedPathMode::Writable => boundary.validate_writable_path(path)?,
};
action(&validated_path)
})
}
pub(crate) fn with_existing_paths<T>(
paths: &[String],
vault_path: Option<&str>,
action: impl FnOnce(Vec<String>) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(vault_path, |boundary| {
let validated_paths = boundary.validate_existing_paths(paths)?;
action(validated_paths)
})
}
pub(crate) fn with_requested_root<T>(
vault_path: &str,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
let requested_root = boundary.requested_root_str();
action(&requested_root)
})
}
pub(crate) fn with_existing_path_in_requested_vault<T>(
vault_path: &str,
path: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
let requested_validation = with_boundary(Some(vault_path), |boundary| {
Ok((
boundary.requested_root_str(),
boundary.validate_existing_path(path)?,
))
});
let validated = match requested_validation {
Ok(validated) => validated,
Err(error) if error == ACTIVE_VAULT_PATH_ERROR || error == ACTIVE_VAULT_MISMATCH_ERROR => {
let Some(root) = find_registered_root_for_absolute_path(path)? else {
return Err(error);
};
let boundary = VaultBoundary {
requested_root: root.requested,
canonical_root: root.canonical,
};
(
boundary.requested_root_str(),
boundary.validate_existing_path(path)?,
)
}
Err(error) => return Err(error),
};
action(&validated.0, &validated.1)
}
pub(crate) fn with_view_file<T>(
vault_path: &str,
filename: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
validate_view_filename(filename)?;
let requested_root = boundary.requested_root_str();
action(&requested_root, filename)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault_list::{VaultEntry, VaultList};
#[test]
fn registered_vault_roots_skip_unavailable_vaults() {
let available = tempfile::TempDir::new().unwrap();
let missing = available.path().join("missing-vault");
let list = VaultList {
vaults: vec![
VaultEntry {
label: "Missing".to_string(),
path: missing.to_string_lossy().to_string(),
..Default::default()
},
VaultEntry {
label: "Available".to_string(),
path: available.path().to_string_lossy().to_string(),
..Default::default()
},
],
active_vault: None,
default_workspace_path: None,
hidden_defaults: vec![],
};
let roots = registered_vault_roots(&list);
assert!(roots
.iter()
.any(|root| root.canonical == available.path().canonicalize().unwrap()));
assert!(roots.iter().all(|root| root.canonical != missing));
}
#[test]
fn registered_vault_roots_include_hidden_default_vaults() {
let hidden = tempfile::TempDir::new().unwrap();
let list = VaultList {
vaults: Vec::new(),
active_vault: None,
default_workspace_path: None,
hidden_defaults: vec![hidden.path().to_string_lossy().to_string()],
};
let roots = registered_vault_roots(&list);
assert!(roots
.iter()
.any(|root| root.canonical == hidden.path().canonicalize().unwrap()));
}
}

View file

@ -0,0 +1,458 @@
use crate::commands::expand_tilde;
use crate::vault::filename_rules::validate_folder_name;
use crate::vault::{self, FolderNode, VaultEntry};
use std::path::{Path, PathBuf};
use super::boundary::{
with_boundary, with_existing_paths, with_requested_root, with_validated_path, ValidatedPathMode,
};
fn with_note_path<T>(
path: &Path,
vault_path: Option<&Path>,
mode: ValidatedPathMode,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let raw_vault_path = vault_path.map(|value| value.to_string_lossy());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
mode,
|validated_path| action(Path::new(validated_path)),
)
}
fn with_external_file_path<T>(
path: &Path,
vault_path: Option<&Path>,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
with_note_path(path, vault_path, ValidatedPathMode::Existing, action)
}
fn with_expanded_vault_root<T>(
path: &Path,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let expanded = expand_tilde(raw_path.as_ref()).into_owned();
action(Path::new(&expanded))
}
fn with_requested_root_path<T>(
vault_path: &Path,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_requested_root(raw_vault_path.as_ref(), action)
}
fn sync_image_asset_scope(
app_handle: &tauri::AppHandle,
requested_root: &str,
) -> Result<(), String> {
#[cfg(desktop)]
crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?;
#[cfg(not(desktop))]
let _ = requested_root;
#[cfg(not(desktop))]
let _ = app_handle;
Ok(())
}
fn with_image_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
action: impl FnOnce(&str) -> Result<String, String>,
) -> Result<String, String> {
with_requested_root_path(vault_path, |requested_root| {
let saved_path = action(requested_root)?;
sync_image_asset_scope(app_handle, requested_root)?;
Ok(saved_path)
})
}
#[tauri::command]
pub fn sync_vault_asset_scope_for_window(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
) -> Result<(), String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
sync_image_asset_scope(&app_handle, requested_root)
})
}
#[tauri::command]
pub fn open_vault_file_external(
app_handle: tauri::AppHandle,
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_external_file_path(path.as_path(), vault_path.as_deref(), |validated_path| {
open_path_with_default_app(&app_handle, validated_path)
})
}
fn open_path_with_default_app(app_handle: &tauri::AppHandle, path: &Path) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
app_handle
.opener()
.open_path(path.to_string_lossy().into_owned(), None::<String>)
.map_err(|error| error.to_string())
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
vault_path
.as_ref()
.map(|value| value.to_string_lossy())
.as_deref(),
ValidatedPathMode::Writable,
action,
)
}
#[tauri::command]
pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<String, String> {
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
vault::get_note_content,
)
}
#[tauri::command]
pub fn validate_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<bool, String> {
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| vault::note_content_matches(validated_path, &content),
)
}
#[tauri::command]
pub async fn save_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
with_writable_note_path(path, vault_path, |validated_path| {
vault::save_note_content(validated_path, &content)
})
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub fn create_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::create_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn delete_note(path: PathBuf) -> Result<String, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
None,
ValidatedPathMode::Existing,
vault::delete_note,
)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<PathBuf>) -> Result<Vec<String>, String> {
let raw_paths = paths
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
with_existing_paths(&raw_paths, None, |validated_paths| {
vault::batch_delete_notes(&validated_paths)
})
}
#[tauri::command]
pub fn create_vault_folder(
vault_path: PathBuf,
folder_name: PathBuf,
parent_path: Option<PathBuf>,
) -> Result<String, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
let folder_name = folder_name.to_string_lossy();
let relative_path = match parent_path.as_deref() {
Some(parent) if !parent.as_os_str().is_empty() => parent.join(folder_name.as_ref()),
_ => PathBuf::from(folder_name.as_ref()),
};
let folder_path = boundary.child_path(&relative_path.to_string_lossy())?;
validate_folder_name(folder_name.as_ref())?;
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
std::fs::create_dir_all(&folder_path)
.map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name.into_owned())
})
}
fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
Ok(())
}
fn scan_visible_vault_entries(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
}
fn scan_visible_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
let folders = vault::scan_vault_folders(vault_path)?;
Ok(vault::filter_gitignored_folders(
vault_path,
folders,
crate::settings::hide_gitignored_files_enabled(),
))
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<bool, String> {
use vault::SyncAction;
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| {
let action = vault::sync_title_on_open(validated_path)?;
Ok(matches!(action, SyncAction::Updated { .. }))
},
)
}
#[tauri::command]
pub fn save_image(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
filename: String,
data: String,
) -> Result<String, String> {
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
vault::save_image(requested_root, &filename, &data)
})
}
#[tauri::command]
pub fn copy_image_to_vault(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
source_path: PathBuf,
) -> Result<String, String> {
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
})
}
#[tauri::command]
pub async fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
tokio::task::spawn_blocking(move || {
with_expanded_vault_root(path.as_path(), scan_visible_vault_entries)
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub async fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
tokio::task::spawn_blocking(move || {
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn vault_root(dir: &TempDir) -> PathBuf {
dir.path().to_path_buf()
}
fn note_path(dir: &TempDir, name: &str) -> PathBuf {
dir.path().join(name)
}
#[tokio::test]
async fn note_content_commands_roundtrip_with_requested_vault() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = note_path(&dir, "notes/command-note.md");
create_note_content(
note.clone(),
"# Command Note\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert_eq!(
get_note_content(note.clone(), Some(root.clone())).unwrap(),
"# Command Note\n"
);
save_note_content(
note.clone(),
"---\ntitle: Command Note\n---\n# Command Note\nBody\n".to_string(),
Some(root.clone()),
)
.await
.unwrap();
assert!(!sync_note_title(note.clone(), Some(root.clone())).unwrap());
save_note_content(
note.clone(),
"# Updated Command Note\n".to_string(),
Some(root.clone()),
)
.await
.unwrap();
assert!(sync_note_title(note.clone(), Some(root.clone())).unwrap());
assert!(get_note_content(note, Some(root))
.unwrap()
.contains("title: Command Note"));
}
#[tokio::test]
async fn note_content_commands_accept_windows_sensitive_valid_segments() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = root
.join("@raflymln")
.join("notes with spaces")
.join("résumé note.md");
save_note_content(
note.clone(),
"# Windows-Sensitive Path\n\nBody\n".to_string(),
Some(root.clone()),
)
.await
.unwrap();
assert_eq!(
get_note_content(note, Some(root)).unwrap(),
"# Windows-Sensitive Path\n\nBody\n"
);
}
#[tokio::test]
async fn folder_and_listing_commands_use_expanded_vault_root() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
assert_eq!(
create_vault_folder(root.clone(), PathBuf::from("Projects"), None).unwrap(),
"Projects"
);
fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap();
let entries = list_vault(root.clone()).await.unwrap();
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
let folders = list_vault_folders(root).await.unwrap();
assert!(folders.iter().any(|folder| folder.name == "Projects"));
}
#[test]
fn commands_reject_paths_outside_requested_vault() {
let vault = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_note = outside.path().join("outside.md");
fs::write(&outside_note, "# Outside\n").unwrap();
let error = get_note_content(outside_note, Some(vault.path().to_path_buf())).unwrap_err();
assert!(error.contains("Path must stay inside the active vault"));
let folder_error =
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"), None)
.unwrap_err();
assert!(folder_error.contains("Path must stay inside the active vault"));
}
#[test]
fn external_file_paths_accept_files_inside_requested_vault() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let attachment = note_path(&dir, "attachments/photo.png");
fs::create_dir_all(attachment.parent().unwrap()).unwrap();
fs::write(&attachment, "image-bytes").unwrap();
let validated = with_external_file_path(
attachment.as_path(),
Some(root.as_path()),
|validated_path| Ok(validated_path.to_path_buf()),
)
.unwrap();
assert_eq!(validated, attachment);
}
#[test]
fn external_file_paths_reject_files_outside_requested_vault() {
let vault = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("photo.png");
fs::write(&outside_file, "image-bytes").unwrap();
let error = with_external_file_path(
outside_file.as_path(),
Some(vault.path()),
|validated_path| Ok(validated_path.to_path_buf()),
)
.unwrap_err();
assert!(error.contains("Path must stay inside the active vault"));
}
#[test]
fn validate_note_content_compares_against_disk() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = note_path(&dir, "note.md");
fs::write(&note, "# Fresh\n").unwrap();
assert!(
validate_note_content(note.clone(), "# Fresh\n".to_string(), Some(root.clone()),)
.unwrap()
);
assert!(!validate_note_content(note, "# Stale\n".to_string(), Some(root)).unwrap());
}
}

View file

@ -0,0 +1,135 @@
use crate::frontmatter;
use crate::frontmatter::FrontmatterValue;
use super::boundary::{with_existing_paths, with_validated_path, ValidatedPathMode};
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::update_frontmatter(validated_path, &key, value),
)
}
#[tauri::command]
pub fn delete_frontmatter_property(
path: String,
key: String,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::delete_frontmatter_property(validated_path, &key),
)
}
#[tauri::command]
pub fn batch_archive_notes(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<usize, String> {
with_existing_paths(&paths, vault_path.as_deref(), |validated_paths| {
let mut count = 0;
for path in &validated_paths {
frontmatter::update_frontmatter(path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn note_path(dir: &tempfile::TempDir, name: &str) -> String {
dir.path().join(name).to_string_lossy().into_owned()
}
fn write_note(path: &str, content: &str) {
std::fs::write(path, content).unwrap();
}
#[test]
fn update_frontmatter_command_validates_and_updates_note() {
let dir = tempfile::TempDir::new().unwrap();
let path = note_path(&dir, "note.md");
write_note(&path, "---\nStatus: Draft\n---\n# Note\n");
let updated = update_frontmatter(
path.clone(),
"Status".to_string(),
FrontmatterValue::String("Done".to_string()),
Some(dir.path().to_string_lossy().into_owned()),
)
.unwrap();
assert!(updated.contains("Status: Done"));
assert_eq!(std::fs::read_to_string(path).unwrap(), updated);
}
#[test]
fn delete_frontmatter_property_command_removes_existing_key() {
let dir = tempfile::TempDir::new().unwrap();
let path = note_path(&dir, "note.md");
write_note(&path, "---\nStatus: Draft\nOwner: Ada\n---\n# Note\n");
let updated = delete_frontmatter_property(
path,
"Owner".to_string(),
Some(dir.path().to_string_lossy().into_owned()),
)
.unwrap();
assert!(!updated.contains("Owner:"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn batch_archive_notes_command_marks_each_note_archived() {
let dir = tempfile::TempDir::new().unwrap();
let first = note_path(&dir, "first.md");
let second = note_path(&dir, "second.md");
write_note(&first, "---\nStatus: Draft\n---\n# First\n");
write_note(&second, "# Second\n");
let count = batch_archive_notes(
vec![first.clone(), second.clone()],
Some(dir.path().to_string_lossy().into_owned()),
)
.unwrap();
assert_eq!(count, 2);
assert!(std::fs::read_to_string(first)
.unwrap()
.contains("_archived: true"));
assert!(std::fs::read_to_string(second)
.unwrap()
.contains("_archived: true"));
}
#[test]
fn batch_archive_notes_command_rejects_notes_outside_vault() {
let vault = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let outside_note = note_path(&outside, "outside.md");
write_note(&outside_note, "# Outside\n");
let error = batch_archive_notes(
vec![outside_note],
Some(vault.path().to_string_lossy().into_owned()),
)
.unwrap_err();
assert!(error.contains("Path must stay inside the active vault"));
}
}

View file

@ -0,0 +1,156 @@
use crate::commands::expand_tilde;
use crate::{git, vault};
use std::path::Path;
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = Path::new(&path);
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
fn initialize_empty_vault(vault_dir: &Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub async fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = resolve_getting_started_target(target_path.as_deref())?;
tokio::task::spawn_blocking(move || vault::create_getting_started_vault(&path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(std::path::Path::new(vault_path.as_ref()))?;
Ok("Vault repaired".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn empty_vault_target_validation_allows_missing_or_empty_directories() {
let dir = tempfile::TempDir::new().unwrap();
let missing = dir.path().join("new-vault");
let empty = dir.path().join("empty-vault");
fs::create_dir(&empty).unwrap();
assert_eq!(ensure_directory_is_missing_or_empty(&missing), Ok(()));
assert_eq!(ensure_directory_is_missing_or_empty(&empty), Ok(()));
}
#[test]
fn empty_vault_target_validation_rejects_files_and_nonempty_directories() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("vault.md");
let nonempty = dir.path().join("vault");
fs::write(&file, "# Not a folder").unwrap();
fs::create_dir(&nonempty).unwrap();
fs::write(nonempty.join("note.md"), "# Existing note").unwrap();
assert_eq!(
ensure_directory_is_missing_or_empty(&file),
Err("Choose a folder path for the new vault".to_string())
);
assert_eq!(
ensure_directory_is_missing_or_empty(&nonempty),
Err("Choose an empty folder to create a new vault".to_string())
);
}
#[test]
fn canonical_vault_path_uses_existing_canonical_path_or_original_path() {
let dir = tempfile::TempDir::new().unwrap();
let existing = dir.path().join("existing");
let missing = dir.path().join("missing");
fs::create_dir(&existing).unwrap();
assert_eq!(
canonical_vault_path_string(&existing),
existing.canonicalize().unwrap().to_string_lossy()
);
assert_eq!(
canonical_vault_path_string(&missing),
missing.to_string_lossy()
);
}
#[test]
fn getting_started_target_uses_explicit_path_when_provided() {
let dir = tempfile::TempDir::new().unwrap();
let explicit = dir.path().join("starter");
assert_eq!(
resolve_getting_started_target(explicit.to_str()),
Ok(explicit.to_string_lossy().to_string())
);
}
}

View file

@ -0,0 +1,452 @@
use crate::commands::expand_tilde;
use crate::vault::{self, DetectedRename, RenameResult};
use serde::Deserialize;
use std::path::Path;
use super::boundary::{
with_boundary, with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
};
struct RequestedNotePath<'a> {
vault_path: &'a str,
note_path: &'a str,
}
struct ValidatedNotePath<'a> {
vault_path: &'a str,
note_path: &'a str,
}
impl<'a> RequestedNotePath<'a> {
fn new(vault_path: &'a str, note_path: &'a str) -> Self {
Self {
vault_path,
note_path,
}
}
}
fn with_note_path_in_vault<T>(
request: RequestedNotePath<'_>,
action: impl FnOnce(ValidatedNotePath<'_>) -> Result<T, String>,
) -> Result<T, String> {
with_existing_path_in_requested_vault(
request.vault_path,
request.note_path,
|requested_root, validated_path| {
action(ValidatedNotePath {
vault_path: requested_root,
note_path: validated_path,
})
},
)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveNoteToWorkspaceCommandArgs {
source_vault_path: String,
destination_vault_path: String,
old_path: String,
replacement_target: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameNoteCommandArgs {
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameNoteFilenameCommandArgs {
vault_path: String,
old_path: String,
new_filename_stem: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveNoteToFolderCommandArgs {
vault_path: String,
old_path: String,
folder_path: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoRenameUntitledCommandArgs {
vault_path: String,
note_path: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VaultPathCommandArgs {
vault_path: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateWikilinksForRenamesCommandArgs {
vault_path: String,
renames: Vec<DetectedRename>,
}
enum NoteRenameCommandArgs {
Title {
new_title: String,
old_title: Option<String>,
},
Filename {
new_filename_stem: String,
},
}
impl NoteRenameCommandArgs {
fn run(self, note: ValidatedNotePath<'_>) -> Result<RenameResult, String> {
match self {
Self::Title {
new_title,
old_title,
} => vault::rename_note(vault::RenameNoteRequest {
vault_path: note.vault_path,
old_path: note.note_path,
new_title: &new_title,
old_title_hint: old_title.as_deref(),
}),
Self::Filename { new_filename_stem } => {
vault::rename_note_filename(vault::RenameNoteFilenameRequest {
vault_path: note.vault_path,
old_path: note.note_path,
new_filename_stem: &new_filename_stem,
})
}
}
}
}
struct PendingNoteRenameCommand {
vault_path: String,
old_path: String,
args: NoteRenameCommandArgs,
}
enum PublicNoteRenameCommandArgs {
Title(RenameNoteCommandArgs),
Filename(RenameNoteFilenameCommandArgs),
}
fn pending_note_rename(
vault_path: String,
old_path: String,
args: NoteRenameCommandArgs,
) -> PendingNoteRenameCommand {
PendingNoteRenameCommand {
vault_path,
old_path,
args,
}
}
fn rename_existing_note(command: PendingNoteRenameCommand) -> Result<RenameResult, String> {
let request = RequestedNotePath::new(&command.vault_path, &command.old_path);
with_note_path_in_vault(request, |note| command.args.run(note))
}
fn rename_public_note(args: PublicNoteRenameCommandArgs) -> Result<RenameResult, String> {
let command = match args {
PublicNoteRenameCommandArgs::Title(args) => pending_note_rename(
args.vault_path,
args.old_path,
NoteRenameCommandArgs::Title {
new_title: args.new_title,
old_title: args.old_title,
},
),
PublicNoteRenameCommandArgs::Filename(args) => pending_note_rename(
args.vault_path,
args.old_path,
NoteRenameCommandArgs::Filename {
new_filename_stem: args.new_filename_stem,
},
),
};
rename_existing_note(command)
}
#[tauri::command]
pub fn rename_note(args: RenameNoteCommandArgs) -> Result<RenameResult, String> {
rename_public_note(PublicNoteRenameCommandArgs::Title(args))
}
#[tauri::command]
pub fn rename_note_filename(args: RenameNoteFilenameCommandArgs) -> Result<RenameResult, String> {
rename_public_note(PublicNoteRenameCommandArgs::Filename(args))
}
fn run_folder_move(args: MoveNoteToFolderCommandArgs) -> Result<RenameResult, String> {
let request = RequestedNotePath::new(&args.vault_path, &args.old_path);
with_note_path_in_vault(request, |note| {
let trimmed_folder_path = args.folder_path.trim();
if trimmed_folder_path.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let folder_absolute_path = Path::new(note.vault_path).join(trimmed_folder_path);
with_validated_path(
folder_absolute_path.to_string_lossy().as_ref(),
Some(args.vault_path.as_str()),
ValidatedPathMode::Existing,
|validated_folder_path| {
let validated_folder = Path::new(validated_folder_path);
if !validated_folder.is_dir() {
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
}
vault::move_note_to_folder(vault::MoveNoteToFolderRequest {
vault_path: note.vault_path,
old_path: note.note_path,
destination_folder_path: validated_folder_path,
})
},
)
})
}
#[tauri::command]
pub fn move_note_to_folder(args: MoveNoteToFolderCommandArgs) -> Result<RenameResult, String> {
run_folder_move(args)
}
#[tauri::command]
pub fn move_note_to_workspace(
args: MoveNoteToWorkspaceCommandArgs,
) -> Result<RenameResult, String> {
let request = RequestedNotePath::new(&args.source_vault_path, &args.old_path);
with_note_path_in_vault(request, |note| {
let source_root_path = Path::new(note.vault_path);
let old_file = Path::new(note.note_path);
let relative_path = old_file
.strip_prefix(source_root_path)
.map_err(|_| "Path must stay inside the source vault".to_string())?;
let relative_path = relative_path.to_string_lossy();
with_boundary(Some(&args.destination_vault_path), |destination_boundary| {
let destination_path = destination_boundary.child_path(relative_path.as_ref())?;
let destination_root = destination_boundary
.requested_root()
.to_string_lossy()
.into_owned();
let destination_path = destination_path.to_string_lossy().into_owned();
vault::move_note_to_workspace(vault::MoveNoteToWorkspaceRequest {
source_vault_path: note.vault_path,
destination_vault_path: &destination_root,
old_path: note.note_path,
destination_path: &destination_path,
replacement_target: args.replacement_target.as_deref(),
})
})
})
}
#[tauri::command]
pub fn auto_rename_untitled(
args: AutoRenameUntitledCommandArgs,
) -> Result<Option<RenameResult>, String> {
with_existing_path_in_requested_vault(
&args.vault_path,
&args.note_path,
|requested_root, validated_path| {
vault::auto_rename_untitled(vault::AutoRenameUntitledRequest {
vault_path: requested_root,
note_path: validated_path,
})
},
)
}
#[tauri::command]
pub fn detect_renames(args: VaultPathCommandArgs) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&args.vault_path);
vault::detect_renames(Path::new(vault_path.as_ref()))
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
args: UpdateWikilinksForRenamesCommandArgs,
) -> Result<usize, String> {
let vault_path = expand_tilde(&args.vault_path);
vault::update_wikilinks_for_renames(Path::new(vault_path.as_ref()), &args.renames)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn vault_path(dir: &TempDir) -> String {
dir.path().to_string_lossy().into_owned()
}
fn write_note(dir: &TempDir, relative_path: &str, content: &str) -> String {
let path = dir.path().join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn rename_note_command_updates_title_file_and_links() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let old_path = write_note(
&dir,
"old-title.md",
"---\ntitle: Old Title\n---\n# Old Title\n",
);
let linked_path = write_note(&dir, "linked.md", "See [[Old Title]].\n");
let result = rename_note(RenameNoteCommandArgs {
vault_path: vault.clone(),
old_path: old_path.clone(),
new_title: "New Title".to_string(),
old_title: None,
})
.unwrap();
assert!(result.new_path.ends_with("new-title.md"));
assert!(!Path::new(&old_path).exists());
assert!(Path::new(&result.new_path).exists());
assert!(fs::read_to_string(linked_path)
.unwrap()
.contains("[[new-title]]"));
assert_eq!(result.failed_updates, 0);
}
#[test]
fn filename_and_folder_commands_preserve_note_content() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let old_path = write_note(
&dir,
"draft.md",
"---\ntitle: Draft Title\n---\n# Draft Title\n",
);
let renamed = rename_note_filename(RenameNoteFilenameCommandArgs {
vault_path: vault.clone(),
old_path,
new_filename_stem: "custom-name".to_string(),
})
.unwrap();
assert!(renamed.new_path.ends_with("custom-name.md"));
fs::create_dir(dir.path().join("Projects")).unwrap();
let moved = move_note_to_folder(MoveNoteToFolderCommandArgs {
vault_path: vault.clone(),
old_path: renamed.new_path.clone(),
folder_path: "Projects".to_string(),
})
.unwrap();
assert!(moved.new_path.ends_with("Projects/custom-name.md"));
assert!(fs::read_to_string(moved.new_path)
.unwrap()
.contains("Draft Title"));
}
#[test]
fn move_note_to_workspace_command_preserves_relative_path() {
let source = TempDir::new().unwrap();
let destination = TempDir::new().unwrap();
let source_vault = vault_path(&source);
let destination_vault = vault_path(&destination);
let old_path = write_note(
&source,
"Projects/draft.md",
"---\ntitle: Draft Title\n---\n# Draft Title\n",
);
let linked_path = write_note(&source, "linked.md", "See [[Draft Title]].\n");
let moved = move_note_to_workspace(MoveNoteToWorkspaceCommandArgs {
source_vault_path: source_vault,
destination_vault_path: destination_vault.clone(),
old_path: old_path.clone(),
replacement_target: Some("team/Projects/draft".to_string()),
})
.unwrap();
assert!(!Path::new(&old_path).exists());
assert!(moved.new_path.ends_with("Projects/draft.md"));
assert!(moved.new_path.starts_with(&destination_vault));
assert!(fs::read_to_string(moved.new_path)
.unwrap()
.contains("Draft Title"));
assert!(fs::read_to_string(linked_path)
.unwrap()
.contains("[[team/Projects/draft]]"));
}
#[test]
fn auto_rename_and_detected_rename_commands_route_through_vault() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n");
let auto = auto_rename_untitled(AutoRenameUntitledCommandArgs {
vault_path: vault.clone(),
note_path: untitled,
})
.unwrap()
.unwrap();
assert!(auto.new_path.ends_with("project-plan.md"));
crate::git::init_repo(&vault).unwrap();
let old_path = dir.path().join("project-plan.md");
let new_path = dir.path().join("plans.md");
fs::rename(&old_path, &new_path).unwrap();
crate::hidden_command("git")
.args(["add", "-A"])
.current_dir(dir.path())
.output()
.unwrap();
let renames = detect_renames(VaultPathCommandArgs {
vault_path: vault.clone(),
})
.unwrap();
assert_eq!(renames.len(), 1);
assert_eq!(renames[0].old_path, "project-plan.md");
assert_eq!(renames[0].new_path, "plans.md");
assert_eq!(
update_wikilinks_for_renames(UpdateWikilinksForRenamesCommandArgs {
vault_path: vault,
renames,
})
.unwrap(),
0,
);
}
#[test]
fn move_note_to_folder_rejects_empty_folder() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let note = write_note(&dir, "note.md", "# Note\n");
let error = move_note_to_folder(MoveNoteToFolderCommandArgs {
vault_path: vault,
old_path: note,
folder_path: " ".to_string(),
})
.unwrap_err();
assert!(error.contains("Folder path cannot be empty"));
}
}

View file

@ -0,0 +1,364 @@
use crate::commands::expand_tilde;
use crate::search::SearchResponse;
use crate::vault::VaultEntry;
use crate::{search, vault, vault_list};
use std::path::{Path, PathBuf};
use super::boundary::{with_validated_path, ValidatedPathMode};
fn collect_registered_vault_roots(vault_list: &vault_list::VaultList) -> Vec<PathBuf> {
let mut roots = Vec::new();
for entry in &vault_list.vaults {
push_unique_vault_root_path(
&mut roots,
PathBuf::from(expand_tilde(&entry.path).into_owned()),
);
}
if let Some(active_vault) = &vault_list.active_vault {
push_unique_vault_root_path(
&mut roots,
PathBuf::from(expand_tilde(active_vault).into_owned()),
);
}
for hidden_default in &vault_list.hidden_defaults {
push_unique_vault_root_path(
&mut roots,
PathBuf::from(expand_tilde(hidden_default).into_owned()),
);
}
#[cfg(not(test))]
if let Ok(default_path) = vault::default_vault_path() {
push_unique_vault_root_path(&mut roots, default_path);
}
if let Some(dev_demo_path) = local_dev_demo_vault_path() {
push_unique_vault_root_path(&mut roots, dev_demo_path);
}
roots
}
fn push_unique_vault_root_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if paths.iter().any(|existing| existing == &path) {
return;
}
paths.push(path);
}
#[cfg(all(debug_assertions, not(test)))]
fn local_dev_demo_vault_path() -> Option<PathBuf> {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir.parent().map(|root| root.join("demo-vault-v2"))
}
#[cfg(not(all(debug_assertions, not(test))))]
fn local_dev_demo_vault_path() -> Option<PathBuf> {
None
}
fn find_registered_vault_root(path: &Path, registered_roots: &[PathBuf]) -> Option<PathBuf> {
registered_roots
.iter()
.filter_map(|root| {
let canonical_root = root.canonicalize().ok()?;
path.starts_with(&canonical_root)
.then_some((canonical_root.components().count(), root.clone()))
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, root)| root)
}
fn resolve_reload_vault_path(
path: &Path,
vault_path: Option<&Path>,
) -> Result<Option<PathBuf>, String> {
if let Some(vault_path) = vault_path {
return Ok(Some(vault_path.to_path_buf()));
}
if !path.is_absolute() {
return Ok(None);
}
let canonical_path = match path.canonicalize() {
Ok(canonical_path) => canonical_path,
Err(_) => return Ok(None),
};
let vault_list = vault_list::load_vault_list()?;
let registered_roots = collect_registered_vault_roots(&vault_list);
Ok(find_registered_vault_root(
canonical_path.as_path(),
&registered_roots,
))
}
#[tauri::command]
pub fn reload_vault_entry(
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<VaultEntry, String> {
let resolved_vault_path = resolve_reload_vault_path(path.as_path(), vault_path.as_deref())?;
let raw_path = path.to_string_lossy();
let raw_vault_path = resolved_vault_path
.as_ref()
.map(|value| value.to_string_lossy().into_owned());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| vault::reload_entry(Path::new(validated_path)),
)
}
#[tauri::command]
pub async fn reload_vault(
app_handle: tauri::AppHandle,
path: String,
) -> Result<Vec<crate::vault::VaultEntry>, String> {
let path = expand_tilde(&path).into_owned();
#[cfg(desktop)]
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
#[cfg(mobile)]
let _ = app_handle;
tokio::task::spawn_blocking(move || {
let vault_path = Path::new(&path);
vault::invalidate_cache(vault_path);
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
limit: Option<usize>,
exclude_frontmatter: Option<bool>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
let exclude_frontmatter = exclude_frontmatter.unwrap_or(false);
tokio::task::spawn_blocking(move || {
search::search_vault_with_options(search::SearchOptions {
vault_path: &vault_path,
query: &query,
mode: "keyword",
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
exclude_frontmatter,
})
})
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[cfg(test)]
mod tests {
use super::{
collect_registered_vault_roots, find_registered_vault_root, reload_vault_entry,
resolve_reload_vault_path, search_vault,
};
use crate::vault_list::{VaultEntry as VaultListEntry, VaultList};
use std::path::{Path, PathBuf};
fn write_note(root: &Path, name: &str, content: &str) -> std::path::PathBuf {
let path = root.join(name);
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn finds_registered_vault_root_for_an_absolute_note_path() {
let dir = tempfile::TempDir::new().unwrap();
let vault_root = dir.path().join("vault");
let note_path = vault_root.join("note.md");
std::fs::create_dir_all(&vault_root).unwrap();
std::fs::write(&note_path, "# Note\n").unwrap();
let vault_list = VaultList {
vaults: vec![VaultListEntry {
label: "Test".to_string(),
path: vault_root.to_string_lossy().into_owned(),
..Default::default()
}],
active_vault: None,
default_workspace_path: None,
hidden_defaults: vec![],
};
let registered_roots = collect_registered_vault_roots(&vault_list);
let canonical_note_path = note_path.canonicalize().unwrap();
assert_eq!(
find_registered_vault_root(canonical_note_path.as_path(), &registered_roots),
Some(vault_root),
);
}
#[test]
fn prefers_the_deepest_registered_vault_root() {
let dir = tempfile::TempDir::new().unwrap();
let parent_root = dir.path().join("vault");
let nested_root = parent_root.join("projects");
let note_path = nested_root.join("note.md");
std::fs::create_dir_all(&nested_root).unwrap();
std::fs::write(&note_path, "# Note\n").unwrap();
let vault_list = VaultList {
vaults: vec![
VaultListEntry {
label: "Parent".to_string(),
path: parent_root.to_string_lossy().into_owned(),
..Default::default()
},
VaultListEntry {
label: "Nested".to_string(),
path: nested_root.to_string_lossy().into_owned(),
..Default::default()
},
],
active_vault: None,
default_workspace_path: None,
hidden_defaults: vec![],
};
let registered_roots = collect_registered_vault_roots(&vault_list);
let canonical_note_path = note_path.canonicalize().unwrap();
assert_eq!(
find_registered_vault_root(canonical_note_path.as_path(), &registered_roots),
Some(nested_root),
);
}
#[test]
fn find_registered_vault_root_ignores_missing_registered_roots() {
let dir = tempfile::TempDir::new().unwrap();
let vault_root = dir.path().join("vault");
std::fs::create_dir_all(&vault_root).unwrap();
let note_path = write_note(&vault_root, "note.md", "# Note\n");
let registered_roots = vec![dir.path().join("missing"), vault_root.clone()];
let canonical_note_path = note_path.canonicalize().unwrap();
assert_eq!(
find_registered_vault_root(canonical_note_path.as_path(), &registered_roots),
Some(vault_root),
);
}
#[test]
fn collect_registered_vault_roots_includes_active_vault() {
let vault_list = VaultList {
vaults: vec![VaultListEntry {
label: "Listed".to_string(),
path: "/listed".to_string(),
..Default::default()
}],
active_vault: Some("/active".to_string()),
default_workspace_path: None,
hidden_defaults: vec![],
};
let roots = collect_registered_vault_roots(&vault_list);
assert_eq!(
roots,
vec![PathBuf::from("/listed"), PathBuf::from("/active")]
);
}
#[test]
fn collect_registered_vault_roots_includes_hidden_defaults() {
let vault_list = VaultList {
vaults: vec![],
active_vault: None,
default_workspace_path: None,
hidden_defaults: vec!["/hidden-default".to_string()],
};
let roots = collect_registered_vault_roots(&vault_list);
assert_eq!(roots, vec![PathBuf::from("/hidden-default")]);
}
#[test]
fn resolve_reload_vault_path_uses_explicit_vault_path() {
let explicit = Path::new("/tmp/vault");
assert_eq!(
resolve_reload_vault_path(Path::new("note.md"), Some(explicit)).unwrap(),
Some(explicit.to_path_buf()),
);
}
#[test]
fn resolve_reload_vault_path_skips_relative_note_paths() {
assert_eq!(
resolve_reload_vault_path(Path::new("note.md"), None).unwrap(),
None,
);
}
#[test]
fn reload_vault_entry_command_reads_note_inside_vault() {
let dir = tempfile::TempDir::new().unwrap();
let note_path = write_note(dir.path(), "note.md", "# Reloaded Title\n\nBody");
let entry = reload_vault_entry(note_path, Some(dir.path().to_path_buf())).unwrap();
assert_eq!(entry.title, "Reloaded Title");
}
#[tokio::test]
async fn search_vault_command_uses_default_limit_and_returns_results() {
let dir = tempfile::Builder::new()
.prefix("scan-search-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
write_note(dir.path(), "search.md", "# Searchable\n\nneedle");
let response = search_vault(
dir.path().to_string_lossy().into_owned(),
"needle".to_string(),
None,
None,
)
.await
.unwrap();
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Searchable");
assert_eq!(response.mode, "keyword");
}
#[tokio::test]
async fn search_vault_command_honors_explicit_limit() {
let dir = tempfile::Builder::new()
.prefix("scan-search-limit-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
write_note(dir.path(), "first.md", "# First\n\nneedle");
write_note(dir.path(), "second.md", "# Second\n\nneedle");
let response = search_vault(
dir.path().to_string_lossy().into_owned(),
"needle".to_string(),
Some(1),
None,
)
.await
.unwrap();
assert_eq!(response.results.len(), 1);
}
}

View file

@ -0,0 +1,94 @@
use crate::vault::{self, ViewDefinition, ViewFile};
use std::path::Path;
use super::boundary::{with_boundary, with_view_file};
#[tauri::command]
pub fn list_views(vault_path: String) -> Result<Vec<ViewFile>, String> {
with_boundary(Some(vault_path.as_str()), |boundary| {
Ok(vault::scan_views(boundary.requested_root()))
})
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::save_view(Path::new(requested_root), validated_filename, &definition)
},
)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::delete_view(Path::new(requested_root), validated_filename)
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::{FilterCondition, FilterGroup, FilterNode, FilterOp};
fn definition(name: &str) -> ViewDefinition {
ViewDefinition {
name: name.to_string(),
icon: Some("star".to_string()),
color: None,
order: None,
sort: Some("modified:desc".to_string()),
list_properties_display: vec!["Priority".to_string()],
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
}
}
#[test]
fn view_commands_roundtrip_through_validated_vault_paths() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_string_lossy().to_string();
assert!(list_views(vault_path.clone()).unwrap().is_empty());
save_view_cmd(
vault_path.clone(),
"active-projects.yml".to_string(),
definition("Active Projects"),
)
.unwrap();
let views = list_views(vault_path.clone()).unwrap();
assert_eq!(views.len(), 1);
assert_eq!(views[0].filename, "active-projects.yml");
assert_eq!(views[0].definition.name, "Active Projects");
delete_view_cmd(vault_path.clone(), "active-projects.yml".to_string()).unwrap();
assert!(list_views(vault_path).unwrap().is_empty());
}
#[test]
fn delete_view_command_treats_missing_backing_file_as_deleted() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_string_lossy().to_string();
delete_view_cmd(vault_path.clone(), "stale-view.yml".to_string()).unwrap();
assert!(list_views(vault_path).unwrap().is_empty());
}
}

View file

@ -0,0 +1,121 @@
use super::{is_numeric_version_part, parse_legacy_build_label};
pub fn parse_build_label(version: &str) -> String {
let version = version.trim();
if version.is_empty() {
return "b?".to_string();
}
parse_legacy_build_label(version)
.or_else(|| parse_calendar_build_label(version))
.or_else(|| parse_semver_build_label(version))
.unwrap_or_else(|| "b?".to_string())
}
fn strip_build_metadata(version: &str) -> &str {
version.split_once('+').map_or(version, |(base, _)| base)
}
fn parse_calendar_build_label(version: &str) -> Option<String> {
let semver = strip_build_metadata(version);
let (core, prerelease) = semver
.split_once('-')
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
let [year, month, day] = split_numeric_version_parts(core)?;
if year.len() != 4 {
return None;
}
let core_version = format!(
"{}.{}.{}",
year.parse::<u32>().ok()?,
month.parse::<u32>().ok()?,
day.parse::<u32>().ok()?
);
match prerelease {
Some(suffix) if suffix.starts_with("alpha.") => suffix
.strip_prefix("alpha.")
.map(|sequence| format!("Alpha {}.{}", core_version, sequence)),
Some(suffix) if suffix.starts_with("stable.") => Some(core_version),
Some(_) => None,
None => Some(core_version),
}
}
fn parse_semver_build_label(version: &str) -> Option<String> {
let semver = strip_build_metadata(version);
let (core, prerelease) = semver
.split_once('-')
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
split_numeric_version_parts(core)?;
match prerelease {
Some(suffix) if suffix.starts_with("alpha.") => Some(format!("Alpha {}", semver)),
Some(_) => Some(format!("v{}", semver)),
None if semver == "0.1.0" || semver == "0.0.0" => Some("dev".to_string()),
None => Some(format!("v{}", semver)),
}
}
fn split_numeric_version_parts(version: &str) -> Option<[&str; 3]> {
let parts: Vec<&str> = version.split('.').collect();
let [major, minor, patch] = parts.as_slice() else {
return None;
};
if ![major, minor, patch]
.iter()
.all(|part| is_numeric_version_part(part))
{
return None;
}
Some([major, minor, patch])
}
#[cfg(test)]
mod tests {
use super::parse_build_label;
#[test]
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_calendar_versions() {
assert_eq!(parse_build_label("2026.4.16"), "2026.4.16");
assert_eq!(parse_build_label("2026.4.16-stable.1"), "2026.4.16");
assert_eq!(parse_build_label("2026.4.16-alpha.3"), "Alpha 2026.4.16.3");
assert_eq!(
parse_build_label("2026.4.16-alpha.3+darwin"),
"Alpha 2026.4.16.3"
);
}
#[test]
fn parse_build_label_legacy_semver_releases() {
assert_eq!(parse_build_label("1.2.3"), "v1.2.3");
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7"),
"Alpha 1.2.4-alpha.202604122135.7"
);
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7+darwin"),
"Alpha 1.2.4-alpha.202604122135.7"
);
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
}