feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
472
product-source/hololake-platform/src-tauri/src/commands/ai.rs
Normal file
472
product-source/hololake-platform/src-tauri/src/commands/ai.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
646
product-source/hololake-platform/src-tauri/src/commands/git.rs
Normal file
646
product-source/hololake-platform/src-tauri/src/commands/git.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
102
product-source/hololake-platform/src-tauri/src/commands/mod.rs
Normal file
102
product-source/hololake-platform/src-tauri/src/commands/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
679
product-source/hololake-platform/src-tauri/src/commands/sheet.rs
Normal file
679
product-source/hololake-platform/src-tauri/src/commands/sheet.rs
Normal 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: ®ex::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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
397
product-source/hololake-platform/src-tauri/src/commands/vault.rs
Normal file
397
product-source/hololake-platform/src-tauri/src/commands/vault.rs
Normal 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(¬e, 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(¬e).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(¬e, "---\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(¬e, "---\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(¬e_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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¬e, "# 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
®istered_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(¬e_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(), ®istered_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(¬e_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(), ®istered_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(), ®istered_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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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?");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue