refactor: split Rust backend into sub-modules — git, theme, github, frontmatter, commands
- git.rs (1907 lines) → 7 files: mod, history, status, commit, remote, conflict, pulse - theme.rs (1075 lines) → 4 files: mod, defaults, seed, create - github.rs (886 lines) → 4 files: mod, api, auth, clone - frontmatter.rs (827 lines) → 3 files: mod, yaml, ops - lib.rs (772 lines) → lib.rs (160) + commands.rs (650) 484 tests pass, clippy clean, rustfmt clean, coverage 85.42%
This commit is contained in:
@@ -1 +1 @@
|
||||
45402
|
||||
33549
|
||||
|
||||
659
src-tauri/src/commands.rs
Normal file
659
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,659 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use crate::claude_cli::{
|
||||
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
use crate::search::SearchResponse;
|
||||
use crate::settings::Settings;
|
||||
use crate::theme::{ThemeFile, VaultSettings};
|
||||
use crate::vault::{RenameResult, VaultEntry};
|
||||
use crate::vault_config::VaultConfig;
|
||||
use crate::vault_list::VaultList;
|
||||
use crate::{
|
||||
frontmatter, git, github, indexing, menu, search, theme, vault, vault_config, vault_list,
|
||||
};
|
||||
|
||||
/// 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> {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
pub fn parse_build_label(version: &str) -> String {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
match parts.as_slice() {
|
||||
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
|
||||
[_, _, _] => "dev".to_string(),
|
||||
_ => "b?".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "unavailable".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some("qmd not available".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vault commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_note_content(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::get_note_content(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::save_note_content(&path, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::delete_note(&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_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = match target_path {
|
||||
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
|
||||
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
|
||||
};
|
||||
vault::create_getting_started_vault(&path)
|
||||
}
|
||||
|
||||
#[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(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::save_image(&vault_path, &filename, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::copy_image_to_vault(&vault_path, &source_path)
|
||||
}
|
||||
|
||||
// ── Frontmatter commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_frontmatter(
|
||||
path: String,
|
||||
key: String,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::update_frontmatter(&path, &key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::delete_frontmatter_property(&path, &key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"Trashed at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── Git commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_history(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let limit = limit.unwrap_or(30);
|
||||
git::get_vault_pulse(&vault_path, limit)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
vault_path: String,
|
||||
file: String,
|
||||
strategy: String,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
|
||||
// ── GitHub commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
// ── AI / Claude commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
crate::ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_claude_cli() -> ClaudeCliStatus {
|
||||
crate::claude_cli::check_cli()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── Search & indexing commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
let limit = limit.unwrap_or(20);
|
||||
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_index_status(vault_path: String) -> IndexStatus {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
indexing::check_index_status(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_indexing(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if indexing::find_qmd_binary().is_none() {
|
||||
log::info!("qmd binary not found — attempting auto-install via bun");
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "installing".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
match indexing::try_auto_install_qmd() {
|
||||
Ok(()) if indexing::find_qmd_binary().is_some() => {
|
||||
log::info!("qmd auto-installed successfully, proceeding with indexing");
|
||||
}
|
||||
Ok(()) => {
|
||||
log::warn!("qmd auto-install reported success but binary still not found");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err("qmd not available after install".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("qmd auto-install failed: {e}");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err(format!("qmd not available: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexing::run_full_index(&vault_path, |progress| {
|
||||
let _ = app_handle.emit("indexing-progress", &progress);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Indexing task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Incremental index failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── MCP commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = 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}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
|
||||
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
|
||||
.await
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
// ── Theme commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::list_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_theme(&vault_path, &theme_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_vault_settings(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::save_vault_settings(&vault_path, settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::set_active_theme(&vault_path, theme_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_theme(&vault_path, source_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_vault_theme(&vault_path, name.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::ensure_vault_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
// ── 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 update_menu_state(
|
||||
app_handle: tauri::AppHandle,
|
||||
has_active_note: bool,
|
||||
has_modified_files: Option<bool>,
|
||||
has_conflicts: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
if let Some(v) = has_modified_files {
|
||||
menu::set_git_commit_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = has_conflicts {
|
||||
menu::set_git_conflict_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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 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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::get_vault_config(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::save_vault_config(&vault_path, config)
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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_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?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_archive_notes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
let result = batch_archive_notes(vec![note.to_str().unwrap().to_string()]);
|
||||
assert_eq!(result.unwrap(), 1);
|
||||
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Archived: true"));
|
||||
assert!(content.contains("Status: Active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_trash_notes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
let result = batch_trash_notes(vec![note.to_str().unwrap().to_string()]);
|
||||
assert_eq!(result.unwrap(), 1);
|
||||
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Trashed: true"));
|
||||
assert!(content.contains("Trashed at"));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -1,827 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Value type for frontmatter updates
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FrontmatterValue {
|
||||
String(String),
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
Null,
|
||||
}
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
fn starts_as_yaml_collection(s: &str) -> bool {
|
||||
s.starts_with('[') || s.starts_with('{')
|
||||
}
|
||||
|
||||
/// Check whether a YAML string value needs quoting to avoid ambiguity.
|
||||
fn needs_yaml_quoting(s: &str) -> bool {
|
||||
has_yaml_special_chars(s)
|
||||
|| starts_as_yaml_collection(s)
|
||||
|| matches!(s, "true" | "false" | "null")
|
||||
|| s.parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// Quote a string value for YAML, escaping internal double quotes.
|
||||
fn quote_yaml_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\"', "\\\""))
|
||||
}
|
||||
|
||||
/// Format a single YAML list item as ` - "value"`.
|
||||
fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{}", n)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
FrontmatterValue::Number(n) => format_yaml_number(*n),
|
||||
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
|
||||
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
|
||||
FrontmatterValue::List(items) => items
|
||||
.iter()
|
||||
.map(|item| format_list_item(item))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
FrontmatterValue::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
|
||||
fn needs_key_quoting(key: &str) -> bool {
|
||||
key.chars()
|
||||
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
|
||||
}
|
||||
|
||||
/// Format a key for YAML output (quote if necessary)
|
||||
pub fn format_yaml_key(key: &str) -> String {
|
||||
if needs_key_quoting(key) {
|
||||
format!("\"{}\"", key)
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line defines a specific key (handles quoted and unquoted keys)
|
||||
fn line_is_key(line: &str, key: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dq = format!("\"{}\":", key);
|
||||
if trimmed.starts_with(&dq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sq = format!("'{}\':", key);
|
||||
if trimmed.starts_with(&sq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Format a key-value pair as one or more YAML lines.
|
||||
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
let after_open = &content[4..];
|
||||
// Handle empty frontmatter: closing --- immediately after opening ---\n
|
||||
if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
return Ok(("", stripped));
|
||||
}
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
}
|
||||
|
||||
/// Wrap content in a new frontmatter block containing a single field.
|
||||
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
|
||||
let field_lines = format_yaml_field(key, value);
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
|
||||
}
|
||||
|
||||
/// Apply a field update to existing frontmatter lines.
|
||||
/// Replaces the matching key (and its list continuations) with the new value,
|
||||
/// or appends if the key is not found. If `value` is None, removes the key.
|
||||
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !line_is_key(lines[i], key) {
|
||||
new_lines.push(lines[i].to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
if let Some(v) = value {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, value) {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return match value {
|
||||
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
|
||||
None => Ok(content.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
|
||||
let updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Update a single frontmatter property in a markdown file.
|
||||
pub fn update_frontmatter(
|
||||
path: &str,
|
||||
key: &str,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, Some(value.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a frontmatter property from a markdown file.
|
||||
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, None)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("aliases:"));
|
||||
assert!(updated.contains(" - \"Alias1\""));
|
||||
assert!(updated.contains(" - \"Alias2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replace_list() {
|
||||
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" - \"New1\""));
|
||||
assert!(!updated.contains("Old1"));
|
||||
assert!(!updated.contains("Old2"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_property() {
|
||||
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
|
||||
assert!(!updated.contains("Owner"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_list_property() {
|
||||
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
|
||||
assert!(!updated.contains("aliases"));
|
||||
assert!(!updated.contains("Alias1"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.starts_with("---\n"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert!(updated.contains("# Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_bool() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_simple() {
|
||||
assert_eq!(format_yaml_key("Status"), "Status");
|
||||
assert_eq!(format_yaml_key("is_a"), "is_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_spaces() {
|
||||
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
|
||||
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
|
||||
}
|
||||
|
||||
// --- to_yaml_value quoting tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_colon() {
|
||||
let v = FrontmatterValue::String("key: value".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_hash() {
|
||||
let v = FrontmatterValue::String("has # comment".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bracket() {
|
||||
let v = FrontmatterValue::String("[array-like]".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_brace() {
|
||||
let v = FrontmatterValue::String("{object-like}".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bool_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("true".to_string()).to_yaml_value(),
|
||||
"\"true\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("false".to_string()).to_yaml_value(),
|
||||
"\"false\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_null_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("null".to_string()).to_yaml_value(),
|
||||
"\"null\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_number_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("42".to_string()).to_yaml_value(),
|
||||
"\"42\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
|
||||
"\"3.14\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_plain() {
|
||||
let v = FrontmatterValue::String("Hello World".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_integer() {
|
||||
let v = FrontmatterValue::Number(42.0);
|
||||
assert_eq!(v.to_yaml_value(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.14);
|
||||
assert_eq!(v.to_yaml_value(), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_null() {
|
||||
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_empty_list() {
|
||||
let v = FrontmatterValue::List(vec![]);
|
||||
assert_eq!(v.to_yaml_value(), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_list_with_colon() {
|
||||
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
|
||||
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
|
||||
}
|
||||
|
||||
// --- update_frontmatter_content additional type tests ---
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Priority: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number_float() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Score: 9.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_null() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
|
||||
assert!(updated.contains("ClearMe: null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
|
||||
.unwrap();
|
||||
assert!(updated.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_malformed_no_closing_fence() {
|
||||
let content = "---\nStatus: Draft\nNo closing fence here";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
|
||||
// --- delete non-existent key (should be no-op) ---
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_key_noop() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_from_no_frontmatter_noop() {
|
||||
let content = "# Test\n\nSome content.";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
// --- line_is_key tests ---
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
assert!(!line_is_key("Status: Draft", "Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_double_quoted() {
|
||||
assert!(line_is_key("\"Is A\": Note", "Is A"));
|
||||
assert!(!line_is_key("\"Is A\": Note", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_single_quoted() {
|
||||
assert!(line_is_key("'Is A': Note", "Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_leading_whitespace() {
|
||||
assert!(line_is_key(" Status: Draft", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_partial_match() {
|
||||
// "StatusBar" should not match key "Status"
|
||||
assert!(!line_is_key("StatusBar: value", "Status"));
|
||||
}
|
||||
|
||||
// --- with_frontmatter error cases ---
|
||||
|
||||
#[test]
|
||||
fn test_with_frontmatter_file_not_found() {
|
||||
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
// --- roundtrip tests ---
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"A".to_string(),
|
||||
"B".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let aliases = map.get("aliases").unwrap();
|
||||
if let gray_matter::Pod::Array(arr) = aliases {
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].as_string().unwrap(), "A");
|
||||
assert_eq!(arr[1].as_string().unwrap(), "B");
|
||||
} else {
|
||||
panic!("Expected array");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_add_then_delete() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let with_owner = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
assert!(without_owner.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
// --- format_yaml_key additional tests ---
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_colon() {
|
||||
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_hash() {
|
||||
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_period() {
|
||||
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
|
||||
}
|
||||
|
||||
// --- split_frontmatter / empty frontmatter edge cases ---
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block() {
|
||||
// ---\n---\n (no fields between opening and closing ---)
|
||||
let result = split_frontmatter("---\n---\n");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter block"
|
||||
);
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert_eq!(rest, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_no_trailing_newline() {
|
||||
// ---\n--- (no trailing newline)
|
||||
let result = split_frontmatter("---\n---");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter without trailing newline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_with_body() {
|
||||
// ---\n---\n\n# Title\n
|
||||
let result = split_frontmatter("---\n---\n\n# Title\n");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter with body"
|
||||
);
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert!(rest.contains("# Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_block() {
|
||||
let content = "---\n---\n\n# Test\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New Title".to_string())),
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"update_frontmatter_content should handle empty frontmatter block"
|
||||
);
|
||||
let updated = result.unwrap();
|
||||
assert!(updated.contains("title: New Title"));
|
||||
}
|
||||
|
||||
// --- block scalar (multi-line string) tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains(" ## Timeline"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let new_template = "## New\n\n## Content";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(new_template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(updated.contains(" ## Content"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(!updated.contains("## Stuff"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(!updated.contains("## Heading"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
// Frontmatter with title, no body after closing ---
|
||||
let content = "---\ntitle: Old\n---\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New".to_string())),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let updated = result.unwrap();
|
||||
assert!(updated.contains("title: New"));
|
||||
assert!(!updated.contains("title: Old"));
|
||||
}
|
||||
}
|
||||
207
src-tauri/src/frontmatter/mod.rs
Normal file
207
src-tauri/src/frontmatter/mod.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
mod ops;
|
||||
mod yaml;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub use ops::update_frontmatter_content;
|
||||
pub use yaml::{format_yaml_key, FrontmatterValue};
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
|
||||
let updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Update a single frontmatter property in a markdown file.
|
||||
pub fn update_frontmatter(
|
||||
path: &str,
|
||||
key: &str,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, Some(value.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a frontmatter property from a markdown file.
|
||||
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, None)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_with_frontmatter_file_not_found() {
|
||||
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"A".to_string(),
|
||||
"B".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let aliases = map.get("aliases").unwrap();
|
||||
if let gray_matter::Pod::Array(arr) = aliases {
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].as_string().unwrap(), "A");
|
||||
assert_eq!(arr[1].as_string().unwrap(), "B");
|
||||
} else {
|
||||
panic!("Expected array");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_add_then_delete() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let with_owner = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
assert!(without_owner.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_block() {
|
||||
let content = "---\n---\n\n# Test\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New Title".to_string())),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("title: New Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String("## New\n\n## Content".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
let content = "---\ntitle: Old\n---\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("title: New"));
|
||||
assert!(!updated.contains("title: Old"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
}
|
||||
331
src-tauri/src/frontmatter/ops.rs
Normal file
331
src-tauri/src/frontmatter/ops.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
use super::yaml::{format_yaml_field, FrontmatterValue};
|
||||
|
||||
/// Check if a line defines a specific key (handles quoted and unquoted keys)
|
||||
fn line_is_key(line: &str, key: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dq = format!("\"{}\":", key);
|
||||
if trimmed.starts_with(&dq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sq = format!("'{}\':", key);
|
||||
if trimmed.starts_with(&sq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
let after_open = &content[4..];
|
||||
// Handle empty frontmatter: closing --- immediately after opening ---\n
|
||||
if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
return Ok(("", stripped));
|
||||
}
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
}
|
||||
|
||||
/// Wrap content in a new frontmatter block containing a single field.
|
||||
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
|
||||
let field_lines = format_yaml_field(key, value);
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
|
||||
}
|
||||
|
||||
/// Apply a field update to existing frontmatter lines.
|
||||
/// Replaces the matching key (and its list continuations) with the new value,
|
||||
/// or appends if the key is not found. If `value` is None, removes the key.
|
||||
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !line_is_key(lines[i], key) {
|
||||
new_lines.push(lines[i].to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
if let Some(v) = value {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, value) {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return match value {
|
||||
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
|
||||
None => Ok(content.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("aliases:"));
|
||||
assert!(updated.contains(" - \"Alias1\""));
|
||||
assert!(updated.contains(" - \"Alias2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replace_list() {
|
||||
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" - \"New1\""));
|
||||
assert!(!updated.contains("Old1"));
|
||||
assert!(!updated.contains("Old2"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_property() {
|
||||
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
|
||||
assert!(!updated.contains("Owner"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_list_property() {
|
||||
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
|
||||
assert!(!updated.contains("aliases"));
|
||||
assert!(!updated.contains("Alias1"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.starts_with("---\n"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert!(updated.contains("# Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_bool() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Priority: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number_float() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Score: 9.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_null() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
|
||||
assert!(updated.contains("ClearMe: null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
|
||||
.unwrap();
|
||||
assert!(updated.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_malformed_no_closing_fence() {
|
||||
let content = "---\nStatus: Draft\nNo closing fence here";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_key_noop() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_from_no_frontmatter_noop() {
|
||||
let content = "# Test\n\nSome content.";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
assert!(!line_is_key("Status: Draft", "Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_double_quoted() {
|
||||
assert!(line_is_key("\"Is A\": Note", "Is A"));
|
||||
assert!(!line_is_key("\"Is A\": Note", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_single_quoted() {
|
||||
assert!(line_is_key("'Is A': Note", "Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_leading_whitespace() {
|
||||
assert!(line_is_key(" Status: Draft", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_partial_match() {
|
||||
assert!(!line_is_key("StatusBar: value", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block() {
|
||||
let result = split_frontmatter("---\n---\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert_eq!(rest, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_no_trailing_newline() {
|
||||
let result = split_frontmatter("---\n---");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_with_body() {
|
||||
let result = split_frontmatter("---\n---\n\n# Title\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert!(rest.contains("# Title"));
|
||||
}
|
||||
}
|
||||
262
src-tauri/src/frontmatter/yaml.rs
Normal file
262
src-tauri/src/frontmatter/yaml.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Value type for frontmatter updates
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FrontmatterValue {
|
||||
String(String),
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
Null,
|
||||
}
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
fn starts_as_yaml_collection(s: &str) -> bool {
|
||||
s.starts_with('[') || s.starts_with('{')
|
||||
}
|
||||
|
||||
/// Check whether a YAML string value needs quoting to avoid ambiguity.
|
||||
fn needs_yaml_quoting(s: &str) -> bool {
|
||||
has_yaml_special_chars(s)
|
||||
|| starts_as_yaml_collection(s)
|
||||
|| matches!(s, "true" | "false" | "null")
|
||||
|| s.parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// Quote a string value for YAML, escaping internal double quotes.
|
||||
fn quote_yaml_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\"', "\\\""))
|
||||
}
|
||||
|
||||
/// Format a single YAML list item as ` - "value"`.
|
||||
fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{}", n)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
FrontmatterValue::Number(n) => format_yaml_number(*n),
|
||||
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
|
||||
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
|
||||
FrontmatterValue::List(items) => items
|
||||
.iter()
|
||||
.map(|item| format_list_item(item))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
FrontmatterValue::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
|
||||
fn needs_key_quoting(key: &str) -> bool {
|
||||
key.chars()
|
||||
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
|
||||
}
|
||||
|
||||
/// Format a key for YAML output (quote if necessary)
|
||||
pub fn format_yaml_key(key: &str) -> String {
|
||||
if needs_key_quoting(key) {
|
||||
format!("\"{}\"", key)
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a key-value pair as one or more YAML lines.
|
||||
pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_colon() {
|
||||
let v = FrontmatterValue::String("key: value".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_hash() {
|
||||
let v = FrontmatterValue::String("has # comment".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bracket() {
|
||||
let v = FrontmatterValue::String("[array-like]".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_brace() {
|
||||
let v = FrontmatterValue::String("{object-like}".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bool_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("true".to_string()).to_yaml_value(),
|
||||
"\"true\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("false".to_string()).to_yaml_value(),
|
||||
"\"false\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_null_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("null".to_string()).to_yaml_value(),
|
||||
"\"null\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_number_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("42".to_string()).to_yaml_value(),
|
||||
"\"42\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
|
||||
"\"3.14\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_plain() {
|
||||
let v = FrontmatterValue::String("Hello World".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_integer() {
|
||||
let v = FrontmatterValue::Number(42.0);
|
||||
assert_eq!(v.to_yaml_value(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.14);
|
||||
assert_eq!(v.to_yaml_value(), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_null() {
|
||||
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_empty_list() {
|
||||
let v = FrontmatterValue::List(vec![]);
|
||||
assert_eq!(v.to_yaml_value(), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_list_with_colon() {
|
||||
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
|
||||
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_simple() {
|
||||
assert_eq!(format_yaml_key("Status"), "Status");
|
||||
assert_eq!(format_yaml_key("is_a"), "is_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_spaces() {
|
||||
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
|
||||
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_colon() {
|
||||
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_hash() {
|
||||
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_period() {
|
||||
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
}
|
||||
1907
src-tauri/src/git.rs
1907
src-tauri/src/git.rs
File diff suppressed because it is too large
Load Diff
87
src-tauri/src/git/commit.rs
Normal file
87
src-tauri/src/git/commit.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Commit all changes with a message.
|
||||
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Stage all changes
|
||||
let add = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git add: {}", e))?;
|
||||
|
||||
if !add.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&add.stderr);
|
||||
return Err(format!("git add failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Commit
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "-m", message])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git commit: {}", e))?;
|
||||
|
||||
if !commit.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
let stdout = String::from_utf8_lossy(&commit.stdout);
|
||||
// git writes "nothing to commit" to stdout, not stderr
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(format!("git commit failed: {}", detail.trim()));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_git_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
fs::write(vault.join("commit-test.md"), "# Test\n").unwrap();
|
||||
|
||||
let result = git_commit(vault.to_str().unwrap(), "Test commit");
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the commit exists
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Test commit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_nothing_to_commit_returns_error() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
// Create and commit, so working tree is clean
|
||||
fs::write(vault.join("clean.md"), "# Clean\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
// Committing again with no changes should fail
|
||||
let result = git_commit(vp, "nothing here");
|
||||
assert!(result.is_err(), "Commit should fail when nothing to commit");
|
||||
assert!(
|
||||
result.unwrap_err().contains("nothing to commit"),
|
||||
"Error should mention 'nothing to commit'"
|
||||
);
|
||||
}
|
||||
}
|
||||
366
src-tauri/src/git/conflict.rs
Normal file
366
src-tauri/src/git/conflict.rs
Normal file
@@ -0,0 +1,366 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::run_git;
|
||||
|
||||
/// List files with merge conflicts (unmerged paths).
|
||||
///
|
||||
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
|
||||
/// ls-files reliably detects unmerged index entries even when the merge state is
|
||||
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
|
||||
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["ls-files", "--unmerged"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
|
||||
// Format: "<mode> <hash> <stage>\t<path>"
|
||||
let mut files: Vec<String> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
|
||||
/// then stage the result.
|
||||
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let checkout_flag = match strategy {
|
||||
"ours" => "--ours",
|
||||
"theirs" => "--theirs",
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Invalid strategy '{}': must be 'ours' or 'theirs'",
|
||||
strategy
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
run_git(vault, &["checkout", checkout_flag, "--", file])?;
|
||||
run_git(vault, &["add", "--", file])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a rebase is currently in progress.
|
||||
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
|
||||
let vault = Path::new(vault_path);
|
||||
let git_dir = vault.join(".git");
|
||||
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
|
||||
}
|
||||
|
||||
/// Check whether a merge is currently in progress.
|
||||
pub fn is_merge_in_progress(vault_path: &str) -> bool {
|
||||
Path::new(vault_path)
|
||||
.join(".git")
|
||||
.join("MERGE_HEAD")
|
||||
.exists()
|
||||
}
|
||||
|
||||
/// Returns the current conflict mode: "rebase", "merge", or "none".
|
||||
pub fn get_conflict_mode(vault_path: &str) -> String {
|
||||
if is_rebase_in_progress(vault_path) {
|
||||
"rebase".to_string()
|
||||
} else if is_merge_in_progress(vault_path) {
|
||||
"merge".to_string()
|
||||
} else {
|
||||
"none".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit after all conflicts have been resolved.
|
||||
/// Detects whether the repo is in a merge or rebase state and uses the
|
||||
/// appropriate command (`git commit` vs `git rebase --continue`).
|
||||
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Verify no remaining conflicts
|
||||
let remaining = get_conflict_files(vault_path)?;
|
||||
if !remaining.is_empty() {
|
||||
return Err(format!(
|
||||
"Cannot commit: {} file(s) still have unresolved conflicts",
|
||||
remaining.len()
|
||||
));
|
||||
}
|
||||
|
||||
let mode = get_conflict_mode(vault_path);
|
||||
let output = match mode.as_str() {
|
||||
"rebase" => Command::new("git")
|
||||
.args(["rebase", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
|
||||
_ => Command::new("git")
|
||||
.args(["commit", "-m", "Resolve merge conflicts"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git commit: {}", e))?,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
let cmd_name = if mode == "rebase" {
|
||||
"git rebase --continue"
|
||||
} else {
|
||||
"git commit"
|
||||
};
|
||||
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use crate::git::{git_commit, git_pull, git_push};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_get_conflict_files_empty_when_clean() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp).unwrap();
|
||||
assert!(conflicts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_invalid_strategy() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid strategy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_none_for_clean_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp), "none");
|
||||
assert!(!is_rebase_in_progress(vp));
|
||||
assert!(!is_merge_in_progress(vp));
|
||||
}
|
||||
|
||||
/// Set up a pair of clones that have a merge conflict on the same file.
|
||||
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
|
||||
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
|
||||
|
||||
let vp_a = clone_a_dir.path().to_str().unwrap();
|
||||
let vp_b = clone_b_dir.path().to_str().unwrap();
|
||||
|
||||
// A creates the file and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
|
||||
git_commit(vp_a, "create conflict.md").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B pulls to get the file
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
// A modifies and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
|
||||
git_commit(vp_a, "A's change").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B modifies the same file locally and commits
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
// B pulls — this causes a merge conflict
|
||||
let result = git_pull(vp_b).unwrap();
|
||||
assert_eq!(result.status, "conflict");
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(conflicts.contains(&"conflict.md".to_string()));
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version B\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_theirs() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version A\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(clone_b.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Resolve merge conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_fails_with_unresolved() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.contains("still have unresolved conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_merge_during_merge_conflict() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "merge");
|
||||
assert!(is_merge_in_progress(vp_b));
|
||||
assert!(!is_rebase_in_progress(vp_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_merge_mode() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "merge");
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "none");
|
||||
}
|
||||
|
||||
/// Set up a rebase conflict: clone_b has diverged from origin and
|
||||
/// `git pull --rebase` causes a conflict.
|
||||
fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
|
||||
|
||||
let vp_a = clone_a_dir.path().to_str().unwrap();
|
||||
let vp_b = clone_b_dir.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
|
||||
git_commit(vp_a, "create conflict.md").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
|
||||
git_commit(vp_a, "A's change").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["pull", "--rebase"])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Expected rebase conflict, but pull succeeded"
|
||||
);
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_mode_rebase_during_rebase_conflict() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "rebase");
|
||||
assert!(is_rebase_in_progress(vp_b));
|
||||
assert!(!is_merge_in_progress(vp_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_conflict_files_during_rebase() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(
|
||||
conflicts.contains(&"conflict.md".to_string()),
|
||||
"Should detect conflict.md during rebase, got: {:?}",
|
||||
conflicts
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_and_continue_rebase() {
|
||||
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "rebase");
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
|
||||
|
||||
assert_eq!(get_conflict_mode(vp_b), "none");
|
||||
}
|
||||
}
|
||||
349
src-tauri/src/git/history.rs
Normal file
349
src-tauri/src/git/history.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::GitCommit;
|
||||
|
||||
/// Get git log history for a specific file in the vault.
|
||||
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"log",
|
||||
"--format=%H|%h|%an|%aI|%s",
|
||||
"-n",
|
||||
"20",
|
||||
"--",
|
||||
relative_str,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// No commits yet is not an error - just return empty history
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let commits = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|line| {
|
||||
// Format: hash|short_hash|author|date|message
|
||||
// Use splitn(5) so message (last) can contain '|'
|
||||
let parts: Vec<&str> = line.splitn(5, '|').collect();
|
||||
if parts.len() != 5 {
|
||||
return None;
|
||||
}
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(GitCommit {
|
||||
hash: parts[0].to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
author: parts[2].to_string(),
|
||||
date,
|
||||
message: parts[4].to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
/// Get git diff for a specific file.
|
||||
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// First try tracked file diff
|
||||
let output = Command::new("git")
|
||||
.args(["diff", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
|
||||
// If no diff (maybe staged or untracked), try diff --cached
|
||||
if stdout.is_empty() {
|
||||
let cached = Command::new("git")
|
||||
.args(["diff", "--cached", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
|
||||
|
||||
let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string();
|
||||
if !cached_stdout.is_empty() {
|
||||
return Ok(cached_stdout);
|
||||
}
|
||||
|
||||
// Try showing untracked file as all-new
|
||||
let status = Command::new("git")
|
||||
.args(["status", "--porcelain", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
|
||||
let status_out = String::from_utf8_lossy(&status.stdout);
|
||||
if status_out.starts_with("??") {
|
||||
// Untracked file: show entire content as added
|
||||
let content =
|
||||
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
|
||||
return Ok(format!(
|
||||
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
|
||||
relative_str,
|
||||
lines.len(),
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
/// Get git diff for a specific file at a given commit (compared to its parent).
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: &str,
|
||||
file_path: &str,
|
||||
commit_hash: &str,
|
||||
) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// Show diff between commit^ and commit for this file
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
&format!("{}^", commit_hash),
|
||||
commit_hash,
|
||||
"--",
|
||||
relative_str,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
|
||||
// If diff is empty, it might be the initial commit (no parent).
|
||||
// Fall back to showing the full file content as added.
|
||||
if stdout.is_empty() {
|
||||
let show = Command::new("git")
|
||||
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git show: {}", e))?;
|
||||
|
||||
if show.status.success() {
|
||||
let content = String::from_utf8_lossy(&show.stdout);
|
||||
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
|
||||
return Ok(format!(
|
||||
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
|
||||
relative_str,
|
||||
lines.len(),
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stdout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("test.md");
|
||||
|
||||
fs::write(&file, "# Initial\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Updated\n\nNew content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Update test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history[0].message, "Update test");
|
||||
assert_eq!(history[1].message, "Initial commit");
|
||||
assert_eq!(history[0].author, "Test User");
|
||||
assert!(!history[0].hash.is_empty());
|
||||
assert!(!history[0].short_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_no_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("new.md");
|
||||
fs::write(&file, "# New\n").unwrap();
|
||||
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-test.md");
|
||||
|
||||
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "diff-test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Add diff-test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Test\n\nModified content.").unwrap();
|
||||
|
||||
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
assert!(diff.contains("+Modified content."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-at-commit.md");
|
||||
|
||||
fs::write(&file, "# First\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "First commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# First\n\nModified content.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Second commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Get hash of second commit
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("-Original content."));
|
||||
assert!(diff.contains("+Modified content."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_initial_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("initial.md");
|
||||
|
||||
fs::write(&file, "# Initial\n\nHello world.").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "initial.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(!diff.is_empty());
|
||||
assert!(diff.contains("+# Initial"));
|
||||
assert!(diff.contains("+Hello world."));
|
||||
}
|
||||
}
|
||||
278
src-tauri/src/git/mod.rs
Normal file
278
src-tauri/src/git/mod.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod history;
|
||||
mod pulse;
|
||||
mod remote;
|
||||
mod status;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub use commit::git_commit;
|
||||
pub use conflict::{
|
||||
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
|
||||
is_merge_in_progress, is_rebase_in_progress,
|
||||
};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
|
||||
pub use status::{get_modified_files, ModifiedFile};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct GitCommit {
|
||||
pub hash: String,
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
pub message: String,
|
||||
pub author: String,
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
/// Initialize a new git repository, stage all files, and create an initial commit.
|
||||
pub fn init_repo(path: &str) -> Result<(), String> {
|
||||
let dir = Path::new(path);
|
||||
|
||||
run_git(dir, &["init"])?;
|
||||
ensure_author_config(dir)?;
|
||||
run_git(dir, &["add", "."])?;
|
||||
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a git command in the given directory, returning an error on failure.
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git {} failed: {}",
|
||||
args[0],
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
}
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
|
||||
let check = Command::new("git")
|
||||
.args(["config", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check git config: {}", e))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&check.stdout);
|
||||
if !check.status.success() || value.trim().is_empty() {
|
||||
run_git(dir, &["config", key, fallback])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract "owner/repo" from a GitHub remote URL.
|
||||
/// Supports HTTPS (https://github.com/owner/repo.git) and
|
||||
/// SSH (git@github.com:owner/repo.git) formats.
|
||||
fn parse_github_repo_path(url: &str) -> Option<String> {
|
||||
let trimmed = url.trim();
|
||||
|
||||
// SSH format: git@github.com:owner/repo.git
|
||||
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
|
||||
let path = rest.strip_suffix(".git").unwrap_or(rest);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS format: https://github.com/owner/repo.git
|
||||
// Also handle token-embedded URLs: https://token@github.com/owner/repo.git
|
||||
if trimmed.contains("github.com/") {
|
||||
let after = trimmed.split("github.com/").nth(1)?;
|
||||
let path = after.strip_suffix(".git").unwrap_or(after);
|
||||
if path.contains('/') {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub(crate) fn setup_git_repo() -> TempDir {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test User"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
/// Set up a bare "remote" and a clone that acts as the working vault.
|
||||
pub(crate) fn setup_remote_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let bare_dir = TempDir::new().unwrap();
|
||||
let bare = bare_dir.path();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init", "--bare"])
|
||||
.current_dir(bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let clone_a_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
for cmd in &[
|
||||
&["config", "user.email", "a@test.com"][..],
|
||||
&["config", "user.name", "User A"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
.args(*cmd)
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let clone_b_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
for cmd in &[
|
||||
&["config", "user.email", "b@test.com"][..],
|
||||
&["config", "user.name", "User B"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
.args(*cmd)
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_git_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(vault.join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_initial_commit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Initial vault setup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_stages_all_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(vault.join("sub")).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
||||
"All files should be committed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_https() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://github.com/owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_ssh() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_repo_path("git@github.com:owner/repo"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_token_embedded() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
|
||||
Some("owner/repo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_non_github() {
|
||||
assert_eq!(
|
||||
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
479
src-tauri/src/git/pulse.rs
Normal file
479
src-tauri/src/git/pulse.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::parse_github_repo_path;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseFile {
|
||||
pub path: String,
|
||||
pub status: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseCommit {
|
||||
pub hash: String,
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
pub message: String,
|
||||
pub date: i64,
|
||||
#[serde(rename = "githubUrl")]
|
||||
pub github_url: Option<String>,
|
||||
pub files: Vec<PulseFile>,
|
||||
pub added: usize,
|
||||
pub modified: usize,
|
||||
pub deleted: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct LastCommitInfo {
|
||||
#[serde(rename = "shortHash")]
|
||||
pub short_hash: String,
|
||||
#[serde(rename = "commitUrl")]
|
||||
pub commit_url: Option<String>,
|
||||
}
|
||||
|
||||
fn title_from_path(path: &str) -> String {
|
||||
path.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(path)
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(path)
|
||||
.replace('-', " ")
|
||||
}
|
||||
|
||||
fn parse_file_status(code: &str) -> &str {
|
||||
match code {
|
||||
"A" => "added",
|
||||
"M" => "modified",
|
||||
"D" => "deleted",
|
||||
_ => "modified",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
||||
pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !vault.join(".git").exists() {
|
||||
return Err("Not a git repository".to_string());
|
||||
}
|
||||
|
||||
let limit_str = limit.to_string();
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"log",
|
||||
"--name-status",
|
||||
"--pretty=format:%H|%h|%s|%aI",
|
||||
"--diff-filter=ADM",
|
||||
"-n",
|
||||
&limit_str,
|
||||
"--",
|
||||
"*.md",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let github_base = get_github_base_url(vault_path);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_pulse_output(&stdout, &github_base))
|
||||
}
|
||||
|
||||
fn get_github_base_url(vault_path: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let repo_path = parse_github_repo_path(&url)?;
|
||||
Some(format!("https://github.com/{}", repo_path))
|
||||
}
|
||||
|
||||
fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCommit> {
|
||||
let mut commits: Vec<PulseCommit> = Vec::new();
|
||||
let mut current: Option<PulseCommit> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.contains('|')
|
||||
&& !line.starts_with(|c: char| {
|
||||
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
||||
})
|
||||
{
|
||||
// Commit header line: hash|short_hash|message|date
|
||||
if let Some(commit) = current.take() {
|
||||
commits.push(commit);
|
||||
}
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
let hash = parts[0];
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let github_url = github_base
|
||||
.as_ref()
|
||||
.map(|base| format!("{}/commit/{}", base, hash));
|
||||
|
||||
current = Some(PulseCommit {
|
||||
hash: hash.to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
message: parts[2].to_string(),
|
||||
date,
|
||||
github_url,
|
||||
files: Vec::new(),
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref mut commit) = current {
|
||||
// File status line: A\tpath or M\tpath
|
||||
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
if file_parts.len() == 2 {
|
||||
let status = parse_file_status(file_parts[0].trim());
|
||||
let path = file_parts[1].trim();
|
||||
match status {
|
||||
"added" => commit.added += 1,
|
||||
"deleted" => commit.deleted += 1,
|
||||
_ => commit.modified += 1,
|
||||
}
|
||||
commit.files.push(PulseFile {
|
||||
path: path.to_string(),
|
||||
status: status.to_string(),
|
||||
title: title_from_path(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(commit) = current {
|
||||
commits.push(commit);
|
||||
}
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
|
||||
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["log", "-1", "--format=%H|%h"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git log: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("does not have any commits yet") {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(format!("git log failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let line = stdout.trim();
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = line.splitn(2, '|').collect();
|
||||
if parts.len() != 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let full_hash = parts[0];
|
||||
let short_hash = parts[1].to_string();
|
||||
|
||||
let commit_url = get_github_commit_url(vault_path, full_hash);
|
||||
|
||||
Ok(Some(LastCommitInfo {
|
||||
short_hash,
|
||||
commit_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Try to build a GitHub commit URL from the origin remote URL.
|
||||
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let repo_path = parse_github_repo_path(&url)?;
|
||||
Some(format!(
|
||||
"https://github.com/{}/commit/{}",
|
||||
repo_path, full_hash
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
fs::write(vault.join("project.md"), "# Project\n").unwrap();
|
||||
git_commit(vp, "Add project").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30).unwrap();
|
||||
|
||||
assert_eq!(pulse.len(), 2);
|
||||
assert_eq!(pulse[0].message, "Add project");
|
||||
assert_eq!(pulse[1].message, "Add note");
|
||||
assert_eq!(pulse[0].files.len(), 1);
|
||||
assert_eq!(pulse[0].files[0].path, "project.md");
|
||||
assert_eq!(pulse[0].files[0].status, "added");
|
||||
assert_eq!(pulse[0].added, 1);
|
||||
assert_eq!(pulse[0].modified, 0);
|
||||
assert!(!pulse[0].short_hash.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_no_git() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vp = dir.path().to_str().unwrap();
|
||||
|
||||
let result = get_vault_pulse(vp, 30);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Not a git repository"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_empty_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vp = dir.path().to_str().unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30).unwrap();
|
||||
assert!(pulse.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_only_md_files() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
fs::write(vault.join("config.json"), "{}").unwrap();
|
||||
git_commit(vp, "Add files").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30).unwrap();
|
||||
assert_eq!(pulse.len(), 1);
|
||||
assert_eq!(pulse[0].files.len(), 1);
|
||||
assert_eq!(pulse[0].files[0].path, "note.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_respects_limit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
fs::write(
|
||||
vault.join(format!("note{}.md", i)),
|
||||
format!("# Note {}\n", i),
|
||||
)
|
||||
.unwrap();
|
||||
git_commit(vp, &format!("Add note {}", i)).unwrap();
|
||||
}
|
||||
|
||||
let pulse = get_vault_pulse(vp, 3).unwrap();
|
||||
assert_eq!(pulse.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_modified_and_deleted() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
|
||||
git_commit(vp, "Update note").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30).unwrap();
|
||||
assert_eq!(pulse[0].message, "Update note");
|
||||
assert_eq!(pulse[0].files[0].status, "modified");
|
||||
assert_eq!(pulse[0].modified, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_github_url() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/owner/repo.git",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30).unwrap();
|
||||
assert!(pulse[0].github_url.is_some());
|
||||
let url = pulse[0].github_url.as_ref().unwrap();
|
||||
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_vault_pulse_no_github_url_without_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "Add note").unwrap();
|
||||
|
||||
let pulse = get_vault_pulse(vp, 30).unwrap();
|
||||
assert!(pulse[0].github_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_from_path() {
|
||||
assert_eq!(title_from_path("note/my-project.md"), "my project");
|
||||
assert_eq!(title_from_path("simple.md"), "simple");
|
||||
assert_eq!(title_from_path("deep/nested/file.md"), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_basic() {
|
||||
let stdout =
|
||||
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
|
||||
let commits = parse_pulse_output(stdout, &None);
|
||||
|
||||
assert_eq!(commits.len(), 1);
|
||||
assert_eq!(commits[0].message, "Add notes");
|
||||
assert_eq!(commits[0].files.len(), 2);
|
||||
assert_eq!(commits[0].files[0].status, "added");
|
||||
assert_eq!(commits[0].files[1].status, "modified");
|
||||
assert_eq!(commits[0].added, 1);
|
||||
assert_eq!(commits[0].modified, 1);
|
||||
assert!(commits[0].github_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_with_github() {
|
||||
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
|
||||
let base = Some("https://github.com/o/r".to_string());
|
||||
let commits = parse_pulse_output(stdout, &base);
|
||||
|
||||
assert_eq!(
|
||||
commits[0].github_url.as_deref(),
|
||||
Some("https://github.com/o/r/commit/abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pulse_output_multiple_commits() {
|
||||
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
|
||||
let commits = parse_pulse_output(stdout, &None);
|
||||
|
||||
assert_eq!(commits.len(), 2);
|
||||
assert_eq!(commits[0].message, "First");
|
||||
assert_eq!(commits[1].message, "Second");
|
||||
assert_eq!(commits[1].files.len(), 2);
|
||||
assert_eq!(commits[1].deleted, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_with_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap();
|
||||
assert!(info.is_some());
|
||||
let info = info.unwrap();
|
||||
assert_eq!(info.short_hash.len(), 7);
|
||||
assert!(info.commit_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_no_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vp = dir.path().to_str().unwrap();
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap();
|
||||
assert!(info.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_commit_info_with_github_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/lucaong/laputa-vault.git",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let info = get_last_commit_info(vp).unwrap().unwrap();
|
||||
assert!(info.commit_url.is_some());
|
||||
let url = info.commit_url.unwrap();
|
||||
assert!(url.starts_with("https://github.com/lucaong/laputa-vault/commit/"));
|
||||
}
|
||||
}
|
||||
246
src-tauri/src/git/remote.rs
Normal file
246
src-tauri/src/git/remote.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::conflict::get_conflict_files;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPullResult {
|
||||
pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error"
|
||||
pub message: String,
|
||||
#[serde(rename = "updatedFiles")]
|
||||
pub updated_files: Vec<String>,
|
||||
#[serde(rename = "conflictFiles")]
|
||||
pub conflict_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check whether the vault repo has at least one remote configured.
|
||||
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git remote: {}", e))?;
|
||||
|
||||
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
|
||||
}
|
||||
|
||||
/// Pull latest changes from remote. Uses --no-rebase to merge.
|
||||
/// Returns a structured result with status and affected files.
|
||||
pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !has_remote(vault_path)? {
|
||||
return Ok(GitPullResult {
|
||||
status: "no_remote".to_string(),
|
||||
message: "No remote configured".to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["pull", "--no-rebase"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git pull: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
if output.status.success() {
|
||||
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
|
||||
return Ok(GitPullResult {
|
||||
status: "up_to_date".to_string(),
|
||||
message: "Already up to date".to_string(),
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
let updated = parse_updated_files(&stdout);
|
||||
return Ok(GitPullResult {
|
||||
status: "updated".to_string(),
|
||||
message: format!("{} file(s) updated", updated.len()),
|
||||
updated_files: updated,
|
||||
conflict_files: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Check for merge conflicts
|
||||
let conflicts = get_conflict_files(vault_path).unwrap_or_default();
|
||||
if !conflicts.is_empty() {
|
||||
return Ok(GitPullResult {
|
||||
status: "conflict".to_string(),
|
||||
message: format!("Merge conflict in {} file(s)", conflicts.len()),
|
||||
updated_files: vec![],
|
||||
conflict_files: conflicts,
|
||||
});
|
||||
}
|
||||
|
||||
// Network error or other failure — report as error
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout.trim().to_string()
|
||||
} else {
|
||||
stderr.trim().to_string()
|
||||
};
|
||||
Ok(GitPullResult {
|
||||
status: "error".to_string(),
|
||||
message: detail,
|
||||
updated_files: vec![],
|
||||
conflict_files: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `git pull` output to extract updated file paths.
|
||||
fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
// Lines like " path/to/file.md | 5 ++-" in diffstat
|
||||
if trimmed.contains('|') {
|
||||
let path = trimmed.split('|').next()?.trim();
|
||||
if !path.is_empty() {
|
||||
return Some(path.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["push"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git push: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git push failed: {}", stderr));
|
||||
}
|
||||
|
||||
// git push often writes to stderr even on success
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(format!("{}{}", stdout, stderr))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_false_for_local_repo() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
assert!(!has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_true_when_remote_exists() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["remote", "add", "origin", "https://example.com/repo.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(has_remote(vp).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_no_remote_returns_no_remote() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
let result = git_pull(vp).unwrap();
|
||||
assert_eq!(result.status, "no_remote");
|
||||
assert!(result.updated_files.is_empty());
|
||||
assert!(result.conflict_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_up_to_date() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
let result = git_pull(vp_a).unwrap();
|
||||
assert_eq!(result.status, "up_to_date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_updated_files() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Updated Note\n").unwrap();
|
||||
git_commit(vp_a, "update note").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
let result = git_pull(vp_b).unwrap();
|
||||
assert_eq!(result.status, "updated");
|
||||
assert!(result.conflict_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_updated_files_diffstat() {
|
||||
let stdout =
|
||||
" Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n";
|
||||
let files = parse_updated_files(stdout);
|
||||
assert_eq!(files, vec!["note.md", "project/plan.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_updated_files_empty() {
|
||||
let stdout = "Already up to date.\n";
|
||||
let files = parse_updated_files(stdout);
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
status: "updated".to_string(),
|
||||
message: "2 file(s) updated".to_string(),
|
||||
updated_files: vec!["note.md".to_string()],
|
||||
conflict_files: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"updatedFiles\""));
|
||||
assert!(json.contains("\"conflictFiles\""));
|
||||
|
||||
let parsed: GitPullResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "updated");
|
||||
assert_eq!(parsed.updated_files.len(), 1);
|
||||
}
|
||||
}
|
||||
175
src-tauri/src/git/status.rs
Normal file
175
src-tauri/src/git/status.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ModifiedFile {
|
||||
pub path: String,
|
||||
#[serde(rename = "relativePath")]
|
||||
pub relative_path: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
|
||||
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git status failed: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let files = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|line| {
|
||||
if line.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let status_code = &line[..2];
|
||||
let path = line[3..].trim().to_string();
|
||||
|
||||
// Only include markdown files
|
||||
if !path.ends_with(".md") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = match status_code.trim() {
|
||||
"M" | "MM" | "AM" => "modified",
|
||||
"A" => "added",
|
||||
"D" => "deleted",
|
||||
"??" => "untracked",
|
||||
"R" | "RM" => "renamed",
|
||||
_ => "modified",
|
||||
};
|
||||
|
||||
let full_path = vault.join(&path).to_string_lossy().to_string();
|
||||
|
||||
Some(ModifiedFile {
|
||||
path: full_path,
|
||||
relative_path: path,
|
||||
status: status.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create and commit a file
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "note.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Add note"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Modify it
|
||||
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
|
||||
// Add an untracked file
|
||||
fs::write(vault.join("new.md"), "# New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(modified.len() >= 2);
|
||||
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
||||
assert!(statuses.contains(&"modified"));
|
||||
assert!(statuses.contains(&"untracked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_untracked_in_subdirectory() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create initial commit so git is initialized
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "init.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a new untracked file in a subdirectory (simulates new note creation)
|
||||
fs::create_dir_all(vault.join("note")).unwrap();
|
||||
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(modified.len(), 1);
|
||||
assert_eq!(modified[0].status, "untracked");
|
||||
assert_eq!(modified[0].relative_path, "note/brand-new.md");
|
||||
assert!(
|
||||
modified[0].path.ends_with("/note/brand-new.md"),
|
||||
"Full path should end with relative path: {}",
|
||||
modified[0].path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_flow_modified_files_then_commit_clears() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
// Create and commit initial file
|
||||
fs::write(vault.join("flow.md"), "# Original\n").unwrap();
|
||||
git_commit(vp, "initial").unwrap();
|
||||
|
||||
// Modify the file on disk
|
||||
fs::write(vault.join("flow.md"), "# Modified\n").unwrap();
|
||||
|
||||
// get_modified_files should detect the change
|
||||
let modified = get_modified_files(vp).unwrap();
|
||||
assert!(
|
||||
modified.iter().any(|f| f.relative_path == "flow.md"),
|
||||
"Modified file should be detected after write"
|
||||
);
|
||||
|
||||
// Commit the change
|
||||
let result = git_commit(vp, "update flow").unwrap();
|
||||
assert!(
|
||||
result.contains("1 file changed") || result.contains("flow.md"),
|
||||
"Commit output should reference the changed file: {}",
|
||||
result
|
||||
);
|
||||
|
||||
// After commit, get_modified_files should return empty
|
||||
let after = get_modified_files(vp).unwrap();
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"No modified files should remain after commit, found: {:?}",
|
||||
after
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,886 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
|
||||
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github_create_repo_with_base(token, name, private, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_create_repo_with_base(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
api_base: &str,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"private": private,
|
||||
"auto_init": true,
|
||||
"description": "Laputa vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GithubRepo>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
|
||||
}
|
||||
|
||||
// --- OAuth Device Flow ---
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowStart {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowPollResult {
|
||||
pub status: String,
|
||||
pub access_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 404 {
|
||||
return Err(
|
||||
"GitHub device flow not available. Ensure a GitHub App is registered with \
|
||||
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Device flow start failed ({}): {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<DeviceFlowStart>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device flow response: {}", e))
|
||||
}
|
||||
|
||||
/// Polls GitHub for the device flow authorization result.
|
||||
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
|
||||
github_device_flow_poll_with_base(device_code, "https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_poll_with_base(
|
||||
device_code: &str,
|
||||
base_url: &str,
|
||||
) -> Result<DeviceFlowPollResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/oauth/access_token", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Device flow poll HTTP error: {}", body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
let raw: RawResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
|
||||
|
||||
if let Some(token) = raw.access_token {
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
} else {
|
||||
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
|
||||
let status = match error.as_str() {
|
||||
"authorization_pending" | "slow_down" => "pending",
|
||||
"expired_token" => "expired",
|
||||
_ => "error",
|
||||
};
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: status.to_string(),
|
||||
access_token: None,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the authenticated GitHub user's profile.
|
||||
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(format!("{}/user", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub user request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GitHubUser>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))
|
||||
}
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
|
||||
if dest.exists()
|
||||
&& dest
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", &auth_url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up partial clone on failure
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git clone failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
configure_remote_auth(local_path, url, token)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
// ── Mock helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
|
||||
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
|
||||
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
|
||||
let (server, mock) = mock_json("GET", "/user", status, body).await;
|
||||
let result = github_get_user_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
fn clone_err_contains(url: &str, expected: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let result = clone_repo(url, "token", dest.to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains(expected));
|
||||
}
|
||||
|
||||
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
let (server, mock) = mock_json(
|
||||
"GET",
|
||||
"/user/repos?per_page=100&sort=updated&page=1",
|
||||
status,
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
let result = github_list_repos_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
|
||||
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
|
||||
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
|
||||
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
// ── Sync/pure logic tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:gho_abc123@github.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_generic_https_url() {
|
||||
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_ssh_url_rejected() {
|
||||
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
|
||||
assert!(err.contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_http_url_rejected() {
|
||||
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_github_without_dot_git() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo", "tok");
|
||||
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/test/repo.git",
|
||||
"token",
|
||||
dir.path().to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_ssh_url_rejected() {
|
||||
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/nonexistent/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_remote_auth_on_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/user/repo.git",
|
||||
])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
"https://github.com/user/repo.git",
|
||||
"gho_test123",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
// ── Serialization roundtrip tests ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization_roundtrip() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(!repo.private);
|
||||
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_start_serialization_roundtrip() {
|
||||
let start = DeviceFlowStart {
|
||||
device_code: "dc_123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
};
|
||||
let json = serde_json::to_string(&start).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_poll_result_roundtrip() {
|
||||
let complete = DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_abc123".to_string()),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&complete).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
complete
|
||||
);
|
||||
|
||||
let pending = DeviceFlowPollResult {
|
||||
status: "pending".to_string(),
|
||||
access_token: None,
|
||||
error: Some("authorization_pending".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_serialization_roundtrip() {
|
||||
let user = GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_deserialization_null_name() {
|
||||
let user: GitHubUser =
|
||||
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
|
||||
// ── HTTP mock: list repos ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_success() {
|
||||
let repos = mock_list_repos(
|
||||
200,
|
||||
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].name, "my-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_empty() {
|
||||
let repos = mock_list_repos(200, "[]").await.unwrap();
|
||||
assert!(repos.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_auth_error() {
|
||||
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_paginated() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let repos_page1: Vec<serde_json::Value> = (0..100)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"name": format!("repo-{}", i),
|
||||
"full_name": format!("user/repo-{}", i),
|
||||
"description": null,
|
||||
"private": false,
|
||||
"clone_url": format!("https://github.com/user/repo-{}.git", i),
|
||||
"html_url": format!("https://github.com/user/repo-{}", i),
|
||||
"updated_at": null
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mock1 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&repos_page1).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
let mock2 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let repos = github_list_repos_with_base("token", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock1.assert_async().await;
|
||||
mock2.assert_async().await;
|
||||
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[100].name, "extra-repo");
|
||||
}
|
||||
|
||||
// ── HTTP mock: create repo ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_success() {
|
||||
let repo = mock_create_repo(
|
||||
201,
|
||||
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let err = mock_create_repo(
|
||||
422,
|
||||
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
// ── HTTP mock: device flow start ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let start = mock_device_start(
|
||||
200,
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
start,
|
||||
DeviceFlowStart {
|
||||
device_code: "dev_abc".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let err = mock_device_start(400, "bad request").await.unwrap_err();
|
||||
assert!(err.contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_404_gives_clear_message() {
|
||||
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("device flow not available"));
|
||||
assert!(err.contains("Device authorization flow"));
|
||||
}
|
||||
|
||||
// ── HTTP mock: device flow poll ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let poll =
|
||||
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll,
|
||||
DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_secret123".to_string()),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let poll = mock_poll(
|
||||
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
mock.assert_async().await;
|
||||
assert!(err.contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let poll = mock_poll(r#"{}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
|
||||
// ── HTTP mock: get user ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user,
|
||||
GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
326
src-tauri/src/github/api.rs
Normal file
326
src-tauri/src/github/api.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use super::{GitHubUser, GithubRepo};
|
||||
|
||||
/// Lists the authenticated user's GitHub repositories.
|
||||
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
github_list_repos_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_list_repos_with_base(
|
||||
token: &str,
|
||||
api_base: &str,
|
||||
) -> Result<Vec<GithubRepo>, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut all_repos: Vec<GithubRepo> = Vec::new();
|
||||
let mut page = 1u32;
|
||||
|
||||
loop {
|
||||
let url = format!(
|
||||
"{}/user/repos?per_page=100&sort=updated&page={}",
|
||||
api_base, page
|
||||
);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
let repos: Vec<GithubRepo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
|
||||
|
||||
let count = repos.len();
|
||||
all_repos.extend(repos);
|
||||
|
||||
if count < 100 {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
if page > 10 {
|
||||
break; // safety limit: 1000 repos max
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_repos)
|
||||
}
|
||||
|
||||
/// Creates a new GitHub repository for the authenticated user.
|
||||
pub async fn github_create_repo(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github_create_repo_with_base(token, name, private, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_create_repo_with_base(
|
||||
token: &str,
|
||||
name: &str,
|
||||
private: bool,
|
||||
api_base: &str,
|
||||
) -> Result<GithubRepo, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"private": private,
|
||||
"auto_init": true,
|
||||
"description": "Laputa vault"
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(format!("{}/user/repos", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub API request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 422 && body.contains("name already exists") {
|
||||
return Err("Repository name already exists on your account".to_string());
|
||||
}
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GithubRepo>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
|
||||
}
|
||||
|
||||
/// Gets the authenticated GitHub user's profile.
|
||||
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
|
||||
github_get_user_with_base(token, "https://api.github.com").await
|
||||
}
|
||||
|
||||
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(format!("{}/user", api_base))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("GitHub user request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("GitHub API error {}: {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<GitHubUser>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
|
||||
let (server, mock) = mock_json(
|
||||
"GET",
|
||||
"/user/repos?per_page=100&sort=updated&page=1",
|
||||
status,
|
||||
body,
|
||||
)
|
||||
.await;
|
||||
let result = github_list_repos_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
|
||||
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
|
||||
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
|
||||
let (server, mock) = mock_json("GET", "/user", status, body).await;
|
||||
let result = github_get_user_with_base("token", &server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_success() {
|
||||
let repos = mock_list_repos(
|
||||
200,
|
||||
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0].name, "my-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_empty() {
|
||||
let repos = mock_list_repos(200, "[]").await.unwrap();
|
||||
assert!(repos.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_auth_error() {
|
||||
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_list_repos_paginated() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let repos_page1: Vec<serde_json::Value> = (0..100)
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"name": format!("repo-{}", i),
|
||||
"full_name": format!("user/repo-{}", i),
|
||||
"description": null,
|
||||
"private": false,
|
||||
"clone_url": format!("https://github.com/user/repo-{}.git", i),
|
||||
"html_url": format!("https://github.com/user/repo-{}", i),
|
||||
"updated_at": null
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mock1 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(serde_json::to_string(&repos_page1).unwrap())
|
||||
.create_async()
|
||||
.await;
|
||||
let mock2 = server
|
||||
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let repos = github_list_repos_with_base("token", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock1.assert_async().await;
|
||||
mock2.assert_async().await;
|
||||
|
||||
assert_eq!(repos.len(), 101);
|
||||
assert_eq!(repos[100].name, "extra-repo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_success() {
|
||||
let repo = mock_create_repo(
|
||||
201,
|
||||
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(repo.name, "new-repo");
|
||||
assert!(repo.private);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_name_exists() {
|
||||
let err = mock_create_repo(
|
||||
422,
|
||||
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Repository name already exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_create_repo_server_error() {
|
||||
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 500"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_success() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user,
|
||||
GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_unauthorized() {
|
||||
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("GitHub API error 401"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_get_user_null_name() {
|
||||
let user = mock_user(
|
||||
200,
|
||||
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.login, "bot-account");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
242
src-tauri/src/github/auth.rs
Normal file
242
src-tauri/src/github/auth.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID};
|
||||
|
||||
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github_device_flow_start_with_base("https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/device/code", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 404 {
|
||||
return Err(
|
||||
"GitHub device flow not available. Ensure a GitHub App is registered with \
|
||||
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Err(format!("Device flow start failed ({}): {}", status, body));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<DeviceFlowStart>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse device flow response: {}", e))
|
||||
}
|
||||
|
||||
/// Polls GitHub for the device flow authorization result.
|
||||
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
|
||||
github_device_flow_poll_with_base(device_code, "https://github.com").await
|
||||
}
|
||||
|
||||
async fn github_device_flow_poll_with_base(
|
||||
device_code: &str,
|
||||
base_url: &str,
|
||||
) -> Result<DeviceFlowPollResult, String> {
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{}/login/oauth/access_token", base_url))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "Laputa-App")
|
||||
.form(&[
|
||||
("client_id", GITHUB_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Device flow poll failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Device flow poll HTTP error: {}", body));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
let raw: RawResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
|
||||
|
||||
if let Some(token) = raw.access_token {
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some(token),
|
||||
error: None,
|
||||
})
|
||||
} else {
|
||||
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
|
||||
let status = match error.as_str() {
|
||||
"authorization_pending" | "slow_down" => "pending",
|
||||
"expired_token" => "expired",
|
||||
_ => "error",
|
||||
};
|
||||
Ok(DeviceFlowPollResult {
|
||||
status: status.to_string(),
|
||||
access_token: None,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mock_json(
|
||||
method: &str,
|
||||
path: &str,
|
||||
status: usize,
|
||||
body: &str,
|
||||
) -> (mockito::ServerGuard, mockito::Mock) {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock(method, path)
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
(server, mock)
|
||||
}
|
||||
|
||||
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
|
||||
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
|
||||
let result = github_device_flow_start_with_base(&server.url()).await;
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
|
||||
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
|
||||
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap();
|
||||
mock.assert_async().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_success() {
|
||||
let start = mock_device_start(
|
||||
200,
|
||||
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
start,
|
||||
DeviceFlowStart {
|
||||
device_code: "dev_abc".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_error() {
|
||||
let err = mock_device_start(400, "bad request").await.unwrap_err();
|
||||
assert!(err.contains("Device flow start failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_start_404_gives_clear_message() {
|
||||
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("device flow not available"));
|
||||
assert!(err.contains("Device authorization flow"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_complete() {
|
||||
let poll =
|
||||
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll,
|
||||
DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_secret123".to_string()),
|
||||
error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_pending() {
|
||||
let poll = mock_poll(
|
||||
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("authorization_pending".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_slow_down() {
|
||||
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
|
||||
assert_eq!(poll.status, "pending");
|
||||
assert_eq!(poll.error, Some("slow_down".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_expired() {
|
||||
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
|
||||
assert_eq!(poll.status, "expired");
|
||||
assert_eq!(poll.error, Some("expired_token".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_other_error() {
|
||||
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("access_denied".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_http_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let mock = server
|
||||
.mock("POST", "/login/oauth/access_token")
|
||||
.with_status(503)
|
||||
.with_body("Service Unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
|
||||
.await
|
||||
.unwrap_err();
|
||||
mock.assert_async().await;
|
||||
assert!(err.contains("Device flow poll HTTP error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_github_device_flow_poll_unknown_error() {
|
||||
let poll = mock_poll(r#"{}"#).await;
|
||||
assert_eq!(poll.status, "error");
|
||||
assert_eq!(poll.error, Some("unknown".to_string()));
|
||||
}
|
||||
}
|
||||
203
src-tauri/src/github/clone.rs
Normal file
203
src-tauri/src/github/clone.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
|
||||
if dest.exists()
|
||||
&& dest
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", &auth_url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up partial clone on failure
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git clone failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
configure_remote_auth(local_path, url, token)?;
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
|
||||
} else if let Some(rest) = url.strip_prefix("https://") {
|
||||
// Handle URLs that already have a host
|
||||
Ok(format!("https://oauth2:{}@{}", token, rest))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported URL format: {}. Use an HTTPS URL.",
|
||||
url
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
let vault = Path::new(local_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "set-url", "origin", &auth_url])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to configure remote: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to set remote URL: {}", stderr));
|
||||
}
|
||||
|
||||
// Also configure git user if not set
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn clone_err_contains(url: &str, expected: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let result = clone_repo(url, "token", dest.to_str().unwrap());
|
||||
assert!(result.unwrap_err().contains(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_basic_github_url() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:gho_abc123@github.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_generic_https_url() {
|
||||
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_ssh_url_rejected() {
|
||||
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
|
||||
assert!(err.contains("Unsupported URL format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_http_url_rejected() {
|
||||
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_token_github_without_dot_git() {
|
||||
let result = inject_token_into_url("https://github.com/user/repo", "tok");
|
||||
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_nonempty_dest() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/test/repo.git",
|
||||
"token",
|
||||
dir.path().to_str().unwrap(),
|
||||
);
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_ssh_url_rejected() {
|
||||
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_repo_empty_dest_allowed() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("empty-dir");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
|
||||
let result = clone_repo(
|
||||
"https://github.com/nonexistent/repo.git",
|
||||
"token",
|
||||
dest.to_str().unwrap(),
|
||||
);
|
||||
// Should fail at git clone, not at directory check
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_remote_auth_on_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
"https://github.com/user/repo.git",
|
||||
])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
configure_remote_auth(
|
||||
path.to_str().unwrap(),
|
||||
"https://github.com/user/repo.git",
|
||||
"gho_test123",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
}
|
||||
139
src-tauri/src/github/mod.rs
Normal file
139
src-tauri/src/github/mod.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod clone;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api::{github_create_repo, github_get_user, github_list_repos};
|
||||
pub use auth::{github_device_flow_poll, github_device_flow_start};
|
||||
pub use clone::clone_repo;
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
|
||||
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GithubRepo {
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowStart {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DeviceFlowPollResult {
|
||||
pub status: String,
|
||||
pub access_token: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GitHubUser {
|
||||
pub login: String,
|
||||
pub name: Option<String>,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_serialization_roundtrip() {
|
||||
let repo = GithubRepo {
|
||||
name: "test-repo".to_string(),
|
||||
full_name: "user/test-repo".to_string(),
|
||||
description: Some("A test repo".to_string()),
|
||||
private: true,
|
||||
clone_url: "https://github.com/user/test-repo.git".to_string(),
|
||||
html_url: "https://github.com/user/test-repo".to_string(),
|
||||
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&repo).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_repo_deserialization_null_fields() {
|
||||
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
|
||||
let repo: GithubRepo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(repo.name, "r");
|
||||
assert!(!repo.private);
|
||||
assert!(repo.description.is_none());
|
||||
assert!(repo.updated_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_start_serialization_roundtrip() {
|
||||
let start = DeviceFlowStart {
|
||||
device_code: "dc_123".to_string(),
|
||||
user_code: "ABCD-1234".to_string(),
|
||||
verification_uri: "https://github.com/login/device".to_string(),
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
};
|
||||
let json = serde_json::to_string(&start).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
|
||||
start
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_device_flow_poll_result_roundtrip() {
|
||||
let complete = DeviceFlowPollResult {
|
||||
status: "complete".to_string(),
|
||||
access_token: Some("gho_abc123".to_string()),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&complete).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
complete
|
||||
);
|
||||
|
||||
let pending = DeviceFlowPollResult {
|
||||
status: "pending".to_string(),
|
||||
access_token: None,
|
||||
error: Some("authorization_pending".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&pending).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
|
||||
pending
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_serialization_roundtrip() {
|
||||
let user = GitHubUser {
|
||||
login: "lucaong".to_string(),
|
||||
name: Some("Luca Ongaro".to_string()),
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&user).unwrap();
|
||||
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_user_deserialization_null_name() {
|
||||
let user: GitHubUser =
|
||||
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(user.login, "bot");
|
||||
assert!(user.name.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ai_chat;
|
||||
pub mod claude_cli;
|
||||
mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
@@ -13,544 +14,11 @@ pub mod vault;
|
||||
pub mod vault_config;
|
||||
pub mod vault_list;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
use std::process::Child;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
|
||||
use frontmatter::FrontmatterValue;
|
||||
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use indexing::{IndexStatus, IndexingProgress};
|
||||
use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
use theme::{ThemeFile, VaultSettings};
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
use vault_config::VaultConfig;
|
||||
use vault_list::VaultList;
|
||||
|
||||
/// 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.
|
||||
fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::scan_vault_cached(Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_note_content(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::get_note_content(Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_note_content(path: String, content: String) -> Result<(), String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::save_note_content(&path, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_frontmatter(
|
||||
path: String,
|
||||
key: String,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::update_frontmatter(&path, &key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::delete_frontmatter_property(&path, &key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_history(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_vault_pulse(vault_path: String, limit: Option<usize>) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let limit = limit.unwrap_or(30);
|
||||
git::get_vault_pulse(&vault_path, limit)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
fn parse_build_label(version: &str) -> String {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
match parts.as_slice() {
|
||||
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
|
||||
[_, _, _] => "dev".to_string(),
|
||||
_ => "b?".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_build_number(app_handle: tauri::AppHandle) -> String {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
parse_build_label(&version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_conflict_mode(vault_path: String) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_push(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_claude_cli() -> ClaudeCliStatus {
|
||||
claude_cli::check_cli()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::save_image(&vault_path, &filename, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::copy_image_to_vault(&vault_path, &source_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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]
|
||||
fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = match target_path {
|
||||
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
|
||||
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
|
||||
};
|
||||
vault::create_getting_started_vault(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_vault_exists(path: String) -> bool {
|
||||
let path = expand_tilde(&path);
|
||||
vault::vault_exists(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_default_vault_path() -> Result<String, String> {
|
||||
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"Trashed at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_menu_state(
|
||||
app_handle: tauri::AppHandle,
|
||||
has_active_note: bool,
|
||||
has_modified_files: Option<bool>,
|
||||
has_conflicts: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
if let Some(v) = has_modified_files {
|
||||
menu::set_git_commit_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = has_conflicts {
|
||||
menu::set_git_conflict_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings() -> Result<Settings, String> {
|
||||
settings::get_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_vault_list() -> Result<VaultList, String> {
|
||||
vault_list::load_vault_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
let limit = limit.unwrap_or(20);
|
||||
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_index_status(vault_path: String) -> IndexStatus {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
indexing::check_index_status(&vault_path)
|
||||
}
|
||||
|
||||
fn emit_unavailable(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "unavailable".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some("qmd not available".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if indexing::find_qmd_binary().is_none() {
|
||||
log::info!("qmd binary not found — attempting auto-install via bun");
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "installing".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
match indexing::try_auto_install_qmd() {
|
||||
Ok(()) if indexing::find_qmd_binary().is_some() => {
|
||||
log::info!("qmd auto-installed successfully, proceeding with indexing");
|
||||
}
|
||||
Ok(()) => {
|
||||
log::warn!("qmd auto-install reported success but binary still not found");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err("qmd not available after install".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("qmd auto-install failed: {e}");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err(format!("qmd not available: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexing::run_full_index(&vault_path, |progress| {
|
||||
let _ = app_handle.emit("indexing-progress", &progress);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Indexing task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Incremental index failed: {e}"))?
|
||||
}
|
||||
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[tauri::command]
|
||||
async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || mcp::register_mcp(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Registration task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_mcp_status() -> Result<mcp::McpStatus, String> {
|
||||
tokio::task::spawn_blocking(mcp::check_mcp_status)
|
||||
.await
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::list_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_theme(&vault_path, &theme_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_vault_settings(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::save_vault_settings(&vault_path, settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::set_active_theme(&vault_path, theme_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_theme(&vault_path, source_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_vault_theme(&vault_path, name.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::ensure_vault_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::get_vault_config(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::save_vault_config(&vault_path, config)
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
||||
@@ -589,61 +57,6 @@ fn run_startup_tasks() {
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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_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?");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_ws_bridge(app: &mut tauri::App) {
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
@@ -670,11 +83,6 @@ pub fn run() {
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
// Open devtools automatically in debug builds
|
||||
// use tauri::Manager;
|
||||
// if let Some(window) = app.get_webview_window("main") {
|
||||
// window.open_devtools();
|
||||
// }
|
||||
}
|
||||
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
@@ -693,68 +101,68 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_vault,
|
||||
get_note_content,
|
||||
save_note_content,
|
||||
update_frontmatter,
|
||||
delete_frontmatter_property,
|
||||
rename_note,
|
||||
get_file_history,
|
||||
get_modified_files,
|
||||
get_file_diff,
|
||||
get_file_diff_at_commit,
|
||||
get_vault_pulse,
|
||||
git_commit,
|
||||
get_build_number,
|
||||
get_last_commit_info,
|
||||
git_pull,
|
||||
git_push,
|
||||
get_conflict_files,
|
||||
get_conflict_mode,
|
||||
git_resolve_conflict,
|
||||
git_commit_conflict_resolution,
|
||||
ai_chat,
|
||||
check_claude_cli,
|
||||
stream_claude_chat,
|
||||
stream_claude_agent,
|
||||
save_image,
|
||||
copy_image_to_vault,
|
||||
purge_trash,
|
||||
delete_note,
|
||||
migrate_is_a_to_type,
|
||||
batch_archive_notes,
|
||||
batch_trash_notes,
|
||||
get_settings,
|
||||
update_menu_state,
|
||||
save_settings,
|
||||
load_vault_list,
|
||||
save_vault_list,
|
||||
github_list_repos,
|
||||
github_create_repo,
|
||||
clone_repo,
|
||||
github_device_flow_start,
|
||||
github_device_flow_poll,
|
||||
github_get_user,
|
||||
search_vault,
|
||||
get_index_status,
|
||||
start_indexing,
|
||||
trigger_incremental_index,
|
||||
create_getting_started_vault,
|
||||
check_vault_exists,
|
||||
get_default_vault_path,
|
||||
register_mcp_tools,
|
||||
check_mcp_status,
|
||||
list_themes,
|
||||
get_theme,
|
||||
get_vault_settings,
|
||||
save_vault_settings,
|
||||
set_active_theme,
|
||||
create_theme,
|
||||
create_vault_theme,
|
||||
ensure_vault_themes,
|
||||
restore_default_themes,
|
||||
get_vault_config,
|
||||
save_vault_config
|
||||
commands::list_vault,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
commands::get_file_diff_at_commit,
|
||||
commands::get_vault_pulse,
|
||||
commands::git_commit,
|
||||
commands::get_build_number,
|
||||
commands::get_last_commit_info,
|
||||
commands::git_pull,
|
||||
commands::git_push,
|
||||
commands::get_conflict_files,
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::ai_chat,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
commands::delete_note,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::github_list_repos,
|
||||
commands::github_create_repo,
|
||||
commands::clone_repo,
|
||||
commands::github_device_flow_start,
|
||||
commands::github_device_flow_poll,
|
||||
commands::github_get_user,
|
||||
commands::search_vault,
|
||||
commands::get_index_status,
|
||||
commands::start_indexing,
|
||||
commands::trigger_incremental_index,
|
||||
commands::create_getting_started_vault,
|
||||
commands::check_vault_exists,
|
||||
commands::get_default_vault_path,
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::list_themes,
|
||||
commands::get_theme,
|
||||
commands::get_vault_settings,
|
||||
commands::save_vault_settings,
|
||||
commands::set_active_theme,
|
||||
commands::create_theme,
|
||||
commands::create_vault_theme,
|
||||
commands::ensure_vault_themes,
|
||||
commands::restore_default_themes,
|
||||
commands::get_vault_config,
|
||||
commands::save_vault_config
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
219
src-tauri/src/theme/create.rs
Normal file
219
src-tauri/src/theme/create.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::defaults::DEFAULT_VAULT_THEME_VARS;
|
||||
|
||||
/// Create a new vault theme note in `theme/` directory.
|
||||
/// Returns the absolute path to the newly created theme note.
|
||||
pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String, String> {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
|
||||
let display_name = name.unwrap_or("Untitled Theme");
|
||||
let slug = slugify(display_name);
|
||||
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
|
||||
let path = theme_dir.join(&filename);
|
||||
|
||||
let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS);
|
||||
fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?;
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Create a new theme file by copying the active theme (or default).
|
||||
/// Returns the ID of the new theme.
|
||||
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
|
||||
let new_id = find_available_stem(&themes_dir, "untitled", "json");
|
||||
|
||||
let source = source_id.unwrap_or("default");
|
||||
let source_path = themes_dir.join(format!("{source}.json"));
|
||||
|
||||
let content = if source_path.exists() {
|
||||
let mut theme: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(&source_path)
|
||||
.map_err(|e| format!("Failed to read source theme: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to parse source theme: {e}"))?;
|
||||
|
||||
if let Some(obj) = theme.as_object_mut() {
|
||||
obj.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String("Untitled Theme".to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string_pretty(&theme)
|
||||
.map_err(|e| format!("Failed to serialize new theme: {e}"))?
|
||||
} else {
|
||||
default_theme_json("Untitled Theme")
|
||||
};
|
||||
|
||||
fs::write(themes_dir.join(format!("{new_id}.json")), content)
|
||||
.map_err(|e| format!("Failed to write new theme: {e}"))?;
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
/// Convert a display name to a URL-safe slug.
|
||||
fn slugify(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Find an available filename stem (base, base-2, base-3, …) that doesn't
|
||||
/// conflict when `ext` is appended.
|
||||
fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
|
||||
if !dir.join(format!("{base}.{ext}")).exists() {
|
||||
return base.to_string();
|
||||
}
|
||||
for i in 2.. {
|
||||
let candidate = format!("{base}-{i}");
|
||||
if !dir.join(format!("{candidate}.{ext}")).exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Build a vault theme note markdown string from a name and CSS variable map.
|
||||
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
|
||||
for (key, value) in vars {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') {
|
||||
fm.push_str(&format!("{key}: \"{value}\"\n"));
|
||||
} else {
|
||||
fm.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
}
|
||||
fm.push_str("---\n\n");
|
||||
fm.push_str(&format!(
|
||||
"# {name} Theme\n\nA custom {name} theme for Laputa.\n"
|
||||
));
|
||||
fm
|
||||
}
|
||||
|
||||
/// Generate the default light theme JSON.
|
||||
fn default_theme_json(name: &str) -> String {
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": name,
|
||||
"description": "Custom theme",
|
||||
"colors": {
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#37352F",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"accent": "#155DFF",
|
||||
"muted": "#787774",
|
||||
"border": "#E9E9E7"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "system-ui",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "240px"
|
||||
}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::defaults::*;
|
||||
use crate::theme::get_theme;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_vault_with_themes(dir: &TempDir) -> String {
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
|
||||
vault.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_theme_copies_source() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let new_id = create_theme(&vault, Some("default")).unwrap();
|
||||
assert_eq!(new_id, "untitled");
|
||||
|
||||
let theme = get_theme(&vault, &new_id).unwrap();
|
||||
assert_eq!(theme.name, "Untitled Theme");
|
||||
assert!(!theme.colors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_theme_increments_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
|
||||
let id1 = create_theme(&vault, None).unwrap();
|
||||
assert_eq!(id1, "untitled");
|
||||
|
||||
let id2 = create_theme(&vault, None).unwrap();
|
||||
assert_eq!(id2, "untitled-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_creates_md_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let path = create_vault_theme(vp, Some("My Theme")).unwrap();
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
assert!(content.contains("# My Theme"));
|
||||
assert!(content.contains("background:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_default_name() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let path = create_vault_theme(vp, None).unwrap();
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("# Untitled Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_avoids_conflicts() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let p1 = create_vault_theme(vp, Some("Custom")).unwrap();
|
||||
let p2 = create_vault_theme(vp, Some("Custom")).unwrap();
|
||||
assert_ne!(p1, p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("My Cool Theme"), "my-cool-theme");
|
||||
assert_eq!(slugify("default"), "default");
|
||||
assert_eq!(slugify("Dark Mode!"), "dark-mode");
|
||||
}
|
||||
}
|
||||
331
src-tauri/src/theme/defaults.rs
Normal file
331
src-tauri/src/theme/defaults.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
/// Content for the built-in default (light) theme.
|
||||
pub const DEFAULT_THEME: &str = r##"{
|
||||
"name": "Default",
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"colors": {
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#37352F",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"destructive": "#E03E3E",
|
||||
"border": "#E9E9E7",
|
||||
"input": "#E9E9E7",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-foreground": "#37352F",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-accent": "#EBEBEA"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// Content for the built-in dark theme.
|
||||
pub const DARK_THEME: &str = r##"{
|
||||
"name": "Dark",
|
||||
"description": "Dark variant with deep navy tones",
|
||||
"colors": {
|
||||
"background": "#0f0f1a",
|
||||
"foreground": "#e0e0e0",
|
||||
"card": "#16162a",
|
||||
"popover": "#1e1e3a",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#2a2a4a",
|
||||
"secondary-foreground": "#e0e0e0",
|
||||
"muted": "#1e1e3a",
|
||||
"muted-foreground": "#888888",
|
||||
"accent": "#2a2a4a",
|
||||
"accent-foreground": "#e0e0e0",
|
||||
"destructive": "#f44336",
|
||||
"border": "#2a2a4a",
|
||||
"input": "#2a2a4a",
|
||||
"ring": "#155DFF",
|
||||
"sidebar-background": "#1a1a2e",
|
||||
"sidebar-foreground": "#e0e0e0",
|
||||
"sidebar-border": "#2a2a4a",
|
||||
"sidebar-accent": "#2a2a4a"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// Content for the built-in minimal theme.
|
||||
pub const MINIMAL_THEME: &str = r##"{
|
||||
"name": "Minimal",
|
||||
"description": "High contrast, minimal chrome",
|
||||
"colors": {
|
||||
"background": "#FAFAFA",
|
||||
"foreground": "#111111",
|
||||
"card": "#FFFFFF",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#000000",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"secondary": "#F0F0F0",
|
||||
"secondary-foreground": "#111111",
|
||||
"muted": "#F5F5F5",
|
||||
"muted-foreground": "#666666",
|
||||
"accent": "#F0F0F0",
|
||||
"accent-foreground": "#111111",
|
||||
"destructive": "#CC0000",
|
||||
"border": "#E0E0E0",
|
||||
"input": "#E0E0E0",
|
||||
"ring": "#000000",
|
||||
"sidebar-background": "#F5F5F5",
|
||||
"sidebar-foreground": "#111111",
|
||||
"sidebar-border": "#E0E0E0",
|
||||
"sidebar-accent": "#E8E8E8"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'SF Mono', 'Menlo', monospace",
|
||||
"font-size-base": "13px"
|
||||
},
|
||||
"spacing": {
|
||||
"sidebar-width": "220px"
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// CSS variable key-value pairs for the default light vault theme.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
// shadcn/ui base
|
||||
("background", "#FFFFFF"),
|
||||
("foreground", "#37352F"),
|
||||
("card", "#FFFFFF"),
|
||||
("popover", "#FFFFFF"),
|
||||
("primary", "#155DFF"),
|
||||
("primary-foreground", "#FFFFFF"),
|
||||
("secondary", "#EBEBEA"),
|
||||
("secondary-foreground", "#37352F"),
|
||||
("muted", "#F0F0EF"),
|
||||
("muted-foreground", "#787774"),
|
||||
("accent", "#EBEBEA"),
|
||||
("accent-foreground", "#37352F"),
|
||||
("destructive", "#E03E3E"),
|
||||
("border", "#E9E9E7"),
|
||||
("input", "#E9E9E7"),
|
||||
("ring", "#155DFF"),
|
||||
("sidebar", "#F7F6F3"),
|
||||
("sidebar-foreground", "#37352F"),
|
||||
("sidebar-border", "#E9E9E7"),
|
||||
("sidebar-accent", "#EBEBEA"),
|
||||
// Text hierarchy
|
||||
("text-primary", "#37352F"),
|
||||
("text-secondary", "#787774"),
|
||||
("text-muted", "#B4B4B4"),
|
||||
("text-heading", "#37352F"),
|
||||
// Backgrounds
|
||||
("bg-primary", "#FFFFFF"),
|
||||
("bg-sidebar", "#F7F6F3"),
|
||||
("bg-hover", "#EBEBEA"),
|
||||
("bg-hover-subtle", "#F0F0EF"),
|
||||
("bg-selected", "#E8F4FE"),
|
||||
("border-primary", "#E9E9E7"),
|
||||
// Accent colours
|
||||
("accent-blue", "#155DFF"),
|
||||
("accent-green", "#00B38B"),
|
||||
("accent-orange", "#D9730D"),
|
||||
("accent-red", "#E03E3E"),
|
||||
("accent-purple", "#A932FF"),
|
||||
("accent-yellow", "#F0B100"),
|
||||
("accent-blue-light", "#155DFF14"),
|
||||
("accent-green-light", "#00B38B14"),
|
||||
("accent-purple-light", "#A932FF14"),
|
||||
("accent-red-light", "#E03E3E14"),
|
||||
("accent-yellow-light", "#F0B10014"),
|
||||
// Typography
|
||||
(
|
||||
"font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("font-size-base", "14px"),
|
||||
// Editor
|
||||
("editor-font-size", "16"),
|
||||
("editor-line-height", "1.5"),
|
||||
("editor-max-width", "720"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Default theme.
|
||||
pub const DEFAULT_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Light theme with warm, paper-like tones\n\
|
||||
background: \"#FFFFFF\"\n\
|
||||
foreground: \"#37352F\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#EBEBEA\"\n\
|
||||
secondary-foreground: \"#37352F\"\n\
|
||||
muted: \"#F0F0EF\"\n\
|
||||
muted-foreground: \"#787774\"\n\
|
||||
accent: \"#EBEBEA\"\n\
|
||||
accent-foreground: \"#37352F\"\n\
|
||||
destructive: \"#E03E3E\"\n\
|
||||
border: \"#E9E9E7\"\n\
|
||||
input: \"#E9E9E7\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#F7F6F3\"\n\
|
||||
sidebar-foreground: \"#37352F\"\n\
|
||||
sidebar-border: \"#E9E9E7\"\n\
|
||||
sidebar-accent: \"#EBEBEA\"\n\
|
||||
text-primary: \"#37352F\"\n\
|
||||
text-secondary: \"#787774\"\n\
|
||||
text-muted: \"#B4B4B4\"\n\
|
||||
text-heading: \"#37352F\"\n\
|
||||
bg-primary: \"#FFFFFF\"\n\
|
||||
bg-sidebar: \"#F7F6F3\"\n\
|
||||
bg-hover: \"#EBEBEA\"\n\
|
||||
bg-hover-subtle: \"#F0F0EF\"\n\
|
||||
bg-selected: \"#E8F4FE\"\n\
|
||||
border-primary: \"#E9E9E7\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#E03E3E\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF14\"\n\
|
||||
accent-green-light: \"#00B38B14\"\n\
|
||||
accent-purple-light: \"#A932FF14\"\n\
|
||||
accent-red-light: \"#E03E3E14\"\n\
|
||||
accent-yellow-light: \"#F0B10014\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Default Theme\n\
|
||||
\n\
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Dark theme.
|
||||
pub const DARK_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Dark variant with deep navy tones\n\
|
||||
background: \"#0f0f1a\"\n\
|
||||
foreground: \"#e0e0e0\"\n\
|
||||
card: \"#16162a\"\n\
|
||||
popover: \"#1e1e3a\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#2a2a4a\"\n\
|
||||
secondary-foreground: \"#e0e0e0\"\n\
|
||||
muted: \"#1e1e3a\"\n\
|
||||
muted-foreground: \"#888888\"\n\
|
||||
accent: \"#2a2a4a\"\n\
|
||||
accent-foreground: \"#e0e0e0\"\n\
|
||||
destructive: \"#f44336\"\n\
|
||||
border: \"#2a2a4a\"\n\
|
||||
input: \"#2a2a4a\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#1a1a2e\"\n\
|
||||
sidebar-foreground: \"#e0e0e0\"\n\
|
||||
sidebar-border: \"#2a2a4a\"\n\
|
||||
sidebar-accent: \"#2a2a4a\"\n\
|
||||
text-primary: \"#e0e0e0\"\n\
|
||||
text-secondary: \"#888888\"\n\
|
||||
text-muted: \"#666666\"\n\
|
||||
text-heading: \"#e0e0e0\"\n\
|
||||
bg-primary: \"#0f0f1a\"\n\
|
||||
bg-sidebar: \"#1a1a2e\"\n\
|
||||
bg-hover: \"#2a2a4a\"\n\
|
||||
bg-hover-subtle: \"#1e1e3a\"\n\
|
||||
bg-selected: \"#155DFF22\"\n\
|
||||
border-primary: \"#2a2a4a\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#f44336\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF33\"\n\
|
||||
accent-green-light: \"#00B38B33\"\n\
|
||||
accent-purple-light: \"#A932FF33\"\n\
|
||||
accent-red-light: \"#f4433633\"\n\
|
||||
accent-yellow-light: \"#F0B10033\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Dark Theme\n\
|
||||
\n\
|
||||
A dark theme with deep navy tones for comfortable night-time reading.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Minimal theme.
|
||||
pub const MINIMAL_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: High contrast, minimal chrome\n\
|
||||
background: \"#FAFAFA\"\n\
|
||||
foreground: \"#111111\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#000000\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#F0F0F0\"\n\
|
||||
secondary-foreground: \"#111111\"\n\
|
||||
muted: \"#F5F5F5\"\n\
|
||||
muted-foreground: \"#666666\"\n\
|
||||
accent: \"#F0F0F0\"\n\
|
||||
accent-foreground: \"#111111\"\n\
|
||||
destructive: \"#CC0000\"\n\
|
||||
border: \"#E0E0E0\"\n\
|
||||
input: \"#E0E0E0\"\n\
|
||||
ring: \"#000000\"\n\
|
||||
sidebar: \"#F5F5F5\"\n\
|
||||
sidebar-foreground: \"#111111\"\n\
|
||||
sidebar-border: \"#E0E0E0\"\n\
|
||||
sidebar-accent: \"#E8E8E8\"\n\
|
||||
text-primary: \"#111111\"\n\
|
||||
text-secondary: \"#666666\"\n\
|
||||
text-muted: \"#999999\"\n\
|
||||
text-heading: \"#111111\"\n\
|
||||
bg-primary: \"#FAFAFA\"\n\
|
||||
bg-sidebar: \"#F5F5F5\"\n\
|
||||
bg-hover: \"#EBEBEB\"\n\
|
||||
bg-hover-subtle: \"#F5F5F5\"\n\
|
||||
bg-selected: \"#00000014\"\n\
|
||||
border-primary: \"#E0E0E0\"\n\
|
||||
accent-blue: \"#000000\"\n\
|
||||
accent-green: \"#006600\"\n\
|
||||
accent-orange: \"#996600\"\n\
|
||||
accent-red: \"#CC0000\"\n\
|
||||
accent-purple: \"#660099\"\n\
|
||||
accent-yellow: \"#996600\"\n\
|
||||
accent-blue-light: \"#00000014\"\n\
|
||||
accent-green-light: \"#00660014\"\n\
|
||||
accent-purple-light: \"#66009914\"\n\
|
||||
accent-red-light: \"#CC000014\"\n\
|
||||
accent-yellow-light: \"#99660014\"\n\
|
||||
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
|
||||
font-size-base: 13px\n\
|
||||
editor-font-size: 15\n\
|
||||
editor-line-height: 1.6\n\
|
||||
editor-max-width: 680\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Minimal Theme\n\
|
||||
\n\
|
||||
High contrast, minimal chrome. Monospace typography throughout.\n";
|
||||
268
src-tauri/src/theme/mod.rs
Normal file
268
src-tauri/src/theme/mod.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
mod create;
|
||||
pub mod defaults;
|
||||
mod seed;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub use create::{create_theme, create_vault_theme};
|
||||
pub use defaults::*;
|
||||
pub use seed::{
|
||||
ensure_vault_themes, restore_default_themes, seed_default_themes, seed_vault_themes,
|
||||
};
|
||||
|
||||
/// A theme file parsed from _themes/*.json in the vault.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThemeFile {
|
||||
/// Filename stem (e.g. "default" for _themes/default.json)
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub colors: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub typography: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub spacing: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VaultSettings {
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
/// List all theme files in _themes/ directory of the vault.
|
||||
/// Seeds built-in themes if the directory is missing.
|
||||
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
if !themes_dir.is_dir() {
|
||||
seed_default_themes(vault_path);
|
||||
}
|
||||
if !themes_dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut themes = Vec::new();
|
||||
let entries =
|
||||
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
match parse_theme_file(&path) {
|
||||
Ok(theme) => themes.push(theme),
|
||||
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
themes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(themes)
|
||||
}
|
||||
|
||||
/// Parse a single theme JSON file.
|
||||
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
|
||||
let id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "Invalid theme filename".to_string())?;
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
|
||||
|
||||
let mut theme: ThemeFile = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
|
||||
|
||||
theme.id = id;
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
/// Read vault-level settings from .laputa/settings.json.
|
||||
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
|
||||
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
return Ok(VaultSettings::default());
|
||||
}
|
||||
let content = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
|
||||
}
|
||||
|
||||
/// Save vault-level settings to .laputa/settings.json.
|
||||
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
|
||||
let laputa_dir = Path::new(vault_path).join(".laputa");
|
||||
fs::create_dir_all(&laputa_dir)
|
||||
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
|
||||
|
||||
let json = serde_json::to_string_pretty(&settings)
|
||||
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
|
||||
fs::write(laputa_dir.join("settings.json"), json)
|
||||
.map_err(|e| format!("Failed to write vault settings: {e}"))
|
||||
}
|
||||
|
||||
/// Set the active theme in vault settings. Pass `None` to clear.
|
||||
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
|
||||
let mut settings = get_vault_settings(vault_path)?;
|
||||
settings.theme = theme_id.map(|s| s.to_string());
|
||||
save_vault_settings(vault_path, settings)
|
||||
}
|
||||
|
||||
/// Read a single theme file by ID from the vault's _themes/ directory.
|
||||
pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String> {
|
||||
let path = Path::new(vault_path)
|
||||
.join("_themes")
|
||||
.join(format!("{theme_id}.json"));
|
||||
if !path.exists() {
|
||||
return Err(format!("Theme not found: {theme_id}"));
|
||||
}
|
||||
parse_theme_file(&path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_vault_with_themes(dir: &TempDir) -> String {
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
|
||||
vault.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_returns_sorted_list() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let themes = list_themes(&vault).unwrap();
|
||||
assert_eq!(themes.len(), 2);
|
||||
assert_eq!(themes[0].id, "dark");
|
||||
assert_eq!(themes[1].id, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_seeds_defaults_when_no_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("empty-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let themes = list_themes(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(themes.len(), 3);
|
||||
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"Default"));
|
||||
assert!(names.contains(&"Dark"));
|
||||
assert!(names.contains(&"Minimal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_theme_by_id() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let theme = get_theme(&vault, "default").unwrap();
|
||||
assert_eq!(theme.name, "Default");
|
||||
assert!(!theme.colors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_theme_not_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let result = get_theme(&vault, "nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_settings_roundtrip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert!(settings.theme.is_none());
|
||||
|
||||
set_active_theme(vp, Some("dark")).unwrap();
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert_eq!(settings.theme.as_deref(), Some("dark"));
|
||||
|
||||
set_active_theme(vp, None).unwrap();
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert_eq!(settings.theme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_settings_creates_laputa_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
assert!(!vault.join(".laputa").exists());
|
||||
save_vault_settings(
|
||||
vp,
|
||||
VaultSettings {
|
||||
theme: Some("light".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(vault.join(".laputa").join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_all_builtin_themes() {
|
||||
for (name, content) in [
|
||||
("default", DEFAULT_THEME),
|
||||
("dark", DARK_THEME),
|
||||
("minimal", MINIMAL_THEME),
|
||||
] {
|
||||
let theme: ThemeFile = serde_json::from_str(content)
|
||||
.unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}"));
|
||||
assert!(!theme.name.is_empty(), "{name} theme should have a name");
|
||||
assert!(!theme.colors.is_empty(), "{name} theme should have colors");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_ignores_non_json_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let themes_dir = Path::new(&vault).join("_themes");
|
||||
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
|
||||
fs::write(themes_dir.join(".DS_Store"), "").unwrap();
|
||||
|
||||
let themes = list_themes(&vault).unwrap();
|
||||
assert_eq!(themes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_themes_skips_malformed_json() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = setup_vault_with_themes(&dir);
|
||||
let themes_dir = Path::new(&vault).join("_themes");
|
||||
fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap();
|
||||
|
||||
let themes = list_themes(&vault).unwrap();
|
||||
assert_eq!(themes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_theme_content_contains_all_vars() {
|
||||
let content = DEFAULT_VAULT_THEME;
|
||||
assert!(content.contains("background:"));
|
||||
assert!(content.contains("primary:"));
|
||||
assert!(content.contains("sidebar:"));
|
||||
assert!(content.contains("text-primary:"));
|
||||
assert!(content.contains("accent-blue:"));
|
||||
assert!(content.contains("editor-font-size:"));
|
||||
}
|
||||
}
|
||||
288
src-tauri/src/theme/seed.rs
Normal file
288
src-tauri/src/theme/seed.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::defaults::*;
|
||||
|
||||
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
|
||||
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
|
||||
if dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
if fs::create_dir_all(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
for (name, content) in files {
|
||||
let _ = fs::write(dir.join(name), content);
|
||||
}
|
||||
log::info!("{log_msg}");
|
||||
}
|
||||
|
||||
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
pub fn seed_default_themes(vault_path: &str) {
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("_themes"),
|
||||
&[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
],
|
||||
"Seeded _themes/ with built-in themes",
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
|
||||
/// Per-file idempotent: creates the directory if missing, writes each default
|
||||
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
|
||||
/// existing files that have content.
|
||||
pub fn seed_vault_themes(vault_path: &str) {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
if fs::create_dir_all(&theme_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
];
|
||||
let mut seeded = false;
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, content);
|
||||
seeded = true;
|
||||
}
|
||||
}
|
||||
if seeded {
|
||||
log::info!("Seeded theme/ with built-in vault themes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure vault theme files exist. Returns an error if the theme directory
|
||||
/// cannot be created (e.g. read-only filesystem).
|
||||
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
];
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore default themes for a vault: seeds both `_themes/` (JSON) and
|
||||
/// `theme/` (markdown notes). Per-file idempotent — never overwrites files
|
||||
/// that already have content. Returns an error on read-only filesystems.
|
||||
pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
|
||||
// Seed _themes/ JSON files (per-file idempotent)
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
let json_defaults: &[(&str, &str)] = &[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
];
|
||||
for (name, content) in json_defaults {
|
||||
let path = themes_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write _themes/{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
|
||||
ensure_vault_themes(vault_path)?;
|
||||
|
||||
Ok("Default themes restored".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_creates_theme_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
assert!(!vault.join("theme").exists());
|
||||
seed_vault_themes(vp);
|
||||
assert!(vault.join("theme").is_dir());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
seed_vault_themes(vp); // second call should be a no-op
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_writes_missing_files_in_existing_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
assert!(theme_dir.join("dark.md").exists());
|
||||
assert!(theme_dir.join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_reseeds_empty_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), "").unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_preserves_existing_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("#FF0000"),
|
||||
"existing content must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_creates_dir_and_defaults() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
assert!(vault.join("theme").is_dir());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_reseeds_empty_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), "").unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_vault_themes_preserves_custom_themes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("#123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_default_themes_creates_both_dirs() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let msg = restore_default_themes(vp).unwrap();
|
||||
assert_eq!(msg, "Default themes restored");
|
||||
assert!(vault.join("_themes").join("default.json").exists());
|
||||
assert!(vault.join("_themes").join("dark.json").exists());
|
||||
assert!(vault.join("_themes").join("minimal.json").exists());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_default_themes_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n";
|
||||
fs::write(vault.join("theme").join("default.md"), custom).unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("#CUSTOM"),
|
||||
"must not overwrite existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_default_themes_fills_partial_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let themes_dir = vault.join("_themes");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
assert!(themes_dir.join("dark.json").exists());
|
||||
assert!(themes_dir.join("minimal.json").exists());
|
||||
assert!(theme_dir.join("dark.md").exists());
|
||||
assert!(theme_dir.join("minimal.md").exists());
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Light theme with warm"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user