diff --git a/.claude-pid b/.claude-pid index f56834e2..6002cf1d 100644 --- a/.claude-pid +++ b/.claude-pid @@ -1 +1 @@ -45402 +33549 diff --git a/demo-vault-v2/.laputa/settings.json b/demo-vault-v2/.laputa/settings.json new file mode 100644 index 00000000..9ecbd575 --- /dev/null +++ b/demo-vault-v2/.laputa/settings.json @@ -0,0 +1,3 @@ +{ + "theme": null +} \ No newline at end of file diff --git a/demo-vault-v2/_themes/dark.json b/demo-vault-v2/_themes/dark.json new file mode 100644 index 00000000..6d902ae2 --- /dev/null +++ b/demo-vault-v2/_themes/dark.json @@ -0,0 +1,33 @@ +{ + "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" + } +} \ No newline at end of file diff --git a/demo-vault-v2/_themes/default.json b/demo-vault-v2/_themes/default.json new file mode 100644 index 00000000..77ca9349 --- /dev/null +++ b/demo-vault-v2/_themes/default.json @@ -0,0 +1,33 @@ +{ + "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" + } +} \ No newline at end of file diff --git a/demo-vault-v2/_themes/minimal.json b/demo-vault-v2/_themes/minimal.json new file mode 100644 index 00000000..e0fd9960 --- /dev/null +++ b/demo-vault-v2/_themes/minimal.json @@ -0,0 +1,33 @@ +{ + "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" + } +} \ No newline at end of file diff --git a/demo-vault-v2/_themes/untitled-2.json b/demo-vault-v2/_themes/untitled-2.json new file mode 100644 index 00000000..d52e497e --- /dev/null +++ b/demo-vault-v2/_themes/untitled-2.json @@ -0,0 +1,33 @@ +{ + "colors": { + "accent": "#EBEBEA", + "accent-foreground": "#37352F", + "background": "#FFFFFF", + "border": "#E9E9E7", + "card": "#FFFFFF", + "destructive": "#E03E3E", + "foreground": "#37352F", + "input": "#E9E9E7", + "muted": "#F0F0EF", + "muted-foreground": "#787774", + "popover": "#FFFFFF", + "primary": "#155DFF", + "primary-foreground": "#FFFFFF", + "ring": "#155DFF", + "secondary": "#EBEBEA", + "secondary-foreground": "#37352F", + "sidebar-accent": "#EBEBEA", + "sidebar-background": "#F7F6F3", + "sidebar-border": "#E9E9E7", + "sidebar-foreground": "#37352F" + }, + "description": "Light theme with warm, paper-like tones", + "name": "Untitled Theme", + "spacing": { + "sidebar-width": "250px" + }, + "typography": { + "font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + "font-size-base": "14px" + } +} \ No newline at end of file diff --git a/demo-vault-v2/_themes/untitled-3.json b/demo-vault-v2/_themes/untitled-3.json new file mode 100644 index 00000000..d52e497e --- /dev/null +++ b/demo-vault-v2/_themes/untitled-3.json @@ -0,0 +1,33 @@ +{ + "colors": { + "accent": "#EBEBEA", + "accent-foreground": "#37352F", + "background": "#FFFFFF", + "border": "#E9E9E7", + "card": "#FFFFFF", + "destructive": "#E03E3E", + "foreground": "#37352F", + "input": "#E9E9E7", + "muted": "#F0F0EF", + "muted-foreground": "#787774", + "popover": "#FFFFFF", + "primary": "#155DFF", + "primary-foreground": "#FFFFFF", + "ring": "#155DFF", + "secondary": "#EBEBEA", + "secondary-foreground": "#37352F", + "sidebar-accent": "#EBEBEA", + "sidebar-background": "#F7F6F3", + "sidebar-border": "#E9E9E7", + "sidebar-foreground": "#37352F" + }, + "description": "Light theme with warm, paper-like tones", + "name": "Untitled Theme", + "spacing": { + "sidebar-width": "250px" + }, + "typography": { + "font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + "font-size-base": "14px" + } +} \ No newline at end of file diff --git a/demo-vault-v2/_themes/untitled-4.json b/demo-vault-v2/_themes/untitled-4.json new file mode 100644 index 00000000..d52e497e --- /dev/null +++ b/demo-vault-v2/_themes/untitled-4.json @@ -0,0 +1,33 @@ +{ + "colors": { + "accent": "#EBEBEA", + "accent-foreground": "#37352F", + "background": "#FFFFFF", + "border": "#E9E9E7", + "card": "#FFFFFF", + "destructive": "#E03E3E", + "foreground": "#37352F", + "input": "#E9E9E7", + "muted": "#F0F0EF", + "muted-foreground": "#787774", + "popover": "#FFFFFF", + "primary": "#155DFF", + "primary-foreground": "#FFFFFF", + "ring": "#155DFF", + "secondary": "#EBEBEA", + "secondary-foreground": "#37352F", + "sidebar-accent": "#EBEBEA", + "sidebar-background": "#F7F6F3", + "sidebar-border": "#E9E9E7", + "sidebar-foreground": "#37352F" + }, + "description": "Light theme with warm, paper-like tones", + "name": "Untitled Theme", + "spacing": { + "sidebar-width": "250px" + }, + "typography": { + "font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + "font-size-base": "14px" + } +} \ No newline at end of file diff --git a/demo-vault-v2/_themes/untitled.json b/demo-vault-v2/_themes/untitled.json new file mode 100644 index 00000000..d52e497e --- /dev/null +++ b/demo-vault-v2/_themes/untitled.json @@ -0,0 +1,33 @@ +{ + "colors": { + "accent": "#EBEBEA", + "accent-foreground": "#37352F", + "background": "#FFFFFF", + "border": "#E9E9E7", + "card": "#FFFFFF", + "destructive": "#E03E3E", + "foreground": "#37352F", + "input": "#E9E9E7", + "muted": "#F0F0EF", + "muted-foreground": "#787774", + "popover": "#FFFFFF", + "primary": "#155DFF", + "primary-foreground": "#FFFFFF", + "ring": "#155DFF", + "secondary": "#EBEBEA", + "secondary-foreground": "#37352F", + "sidebar-accent": "#EBEBEA", + "sidebar-background": "#F7F6F3", + "sidebar-border": "#E9E9E7", + "sidebar-foreground": "#37352F" + }, + "description": "Light theme with warm, paper-like tones", + "name": "Untitled Theme", + "spacing": { + "sidebar-width": "250px" + }, + "typography": { + "font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", + "font-size-base": "14px" + } +} \ No newline at end of file diff --git a/demo-vault-v2/config/ui.config.md b/demo-vault-v2/config/ui.config.md new file mode 100644 index 00000000..71acb9dd --- /dev/null +++ b/demo-vault-v2/config/ui.config.md @@ -0,0 +1,5 @@ +--- +type: config +zoom: 1.3 +view_mode: all +--- diff --git a/demo-vault-v2/note/note-the-innovators-dilemma.md b/demo-vault-v2/note/note-the-innovators-dilemma.md index 3a4589b4..673a1889 100644 --- a/demo-vault-v2/note/note-the-innovators-dilemma.md +++ b/demo-vault-v2/note/note-the-innovators-dilemma.md @@ -4,6 +4,8 @@ Is A: Note Author: "Clayton Christensen" Topics: ["[[topic-saas-business]]"] URL: "https://example.com/innovators-dilemma" +trashed: true +trashed_at: 2026-03-04 --- # The Innovator's Dilemma *Clayton Christensen* diff --git a/demo-vault-v2/note/wikilinks-qa-test.md b/demo-vault-v2/note/wikilinks-qa-test.md new file mode 100644 index 00000000..d64bda0b --- /dev/null +++ b/demo-vault-v2/note/wikilinks-qa-test.md @@ -0,0 +1,5 @@ +# Wikilinks QA Test + +See [[ProjectX]] and [[Team Goals|Goals]] for details on the project timeline. + +This is a test note for QA purposes. diff --git a/demo-vault-v2/theme/dark.md b/demo-vault-v2/theme/dark.md new file mode 100644 index 00000000..e6a14e51 --- /dev/null +++ b/demo-vault-v2/theme/dark.md @@ -0,0 +1,54 @@ +--- +Is A: Theme +Description: Dark variant with deep navy tones +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: "#1a1a2e" +sidebar-foreground: "#e0e0e0" +sidebar-border: "#2a2a4a" +sidebar-accent: "#2a2a4a" +text-primary: "#e0e0e0" +text-secondary: "#888888" +text-muted: "#666666" +text-heading: "#e0e0e0" +bg-primary: "#0f0f1a" +bg-sidebar: "#1a1a2e" +bg-hover: "#2a2a4a" +bg-hover-subtle: "#1e1e3a" +bg-selected: "#155DFF22" +border-primary: "#2a2a4a" +accent-blue: "#155DFF" +accent-green: "#00B38B" +accent-orange: "#D9730D" +accent-red: "#f44336" +accent-purple: "#A932FF" +accent-yellow: "#F0B100" +accent-blue-light: "#155DFF33" +accent-green-light: "#00B38B33" +accent-purple-light: "#A932FF33" +accent-red-light: "#f4433633" +accent-yellow-light: "#F0B10033" +font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" +font-size-base: 14px +editor-font-size: 16 +editor-line-height: 1.5 +editor-max-width: 720 +--- + +# Dark Theme + +A dark theme with deep navy tones for comfortable night-time reading. diff --git a/demo-vault-v2/theme/default.md b/demo-vault-v2/theme/default.md new file mode 100644 index 00000000..0852e797 --- /dev/null +++ b/demo-vault-v2/theme/default.md @@ -0,0 +1,54 @@ +--- +Is A: Theme +Description: Light theme with warm, paper-like tones +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-primary: "#37352F" +text-secondary: "#787774" +text-muted: "#B4B4B4" +text-heading: "#37352F" +bg-primary: "#FFFFFF" +bg-sidebar: "#F7F6F3" +bg-hover: "#EBEBEA" +bg-hover-subtle: "#F0F0EF" +bg-selected: "#E8F4FE" +border-primary: "#E9E9E7" +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" +font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif" +font-size-base: 14px +editor-font-size: 16 +editor-line-height: 1.5 +editor-max-width: 720 +--- + +# Default Theme + +The default light theme for Laputa. Clean and warm, inspired by Notion. diff --git a/demo-vault-v2/theme/minimal.md b/demo-vault-v2/theme/minimal.md new file mode 100644 index 00000000..7d758714 --- /dev/null +++ b/demo-vault-v2/theme/minimal.md @@ -0,0 +1,54 @@ +--- +Is A: Theme +Description: High contrast, minimal chrome +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: "#F5F5F5" +sidebar-foreground: "#111111" +sidebar-border: "#E0E0E0" +sidebar-accent: "#E8E8E8" +text-primary: "#111111" +text-secondary: "#666666" +text-muted: "#999999" +text-heading: "#111111" +bg-primary: "#FAFAFA" +bg-sidebar: "#F5F5F5" +bg-hover: "#EBEBEB" +bg-hover-subtle: "#F5F5F5" +bg-selected: "#00000014" +border-primary: "#E0E0E0" +accent-blue: "#000000" +accent-green: "#006600" +accent-orange: "#996600" +accent-red: "#CC0000" +accent-purple: "#660099" +accent-yellow: "#996600" +accent-blue-light: "#00000014" +accent-green-light: "#00660014" +accent-purple-light: "#66009914" +accent-red-light: "#CC000014" +accent-yellow-light: "#99660014" +font-family: "'SF Mono', 'Menlo', monospace" +font-size-base: 13px +editor-font-size: 15 +editor-line-height: 1.6 +editor-max-width: 680 +--- + +# Minimal Theme + +High contrast, minimal chrome. Monospace typography throughout. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 00000000..a451384b --- /dev/null +++ b/src-tauri/src/commands.rs @@ -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, 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 { + 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 { + 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, String> { + let vault_path = expand_tilde(&vault_path); + vault::purge_trash(&vault_path) +} + +#[tauri::command] +pub fn delete_note(path: String) -> Result { + let path = expand_tilde(&path); + vault::delete_note(&path) +} + +#[tauri::command] +pub fn migrate_is_a_to_type(vault_path: String) -> Result { + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + let path = expand_tilde(&path); + frontmatter::update_frontmatter(&path, &key, value) +} + +#[tauri::command] +pub fn delete_frontmatter_property(path: String, key: String) -> Result { + let path = expand_tilde(&path); + frontmatter::delete_frontmatter_property(&path, &key) +} + +#[tauri::command] +pub fn batch_archive_notes(paths: Vec) -> Result { + 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) -> Result { + 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, 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, 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 { + 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 { + 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, +) -> Result, 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 { + 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, 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 { + let vault_path = expand_tilde(&vault_path); + git::git_pull(&vault_path) +} + +#[tauri::command] +pub fn get_conflict_files(vault_path: String) -> Result, 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 { + let vault_path = expand_tilde(&vault_path); + git::git_commit_conflict_resolution(&vault_path) +} + +#[tauri::command] +pub fn git_push(vault_path: String) -> Result { + 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, String> { + github::github_list_repos(&token).await +} + +#[tauri::command] +pub async fn github_create_repo( + token: String, + name: String, + private: bool, +) -> Result { + github::github_create_repo(&token, &name, private).await +} + +#[tauri::command] +pub fn clone_repo(url: String, token: String, local_path: String) -> Result { + let local_path = expand_tilde(&local_path); + github::clone_repo(&url, &token, &local_path) +} + +#[tauri::command] +pub async fn github_device_flow_start() -> Result { + github::github_device_flow_start().await +} + +#[tauri::command] +pub async fn github_device_flow_poll(device_code: String) -> Result { + github::github_device_flow_poll(&device_code).await +} + +#[tauri::command] +pub async fn github_get_user(token: String) -> Result { + github::github_get_user(&token).await +} + +// ── AI / Claude commands ──────────────────────────────────────────────────── + +#[tauri::command] +pub async fn ai_chat(request: AiChatRequest) -> Result { + 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 { + 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 { + 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, +) -> Result { + 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 { + 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 { + 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, 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 { + 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 { + 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) -> 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) -> Result { + 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) -> Result { + 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 { + 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, + has_conflicts: Option, +) -> 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 { + 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 { + 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 { + 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()); + } +} diff --git a/src-tauri/src/frontmatter.rs b/src-tauri/src/frontmatter.rs deleted file mode 100644 index 837f1b1c..00000000 --- a/src-tauri/src/frontmatter.rs +++ /dev/null @@ -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), - 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::().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::>() - .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::>() - .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 { - 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 { - let mut new_lines: Vec = 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, -) -> Result { - 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(path: &str, transform: F) -> Result -where - F: FnOnce(&str) -> Result, -{ - 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 { - 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 { - 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::::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::::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::::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")); - } -} diff --git a/src-tauri/src/frontmatter/mod.rs b/src-tauri/src/frontmatter/mod.rs new file mode 100644 index 00000000..c1ca8d56 --- /dev/null +++ b/src-tauri/src/frontmatter/mod.rs @@ -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(path: &str, transform: F) -> Result +where + F: FnOnce(&str) -> Result, +{ + 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 { + 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 { + 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::::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::::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::::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"); + } + } +} diff --git a/src-tauri/src/frontmatter/ops.rs b/src-tauri/src/frontmatter/ops.rs new file mode 100644 index 00000000..c48d2b16 --- /dev/null +++ b/src-tauri/src/frontmatter/ops.rs @@ -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 { + let mut new_lines: Vec = 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, +) -> Result { + 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")); + } +} diff --git a/src-tauri/src/frontmatter/yaml.rs b/src-tauri/src/frontmatter/yaml.rs new file mode 100644 index 00000000..53b0fa7f --- /dev/null +++ b/src-tauri/src/frontmatter/yaml.rs @@ -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), + 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::().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::>() + .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::>() + .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 { + 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")); + } +} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs deleted file mode 100644 index 4984788f..00000000 --- a/src-tauri/src/git.rs +++ /dev/null @@ -1,1948 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::process::Command; - -#[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)?; - - // Write .gitignore before the first commit so macOS metadata files - // are never tracked and don't cause conflicts across machines. - let gitignore_path = dir.join(".gitignore"); - if !gitignore_path.exists() { - std::fs::write( - &gitignore_path, - "# macOS\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Thumbnails\n._*\n\n# Editors\n.vscode/\n.idea/\n*.swp\n*.swo\n", - ) - .map_err(|e| format!("Failed to write .gitignore: {}", e))?; - } - - 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(()) -} - -/// Get git log history for a specific file in the vault. -pub fn get_file_history(vault_path: &str, file_path: &str) -> Result, 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 list of modified/added/deleted files in the vault (uncommitted changes). -pub fn get_modified_files(vault_path: &str) -> Result, 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) -} - -#[derive(Debug, Serialize, Clone)] -pub struct ModifiedFile { - pub path: String, - #[serde(rename = "relativePath")] - pub relative_path: String, - pub status: String, -} - -/// Get git diff for a specific file. -pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result { - 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 = 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 { - 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 = 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) -} - -/// Commit all changes with a message. -pub fn git_commit(vault_path: &str, message: &str) -> Result { - 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()) -} - -#[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, - #[serde(rename = "conflictFiles")] - pub conflict_files: Vec, -} - -/// Check whether the vault repo has at least one remote configured. -pub fn has_remote(vault_path: &str) -> Result { - 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 { - 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![], - }) -} - -/// 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, 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: " \t" - let mut files: Vec = stdout - .lines() - .filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string())) - .collect(); - files.sort(); - files.dedup(); - Ok(files) -} - -/// Parse `git pull` output to extract updated file paths. -fn parse_updated_files(stdout: &str) -> Vec { - 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() -} - -/// 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 { - 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()) -} - -/// Push to remote. -pub fn git_push(vault_path: &str) -> Result { - 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)) -} - -#[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, - pub files: Vec, - pub added: usize, - pub modified: usize, - pub deleted: usize, -} - -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, 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 { - 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) -> Vec { - let mut commits: Vec = Vec::new(); - let mut current: Option = 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 -} - -#[derive(Debug, Serialize, Clone)] -pub struct LastCommitInfo { - #[serde(rename = "shortHash")] - pub short_hash: String, - #[serde(rename = "commitUrl")] - pub commit_url: Option, -} - -/// 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, 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 { - 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 - )) -} - -/// 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 { - 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; - - 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 - } - - #[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_init_repo_creates_gitignore_with_ds_store() { - 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 gitignore = vault.join(".gitignore"); - assert!(gitignore.exists(), ".gitignore should be created by init_repo"); - let content = fs::read_to_string(&gitignore).unwrap(); - assert!(content.contains(".DS_Store"), ".gitignore should exclude .DS_Store"); - } - - #[test] - fn test_init_repo_does_not_overwrite_existing_gitignore() { - 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(); - fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap(); - - init_repo(vault.to_str().unwrap()).unwrap(); - - let content = fs::read_to_string(vault.join(".gitignore")).unwrap(); - assert_eq!(content, "custom-rule\n", "existing .gitignore should not be overwritten"); - } - - #[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_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_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.")); - } - - #[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_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 - ); - } - - #[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'" - ); - } - - #[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(); - - // A fresh local repo has no remote - 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()); - } - - /// Set up a bare "remote" and a clone that acts as the working vault. - 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_git_pull_up_to_date() { - let (_bare, clone_a, _clone_b) = setup_remote_pair(); - let vp_a = clone_a.path().to_str().unwrap(); - - // Push a commit from A - fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap(); - git_commit(vp_a, "initial").unwrap(); - git_push(vp_a).unwrap(); - - // Pulling again from A should be up to date - 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(); - - // A pushes a commit - fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap(); - git_commit(vp_a, "initial").unwrap(); - git_push(vp_a).unwrap(); - - // B pulls to get initial - git_pull(vp_b).unwrap(); - - // A makes a change and pushes - fs::write(clone_a.path().join("note.md"), "# Updated Note\n").unwrap(); - git_commit(vp_a, "update note").unwrap(); - git_push(vp_a).unwrap(); - - // B pulls and should see the update - let result = git_pull(vp_b).unwrap(); - assert_eq!(result.status, "updated"); - assert!(result.conflict_files.is_empty()); - } - - #[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_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); - } - - #[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); - // No remote configured, so commit_url should be None - 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/")); - } - - #[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()) - ); - } - - /// 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_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_commit_conflict_resolution() { - let (_bare, _clone_a, clone_b) = setup_conflict_pair(); - let vp_b = clone_b.path().to_str().unwrap(); - - // Resolve all conflicts first - git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap(); - - let result = git_commit_conflict_resolution(vp_b); - assert!(result.is_ok()); - - // Verify the merge commit exists - 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(); - - // Don't resolve — try to commit directly - let result = git_commit_conflict_resolution(vp_b); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("still have unresolved conflicts")); - } - - #[test] - fn test_parse_github_repo_path_non_github() { - assert_eq!( - parse_github_repo_path("https://gitlab.com/owner/repo.git"), - None - ); - } - - #[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)); - } - - #[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(); - - // setup_conflict_pair creates a merge conflict via git_pull (--no-rebase) - 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()); - - // After resolution, mode should be none - 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(); - - // 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 with --rebase to cause a rebase conflict - let output = Command::new("git") - .args(["pull", "--rebase"]) - .current_dir(clone_b_dir.path()) - .output() - .unwrap(); - - // Pull should fail due to conflict - 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"); - - // Resolve the conflict - git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap(); - let remaining = get_conflict_files(vp_b).unwrap(); - assert!(remaining.is_empty()); - - // Commit resolution should use rebase --continue - let result = git_commit_conflict_resolution(vp_b); - assert!(result.is_ok(), "rebase --continue failed: {:?}", result); - - // After resolution, mode should be none - assert_eq!(get_conflict_mode(vp_b), "none"); - } - - #[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); - } -} diff --git a/src-tauri/src/git/commit.rs b/src-tauri/src/git/commit.rs new file mode 100644 index 00000000..a53f620b --- /dev/null +++ b/src-tauri/src/git/commit.rs @@ -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 { + 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'" + ); + } +} diff --git a/src-tauri/src/git/conflict.rs b/src-tauri/src/git/conflict.rs new file mode 100644 index 00000000..b8023d4b --- /dev/null +++ b/src-tauri/src/git/conflict.rs @@ -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, 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: " \t" + let mut files: Vec = 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 { + 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"); + } +} diff --git a/src-tauri/src/git/history.rs b/src-tauri/src/git/history.rs new file mode 100644 index 00000000..e0483ccb --- /dev/null +++ b/src-tauri/src/git/history.rs @@ -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, 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 { + 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 = 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 { + 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 = 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.")); + } +} diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs new file mode 100644 index 00000000..7c3fed43 --- /dev/null +++ b/src-tauri/src/git/mod.rs @@ -0,0 +1,337 @@ +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)?; + + // Write .gitignore before the first commit so machine-specific and + // macOS metadata files are never tracked and don't cause conflicts. + let gitignore_path = dir.join(".gitignore"); + if !gitignore_path.exists() { + std::fs::write( + &gitignore_path, + "# Laputa app files (machine-specific, never commit)\n\ +.laputa-cache.json\n\ +.laputa/settings.json\n\ +\n\ +# macOS\n\ +.DS_Store\n\ +.AppleDouble\n\ +.LSOverride\n\ +\n\ +# Thumbnails\n\ +._*\n\ +\n\ +# Editors\n\ +.vscode/\n\ +.idea/\n\ +*.swp\n\ +*.swo\n", + ) + .map_err(|e| format!("Failed to write .gitignore: {}", e))?; + } + + 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 { + 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_init_repo_creates_gitignore_with_ds_store() { + 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 gitignore = vault.join(".gitignore"); + assert!(gitignore.exists(), ".gitignore should be created by init_repo"); + let content = fs::read_to_string(&gitignore).unwrap(); + assert!(content.contains(".DS_Store"), ".gitignore should exclude .DS_Store"); + assert!(content.contains(".laputa-cache.json"), ".gitignore should exclude .laputa-cache.json"); + assert!(content.contains(".laputa/settings.json"), ".gitignore should exclude settings.json"); + } + + #[test] + fn test_init_repo_does_not_overwrite_existing_gitignore() { + 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(); + fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap(); + + init_repo(vault.to_str().unwrap()).unwrap(); + + let content = fs::read_to_string(vault.join(".gitignore")).unwrap(); + assert_eq!(content, "custom-rule\n", "existing .gitignore should not be overwritten"); + } + + #[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 + ); + } +} diff --git a/src-tauri/src/git/pulse.rs b/src-tauri/src/git/pulse.rs new file mode 100644 index 00000000..1e3cc7c5 --- /dev/null +++ b/src-tauri/src/git/pulse.rs @@ -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, + pub files: Vec, + 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, +} + +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, 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 { + 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) -> Vec { + let mut commits: Vec = Vec::new(); + let mut current: Option = 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, 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 { + 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/")); + } +} diff --git a/src-tauri/src/git/remote.rs b/src-tauri/src/git/remote.rs new file mode 100644 index 00000000..b004d2e4 --- /dev/null +++ b/src-tauri/src/git/remote.rs @@ -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, + #[serde(rename = "conflictFiles")] + pub conflict_files: Vec, +} + +/// Check whether the vault repo has at least one remote configured. +pub fn has_remote(vault_path: &str) -> Result { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src-tauri/src/git/status.rs b/src-tauri/src/git/status.rs new file mode 100644 index 00000000..69264f42 --- /dev/null +++ b/src-tauri/src/git/status.rs @@ -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, 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 + ); + } +} diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs deleted file mode 100644 index ed25a023..00000000 --- a/src-tauri/src/github.rs +++ /dev/null @@ -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, - pub private: bool, - pub clone_url: String, - pub html_url: String, - pub updated_at: Option, -} - -/// Lists the authenticated user's GitHub repositories. -pub async fn github_list_repos(token: &str) -> Result, 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, String> { - let client = reqwest::Client::new(); - let mut all_repos: Vec = 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 = 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 { - 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 { - 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::() - .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, - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct GitHubUser { - pub login: String, - pub name: Option, - 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 { - github_device_flow_start_with_base("https://github.com").await -} - -async fn github_device_flow_start_with_base(base_url: &str) -> Result { - 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::() - .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 { - 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 { - 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, - error: Option, - } - - 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 { - github_get_user_with_base(token, "https://api.github.com").await -} - -async fn github_get_user_with_base(token: &str, api_base: &str) -> Result { - 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::() - .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 { - 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 { - 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 { - 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, 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 { - 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 { - 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::(&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::(&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::(&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::(&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::(&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 = (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()); - } -} diff --git a/src-tauri/src/github/api.rs b/src-tauri/src/github/api.rs new file mode 100644 index 00000000..52606208 --- /dev/null +++ b/src-tauri/src/github/api.rs @@ -0,0 +1,326 @@ +use super::{GitHubUser, GithubRepo}; + +/// Lists the authenticated user's GitHub repositories. +pub async fn github_list_repos(token: &str) -> Result, 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, String> { + let client = reqwest::Client::new(); + let mut all_repos: Vec = 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 = 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 { + 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 { + 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::() + .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 { + github_get_user_with_base(token, "https://api.github.com").await +} + +async fn github_get_user_with_base(token: &str, api_base: &str) -> Result { + 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::() + .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, 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 { + 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 { + 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 = (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()); + } +} diff --git a/src-tauri/src/github/auth.rs b/src-tauri/src/github/auth.rs new file mode 100644 index 00000000..b7e6bc32 --- /dev/null +++ b/src-tauri/src/github/auth.rs @@ -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 { + github_device_flow_start_with_base("https://github.com").await +} + +async fn github_device_flow_start_with_base(base_url: &str) -> Result { + 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::() + .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 { + 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 { + 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, + error: Option, + } + + 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 { + 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())); + } +} diff --git a/src-tauri/src/github/clone.rs b/src-tauri/src/github/clone.rs new file mode 100644 index 00000000..c2483992 --- /dev/null +++ b/src-tauri/src/github/clone.rs @@ -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 { + 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 { + 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"); + } +} diff --git a/src-tauri/src/github/mod.rs b/src-tauri/src/github/mod.rs new file mode 100644 index 00000000..c5be593c --- /dev/null +++ b/src-tauri/src/github/mod.rs @@ -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, + pub private: bool, + pub clone_url: String, + pub html_url: String, + pub updated_at: Option, +} + +#[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, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct GitHubUser { + pub login: String, + pub name: Option, + 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::(&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::(&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::(&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::(&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::(&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()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aef15eed..4f80b8e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, String> { - let path = expand_tilde(&path); - vault::scan_vault_cached(Path::new(path.as_ref())) -} - -#[tauri::command] -fn get_note_content(path: String) -> Result { - 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 { - let path = expand_tilde(&path); - frontmatter::update_frontmatter(&path, &key, value) -} - -#[tauri::command] -fn delete_frontmatter_property(path: String, key: String) -> Result { - let path = expand_tilde(&path); - frontmatter::delete_frontmatter_property(&path, &key) -} - -#[tauri::command] -fn get_file_history(vault_path: String, path: String) -> Result, 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, 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 { - 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 { - 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) -> Result, 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 { - 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, String> { - let vault_path = expand_tilde(&vault_path); - git::get_last_commit_info(&vault_path) -} - -#[tauri::command] -fn git_pull(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - git::git_pull(&vault_path) -} - -#[tauri::command] -fn get_conflict_files(vault_path: String) -> Result, 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 { - let vault_path = expand_tilde(&vault_path); - git::git_commit_conflict_resolution(&vault_path) -} - -#[tauri::command] -fn git_push(vault_path: String) -> Result { - let vault_path = expand_tilde(&vault_path); - git::git_push(&vault_path) -} - -#[tauri::command] -async fn ai_chat(request: AiChatRequest) -> Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, String> { - let vault_path = expand_tilde(&vault_path); - vault::purge_trash(&vault_path) -} - -#[tauri::command] -fn delete_note(path: String) -> Result { - let path = expand_tilde(&path); - vault::delete_note(&path) -} - -#[tauri::command] -fn migrate_is_a_to_type(vault_path: String) -> Result { - 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) -> Result { - 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 { - vault::default_vault_path().map(|p| p.to_string_lossy().to_string()) -} - -#[tauri::command] -fn batch_archive_notes(paths: Vec) -> Result { - 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) -> Result { - 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, - has_conflicts: Option, -) -> 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::get_settings() -} - -#[tauri::command] -fn save_settings(settings: Settings) -> Result<(), String> { - settings::save_settings(settings) -} - -#[tauri::command] -fn load_vault_list() -> Result { - 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, String> { - github::github_list_repos(&token).await -} - -#[tauri::command] -async fn github_create_repo( - token: String, - name: String, - private: bool, -) -> Result { - github::github_create_repo(&token, &name, private).await -} - -#[tauri::command] -fn clone_repo(url: String, token: String, local_path: String) -> Result { - let local_path = expand_tilde(&local_path); - github::clone_repo(&url, &token, &local_path) -} - -#[tauri::command] -async fn github_device_flow_start() -> Result { - github::github_device_flow_start().await -} - -#[tauri::command] -async fn github_device_flow_poll(device_code: String) -> Result { - github::github_device_flow_poll(&device_code).await -} - -#[tauri::command] -async fn github_get_user(token: String) -> Result { - github::github_get_user(&token).await -} - -#[tauri::command] -async fn search_vault( - vault_path: String, - query: String, - mode: String, - limit: Option, -) -> Result { - 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>); -#[tauri::command] -async fn register_mcp_tools(vault_path: String) -> Result { - 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 { - 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, 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 { - let vault_path = expand_tilde(&vault_path); - theme::get_theme(&vault_path, &theme_id) -} - -#[tauri::command] -fn get_vault_settings(vault_path: String) -> Result { - 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) -> 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) -> Result { - 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) -> Result { - 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 { - let vault_path = expand_tilde(&vault_path); - theme::restore_default_themes(&vault_path) -} - -#[tauri::command] -fn get_vault_config(vault_path: String) -> Result { - 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) { 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") diff --git a/src-tauri/src/theme.rs b/src-tauri/src/theme.rs deleted file mode 100644 index 865ae950..00000000 --- a/src-tauri/src/theme.rs +++ /dev/null @@ -1,1075 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::Path; - -/// 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, - #[serde(default)] - pub typography: HashMap, - #[serde(default)] - pub spacing: HashMap, -} - -/// Vault-level settings stored in .laputa/settings.json (git-tracked). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct VaultSettings { - #[serde(default)] - pub theme: Option, -} - -/// 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, 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 { - 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 { - 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 { - 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) -} - -/// 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 { - // 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()) -} - -/// 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 { - 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()) -} - -/// 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::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .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 { - // Values with '#' or spaces need quoting; others can be bare strings. - 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 -} - -/// 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 { - 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) -} - -/// 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() -} - -/// 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"; - -#[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(); - - // Default settings have no theme - let settings = get_vault_settings(vp).unwrap(); - assert!(settings.theme.is_none()); - - // Set and read back - set_active_theme(vp, Some("dark")).unwrap(); - let settings = get_vault_settings(vp).unwrap(); - assert_eq!(settings.theme.as_deref(), Some("dark")); - - // Clear theme - 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_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_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); // only default and dark - } - - #[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); // broken.json is skipped - } - - #[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_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"); - } - - #[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:")); - } - - #[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(); - // Only default exists — dark and minimal should be seeded - 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(); - // Create empty file — should be re-seeded - 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"); - // _themes/ JSON files - assert!(vault.join("_themes").join("default.json").exists()); - assert!(vault.join("_themes").join("dark.json").exists()); - assert!(vault.join("_themes").join("minimal.json").exists()); - // theme/ markdown notes - 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(); - // Modify a theme file to verify it isn't overwritten - 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"); - // Create _themes/ with only default.json, theme/ with only default.md - 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(); - // Missing files should now exist - 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()); - // Existing files should be unchanged - let content = fs::read_to_string(theme_dir.join("default.md")).unwrap(); - assert!(content.contains("Light theme with warm")); - } -} diff --git a/src-tauri/src/theme/create.rs b/src-tauri/src/theme/create.rs new file mode 100644 index 00000000..08aa5cbb --- /dev/null +++ b/src-tauri/src/theme/create.rs @@ -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 { + 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 { + 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::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .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"); + } +} diff --git a/src-tauri/src/theme/defaults.rs b/src-tauri/src/theme/defaults.rs new file mode 100644 index 00000000..64749fea --- /dev/null +++ b/src-tauri/src/theme/defaults.rs @@ -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"; diff --git a/src-tauri/src/theme/mod.rs b/src-tauri/src/theme/mod.rs new file mode 100644 index 00000000..727d9d09 --- /dev/null +++ b/src-tauri/src/theme/mod.rs @@ -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, + #[serde(default)] + pub typography: HashMap, + #[serde(default)] + pub spacing: HashMap, +} + +/// Vault-level settings stored in .laputa/settings.json (git-tracked). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VaultSettings { + #[serde(default)] + pub theme: Option, +} + +/// 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, 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 { + 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 { + 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 { + 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:")); + } +} diff --git a/src-tauri/src/theme/seed.rs b/src-tauri/src/theme/seed.rs new file mode 100644 index 00000000..a9fa8377 --- /dev/null +++ b/src-tauri/src/theme/seed.rs @@ -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 { + // 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")); + } +} diff --git a/src/App.tsx b/src/App.tsx index 718f4bc0..992dd283 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,7 @@ import { isTauri, mockInvoke } from './mock-tauri' import type { SidebarSelection, VaultEntry } from './types' import type { NoteListItem } from './utils/ai-context' import { filterEntries } from './utils/noteListHelpers' +import { openLocalFile } from './utils/url' import './App.css' // Type declaration for mock content storage @@ -287,8 +288,13 @@ function App() { const fullPath = `${resolvedPath}/${relativePath}` const entry = vault.entries.find(e => e.path === fullPath) if (entry) { + // Markdown note — open inside Laputa editor notes.handleSelectNote(entry) dialogs.closeConflictResolver() + } else { + // Non-note file (e.g. .laputa-cache.json, settings.json) — + // open with system default app so the user can inspect/edit it + openLocalFile(fullPath) } } }, [resolvedPath, vault.entries, notes, dialogs]) diff --git a/src/utils/url.ts b/src/utils/url.ts index a4c4b826..821a8b5f 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -22,3 +22,11 @@ export async function openExternalUrl(url: string): Promise { window.open(url, '_blank') } } + +/** Open a local file path with the system default app (e.g. TextEdit for .json). */ +export async function openLocalFile(absolutePath: string): Promise { + if (isTauri()) { + const { openPath } = await import('@tauri-apps/plugin-opener') + await openPath(absolutePath) + } +}