refactor: split Rust backend into sub-modules — git, theme, github, frontmatter, commands

- git.rs (1907 lines) → 7 files: mod, history, status, commit, remote, conflict, pulse
- theme.rs (1075 lines) → 4 files: mod, defaults, seed, create
- github.rs (886 lines) → 4 files: mod, api, auth, clone
- frontmatter.rs (827 lines) → 3 files: mod, yaml, ops
- lib.rs (772 lines) → lib.rs (160) + commands.rs (650)

484 tests pass, clippy clean, rustfmt clean, coverage 85.42%
This commit is contained in:
Test
2026-03-06 13:16:32 +01:00
parent 5b1fda2279
commit ea29a81d79
25 changed files with 5519 additions and 5351 deletions

View File

@@ -1 +1 @@
45402
33549

659
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,659 @@
use std::borrow::Cow;
use crate::ai_chat::{AiChatRequest, AiChatResponse};
use crate::claude_cli::{
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
};
use crate::frontmatter::FrontmatterValue;
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
use crate::settings::Settings;
use crate::theme::{ThemeFile, VaultSettings};
use crate::vault::{RenameResult, VaultEntry};
use crate::vault_config::VaultConfig;
use crate::vault_list::VaultList;
use crate::{
frontmatter, git, github, indexing, menu, search, theme, vault, vault_config, vault_list,
};
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
/// home directory cannot be determined.
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
if path == "~" {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(home.to_string_lossy().into_owned());
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
}
}
Cow::Borrowed(path)
}
pub fn parse_build_label(version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
match parts.as_slice() {
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
[_, _, _] => "dev".to_string(),
_ => "b?".to_string(),
}
}
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
use tauri::Emitter;
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,
error: Some("qmd not available".to_string()),
},
);
}
// ── Vault commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::get_note_content(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title)
}
#[tauri::command]
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
vault::purge_trash(&vault_path)
}
#[tauri::command]
pub fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = match target_path {
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
};
vault::create_getting_started_vault(&path)
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
// ── Frontmatter commands ────────────────────────────────────────────────────
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
#[tauri::command]
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(
&path,
"Trashed at",
FrontmatterValue::String(now.clone()),
)?;
count += 1;
}
Ok(count)
}
// ── Git commands ────────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_history(&vault_path, &path)
}
#[tauri::command]
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_modified_files(&vault_path)
}
#[tauri::command]
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff(&vault_path, &path)
}
#[tauri::command]
pub fn get_file_diff_at_commit(
vault_path: String,
path: String,
commit_hash: String,
) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
pub fn get_vault_pulse(
vault_path: String,
limit: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let limit = limit.unwrap_or(30);
git::get_vault_pulse(&vault_path, limit)
}
#[tauri::command]
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit(&vault_path, &message)
}
#[tauri::command]
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_last_commit_info(&vault_path)
}
#[tauri::command]
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_pull(&vault_path)
}
#[tauri::command]
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_files(&vault_path)
}
#[tauri::command]
pub fn get_conflict_mode(vault_path: String) -> String {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_mode(&vault_path)
}
#[tauri::command]
pub fn git_resolve_conflict(
vault_path: String,
file: String,
strategy: String,
) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[tauri::command]
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit_conflict_resolution(&vault_path)
}
#[tauri::command]
pub fn git_push(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
}
// ── GitHub commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
}
#[tauri::command]
pub async fn github_create_repo(
token: String,
name: String,
private: bool,
) -> Result<GithubRepo, String> {
github::github_create_repo(&token, &name, private).await
}
#[tauri::command]
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
github::clone_repo(&url, &token, &local_path)
}
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github::github_device_flow_start().await
}
#[tauri::command]
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
github::github_device_flow_poll(&device_code).await
}
#[tauri::command]
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
github::github_get_user(&token).await
}
// ── AI / Claude commands ────────────────────────────────────────────────────
#[tauri::command]
pub async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
crate::ai_chat::send_chat(request).await
}
#[tauri::command]
pub fn check_claude_cli() -> ClaudeCliStatus {
crate::claude_cli::check_cli()
}
#[tauri::command]
pub async fn stream_claude_chat(
app_handle: tauri::AppHandle,
request: ChatStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
pub async fn stream_claude_agent(
app_handle: tauri::AppHandle,
request: AgentStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-agent-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
// ── Search & indexing commands ──────────────────────────────────────────────
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
pub fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
#[tauri::command]
pub async fn start_indexing(
app_handle: tauri::AppHandle,
vault_path: String,
) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if indexing::find_qmd_binary().is_none() {
log::info!("qmd binary not found — attempting auto-install via bun");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::try_auto_install_qmd() {
Ok(()) if indexing::find_qmd_binary().is_some() => {
log::info!("qmd auto-installed successfully, proceeding with indexing");
}
Ok(()) => {
log::warn!("qmd auto-install reported success but binary still not found");
emit_unavailable(&app_handle);
return Err("qmd not available after install".to_string());
}
Err(e) => {
log::info!("qmd auto-install failed: {e}");
emit_unavailable(&app_handle);
return Err(format!("qmd not available: {e}"));
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
// ── MCP commands ────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path))
.await
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
// ── Theme commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
theme::list_themes(&vault_path)
}
#[tauri::command]
pub fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_theme(&vault_path, &theme_id)
}
#[tauri::command]
pub fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_vault_settings(&vault_path)
}
#[tauri::command]
pub fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::save_vault_settings(&vault_path, settings)
}
#[tauri::command]
pub fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, theme_id.as_deref())
}
#[tauri::command]
pub fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_theme(&vault_path, source_id.as_deref())
}
#[tauri::command]
pub fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_vault_theme(&vault_path, name.as_deref())
}
#[tauri::command]
pub fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::ensure_vault_themes(&vault_path)
}
#[tauri::command]
pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::restore_default_themes(&vault_path)
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]
pub fn get_build_number(app_handle: tauri::AppHandle) -> String {
let version = app_handle.package_info().version.to_string();
parse_build_label(&version)
}
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
if let Some(v) = has_modified_files {
menu::set_git_commit_items_enabled(&app_handle, v);
}
if let Some(v) = has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
Ok(())
}
#[tauri::command]
pub fn get_settings() -> Result<Settings, String> {
crate::settings::get_settings()
}
#[tauri::command]
pub fn save_settings(settings: Settings) -> Result<(), String> {
crate::settings::save_settings(settings)
}
#[tauri::command]
pub fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
pub fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
let vault_path = expand_tilde(&vault_path);
vault_config::get_vault_config(&vault_path)
}
#[tauri::command]
pub fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
vault_config::save_vault_config(&vault_path, config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_with_subpath() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~/Documents/vault");
assert_eq!(result, format!("{}/Documents/vault", home.display()));
}
#[test]
fn expand_tilde_alone() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~");
assert_eq!(result, home.to_string_lossy());
}
#[test]
fn expand_tilde_noop_for_absolute_path() {
let result = expand_tilde("/usr/local/bin");
assert_eq!(result, "/usr/local/bin");
}
#[test]
fn expand_tilde_noop_for_relative_path() {
let result = expand_tilde("some/relative/path");
assert_eq!(result, "some/relative/path");
}
#[test]
fn expand_tilde_noop_for_tilde_in_middle() {
let result = expand_tilde("/home/~user/path");
assert_eq!(result, "/home/~user/path");
}
#[test]
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
#[test]
fn test_batch_archive_notes() {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
std::fs::write(&note, "---\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(&note).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(&note, "---\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(&note).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());
}
}

View File

@@ -1,827 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// Value type for frontmatter updates
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum FrontmatterValue {
String(String),
Number(f64),
Bool(bool),
List(Vec<String>),
Null,
}
/// Characters that require a YAML string value to be quoted.
fn has_yaml_special_chars(s: &str) -> bool {
s.contains(':') || s.contains('#')
}
/// Check if a string starts with a YAML collection indicator (array or map).
fn starts_as_yaml_collection(s: &str) -> bool {
s.starts_with('[') || s.starts_with('{')
}
/// Check whether a YAML string value needs quoting to avoid ambiguity.
fn needs_yaml_quoting(s: &str) -> bool {
has_yaml_special_chars(s)
|| starts_as_yaml_collection(s)
|| matches!(s, "true" | "false" | "null")
|| s.parse::<f64>().is_ok()
}
/// Quote a string value for YAML, escaping internal double quotes.
fn quote_yaml_string(s: &str) -> String {
format!("\"{}\"", s.replace('\"', "\\\""))
}
/// Format a single YAML list item as ` - "value"`.
fn format_list_item(item: &str) -> String {
format!(" - {}", quote_yaml_string(item))
}
/// Format a multi-line string as a YAML block scalar (`|`).
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
fn format_block_scalar(s: &str) -> String {
let indented = s
.lines()
.map(|l| {
if l.is_empty() {
String::new()
} else {
format!(" {}", l)
}
})
.collect::<Vec<_>>()
.join("\n");
format!("|\n{}", indented)
}
/// Format a number for YAML (integers without decimal, floats with).
fn format_yaml_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
impl FrontmatterValue {
pub fn to_yaml_value(&self) -> String {
match self {
FrontmatterValue::String(s) => {
if s.contains('\n') {
format_block_scalar(s)
} else if needs_yaml_quoting(s) {
quote_yaml_string(s)
} else {
s.clone()
}
}
FrontmatterValue::Number(n) => format_yaml_number(*n),
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
FrontmatterValue::List(items) => items
.iter()
.map(|item| format_list_item(item))
.collect::<Vec<_>>()
.join("\n"),
FrontmatterValue::Null => "null".to_string(),
}
}
}
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
fn needs_key_quoting(key: &str) -> bool {
key.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
}
/// Format a key for YAML output (quote if necessary)
pub fn format_yaml_key(key: &str) -> String {
if needs_key_quoting(key) {
format!("\"{}\"", key)
} else {
key.to_string()
}
}
/// Check if a line defines a specific key (handles quoted and unquoted keys)
fn line_is_key(line: &str, key: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
return true;
}
let dq = format!("\"{}\":", key);
if trimmed.starts_with(&dq) {
return true;
}
let sq = format!("'{}\':", key);
if trimmed.starts_with(&sq) {
return true;
}
false
}
/// Format a key-value pair as one or more YAML lines.
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
let yaml_key = format_yaml_key(key);
let yaml_value = value.to_yaml_value();
if yaml_value.starts_with("|\n") {
// Block scalar: key and indicator on the same line, content follows
vec![format!("{}: {}", yaml_key, yaml_value)]
} else if yaml_value.contains('\n') {
vec![format!("{}:", yaml_key), yaml_value]
} else {
vec![format!("{}: {}", yaml_key, yaml_value)]
}
}
/// Check if a line continues the previous key's value (indented list item,
/// block scalar content, or blank line inside a block scalar).
fn is_value_continuation(line: &str) -> bool {
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
}
/// Split content into frontmatter body and the rest after the closing `---`.
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
let after_open = &content[4..];
// Handle empty frontmatter: closing --- immediately after opening ---\n
if let Some(stripped) = after_open.strip_prefix("---") {
return Ok(("", stripped));
}
let fm_end = after_open
.find("\n---")
.map(|i| i + 4)
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
Ok((&content[4..fm_end], &content[fm_end + 4..]))
}
/// Wrap content in a new frontmatter block containing a single field.
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
let field_lines = format_yaml_field(key, value);
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
}
/// Apply a field update to existing frontmatter lines.
/// Replaces the matching key (and its list continuations) with the new value,
/// or appends if the key is not found. If `value` is None, removes the key.
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
let mut new_lines: Vec<String> = Vec::new();
let mut found_key = false;
let mut i = 0;
while i < lines.len() {
if !line_is_key(lines[i], key) {
new_lines.push(lines[i].to_string());
i += 1;
continue;
}
found_key = true;
i += 1;
// Skip continuation lines belonging to this key (lists, block scalars)
while i < lines.len() && is_value_continuation(lines[i]) {
i += 1;
}
// Insert replacement value (if any)
if let Some(v) = value {
new_lines.extend(format_yaml_field(key, v));
}
}
if let (false, Some(v)) = (found_key, value) {
new_lines.extend(format_yaml_field(key, v));
}
new_lines
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(
content: &str,
key: &str,
value: Option<FrontmatterValue>,
) -> Result<String, String> {
if !content.starts_with("---\n") {
return match value {
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
None => Ok(content.to_string()),
};
}
let (fm_content, rest) = split_frontmatter(content)?;
let lines: Vec<&str> = fm_content.lines().collect();
let new_lines = apply_field_update(&lines, key, value.as_ref());
let new_fm = new_lines.join("\n");
Ok(format!("---\n{}\n---{}", new_fm, rest))
}
/// Helper to read a file, apply a frontmatter transformation, and write back.
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
where
F: FnOnce(&str) -> Result<String, String>,
{
let file_path = Path::new(path);
if !file_path.exists() {
return Err(format!("File does not exist: {}", path));
}
let content =
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
let updated = transform(&content)?;
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
Ok(updated)
}
/// Update a single frontmatter property in a markdown file.
pub fn update_frontmatter(
path: &str,
key: &str,
value: FrontmatterValue,
) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, Some(value.clone()))
})
}
/// Delete a frontmatter property from a markdown file.
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, None)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_frontmatter_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
assert!(updated.contains("Status: Active"));
assert!(!updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_add_new_key() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(updated.contains("Owner: Luca"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_quoted_key() {
let content = "---\n\"Is A\": Note\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Is A",
Some(FrontmatterValue::String("Project".to_string())),
)
.unwrap();
assert!(updated.contains("\"Is A\": Project"));
assert!(!updated.contains("Note"));
}
#[test]
fn test_update_frontmatter_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"Alias1".to_string(),
"Alias2".to_string(),
])),
)
.unwrap();
assert!(updated.contains("aliases:"));
assert!(updated.contains(" - \"Alias1\""));
assert!(updated.contains(" - \"Alias2\""));
}
#[test]
fn test_update_frontmatter_replace_list() {
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec!["New1".to_string()])),
)
.unwrap();
assert!(updated.contains(" - \"New1\""));
assert!(!updated.contains("Old1"));
assert!(!updated.contains("Old2"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_delete_frontmatter_property() {
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
assert!(!updated.contains("Owner"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_delete_frontmatter_list_property() {
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
assert!(!updated.contains("aliases"));
assert!(!updated.contains("Alias1"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_no_existing() {
let content = "# Test\n\nSome content here.";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Draft".to_string())),
)
.unwrap();
assert!(updated.starts_with("---\n"));
assert!(updated.contains("Status: Draft"));
assert!(updated.contains("# Test"));
}
#[test]
fn test_update_frontmatter_bool() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
.unwrap();
assert!(updated.contains("Reviewed: true"));
}
#[test]
fn test_format_yaml_key_simple() {
assert_eq!(format_yaml_key("Status"), "Status");
assert_eq!(format_yaml_key("is_a"), "is_a");
}
#[test]
fn test_format_yaml_key_with_spaces() {
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
}
// --- to_yaml_value quoting tests ---
#[test]
fn test_to_yaml_value_string_needs_quoting_colon() {
let v = FrontmatterValue::String("key: value".to_string());
assert_eq!(v.to_yaml_value(), "\"key: value\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_hash() {
let v = FrontmatterValue::String("has # comment".to_string());
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bracket() {
let v = FrontmatterValue::String("[array-like]".to_string());
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_brace() {
let v = FrontmatterValue::String("{object-like}".to_string());
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bool_like() {
assert_eq!(
FrontmatterValue::String("true".to_string()).to_yaml_value(),
"\"true\""
);
assert_eq!(
FrontmatterValue::String("false".to_string()).to_yaml_value(),
"\"false\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_null_like() {
assert_eq!(
FrontmatterValue::String("null".to_string()).to_yaml_value(),
"\"null\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_number_like() {
assert_eq!(
FrontmatterValue::String("42".to_string()).to_yaml_value(),
"\"42\""
);
assert_eq!(
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
"\"3.14\""
);
}
#[test]
fn test_to_yaml_value_string_plain() {
let v = FrontmatterValue::String("Hello World".to_string());
assert_eq!(v.to_yaml_value(), "Hello World");
}
#[test]
fn test_to_yaml_value_number_integer() {
let v = FrontmatterValue::Number(42.0);
assert_eq!(v.to_yaml_value(), "42");
}
#[test]
fn test_to_yaml_value_number_float() {
let v = FrontmatterValue::Number(3.14);
assert_eq!(v.to_yaml_value(), "3.14");
}
#[test]
fn test_to_yaml_value_null() {
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
}
#[test]
fn test_to_yaml_value_empty_list() {
let v = FrontmatterValue::List(vec![]);
assert_eq!(v.to_yaml_value(), "[]");
}
#[test]
fn test_to_yaml_value_list_with_colon() {
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
}
// --- update_frontmatter_content additional type tests ---
#[test]
fn test_update_frontmatter_number() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
.unwrap();
assert!(updated.contains("Priority: 5"));
}
#[test]
fn test_update_frontmatter_number_float() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
.unwrap();
assert!(updated.contains("Score: 9.5"));
}
#[test]
fn test_update_frontmatter_null() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
assert!(updated.contains("ClearMe: null"));
}
#[test]
fn test_update_frontmatter_empty_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
.unwrap();
assert!(updated.contains("tags: []"));
}
#[test]
fn test_update_frontmatter_malformed_no_closing_fence() {
let content = "---\nStatus: Draft\nNo closing fence here";
let result = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Malformed frontmatter"));
}
// --- delete non-existent key (should be no-op) ---
#[test]
fn test_delete_nonexistent_key_noop() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
assert_eq!(updated, content);
}
#[test]
fn test_delete_from_no_frontmatter_noop() {
let content = "# Test\n\nSome content.";
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
assert_eq!(updated, content);
}
// --- line_is_key tests ---
#[test]
fn test_line_is_key_unquoted() {
assert!(line_is_key("Status: Draft", "Status"));
assert!(!line_is_key("Status: Draft", "Owner"));
}
#[test]
fn test_line_is_key_double_quoted() {
assert!(line_is_key("\"Is A\": Note", "Is A"));
assert!(!line_is_key("\"Is A\": Note", "Status"));
}
#[test]
fn test_line_is_key_single_quoted() {
assert!(line_is_key("'Is A': Note", "Is A"));
}
#[test]
fn test_line_is_key_leading_whitespace() {
assert!(line_is_key(" Status: Draft", "Status"));
}
#[test]
fn test_line_is_key_partial_match() {
// "StatusBar" should not match key "Status"
assert!(!line_is_key("StatusBar: value", "Status"));
}
// --- with_frontmatter error cases ---
#[test]
fn test_with_frontmatter_file_not_found() {
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
// --- roundtrip tests ---
#[test]
fn test_roundtrip_update_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
// Parse back with gray_matter
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_update_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"A".to_string(),
"B".to_string(),
])),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let aliases = map.get("aliases").unwrap();
if let gray_matter::Pod::Array(arr) = aliases {
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].as_string().unwrap(), "A");
assert_eq!(arr[1].as_string().unwrap(), "B");
} else {
panic!("Expected array");
}
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_add_then_delete() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let with_owner = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(with_owner.contains("Owner: Luca"));
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
assert!(!without_owner.contains("Owner"));
assert!(without_owner.contains("Status: Draft"));
}
// --- format_yaml_key additional tests ---
#[test]
fn test_format_yaml_key_with_colon() {
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
}
#[test]
fn test_format_yaml_key_with_hash() {
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
}
#[test]
fn test_format_yaml_key_with_period() {
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
}
// --- split_frontmatter / empty frontmatter edge cases ---
#[test]
fn test_split_frontmatter_empty_block() {
// ---\n---\n (no fields between opening and closing ---)
let result = split_frontmatter("---\n---\n");
assert!(
result.is_ok(),
"split_frontmatter should handle empty frontmatter block"
);
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert_eq!(rest, "\n");
}
#[test]
fn test_split_frontmatter_empty_block_no_trailing_newline() {
// ---\n--- (no trailing newline)
let result = split_frontmatter("---\n---");
assert!(
result.is_ok(),
"split_frontmatter should handle empty frontmatter without trailing newline"
);
}
#[test]
fn test_split_frontmatter_empty_block_with_body() {
// ---\n---\n\n# Title\n
let result = split_frontmatter("---\n---\n\n# Title\n");
assert!(
result.is_ok(),
"split_frontmatter should handle empty frontmatter with body"
);
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert!(rest.contains("# Title"));
}
#[test]
fn test_update_frontmatter_empty_block() {
let content = "---\n---\n\n# Test\n";
let result = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New Title".to_string())),
);
assert!(
result.is_ok(),
"update_frontmatter_content should handle empty frontmatter block"
);
let updated = result.unwrap();
assert!(updated.contains("title: New Title"));
}
// --- block scalar (multi-line string) tests ---
#[test]
fn test_to_yaml_value_multiline_uses_block_scalar() {
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
let yaml = v.to_yaml_value();
assert!(yaml.starts_with("|\n"));
assert!(yaml.contains(" line 1"));
assert!(yaml.contains(" line 2"));
}
#[test]
fn test_format_yaml_field_block_scalar() {
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
let lines = format_yaml_field("template", &v);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("template: |\n"));
assert!(lines[0].contains(" ## Objective"));
assert!(lines[0].contains(" ## Timeline"));
}
#[test]
fn test_update_frontmatter_block_scalar_add() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\n## Timeline";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
assert!(updated.contains("template: |"));
assert!(updated.contains(" ## Objective"));
assert!(updated.contains(" ## Timeline"));
assert!(updated.contains("type: Type"));
}
#[test]
fn test_update_frontmatter_block_scalar_replace() {
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
let new_template = "## New\n\n## Content";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(new_template.to_string())),
)
.unwrap();
assert!(updated.contains(" ## New"));
assert!(updated.contains(" ## Content"));
assert!(!updated.contains("## Old"));
assert!(!updated.contains("## Stuff"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_delete_frontmatter_block_scalar() {
let content =
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(content, "template", None).unwrap();
assert!(!updated.contains("template"));
assert!(!updated.contains("## Heading"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_roundtrip_block_scalar() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
// Parse back with gray_matter
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let roundtripped = map.get("template").unwrap().as_string().unwrap();
assert!(roundtripped.contains("## Objective"));
assert!(roundtripped.contains("## Timeline"));
assert!(roundtripped.contains("Describe the goal."));
} else {
panic!("Expected hash");
}
}
#[test]
fn test_update_frontmatter_no_body_after_closing() {
// Frontmatter with title, no body after closing ---
let content = "---\ntitle: Old\n---\n";
let result = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New".to_string())),
);
assert!(result.is_ok());
let updated = result.unwrap();
assert!(updated.contains("title: New"));
assert!(!updated.contains("title: Old"));
}
}

View File

@@ -0,0 +1,207 @@
mod ops;
mod yaml;
use std::fs;
use std::path::Path;
pub use ops::update_frontmatter_content;
pub use yaml::{format_yaml_key, FrontmatterValue};
/// Helper to read a file, apply a frontmatter transformation, and write back.
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
where
F: FnOnce(&str) -> Result<String, String>,
{
let file_path = Path::new(path);
if !file_path.exists() {
return Err(format!("File does not exist: {}", path));
}
let content =
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
let updated = transform(&content)?;
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
Ok(updated)
}
/// Update a single frontmatter property in a markdown file.
pub fn update_frontmatter(
path: &str,
key: &str,
value: FrontmatterValue,
) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, Some(value.clone()))
})
}
/// Delete a frontmatter property from a markdown file.
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, None)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_frontmatter_file_not_found() {
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_roundtrip_update_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_update_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"A".to_string(),
"B".to_string(),
])),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let aliases = map.get("aliases").unwrap();
if let gray_matter::Pod::Array(arr) = aliases {
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].as_string().unwrap(), "A");
assert_eq!(arr[1].as_string().unwrap(), "B");
} else {
panic!("Expected array");
}
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_add_then_delete() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let with_owner = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(with_owner.contains("Owner: Luca"));
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
assert!(!without_owner.contains("Owner"));
assert!(without_owner.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_empty_block() {
let content = "---\n---\n\n# Test\n";
let result = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New Title".to_string())),
);
assert!(result.is_ok());
assert!(result.unwrap().contains("title: New Title"));
}
#[test]
fn test_update_frontmatter_block_scalar_add() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\n## Timeline";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
assert!(updated.contains("template: |"));
assert!(updated.contains(" ## Objective"));
assert!(updated.contains("type: Type"));
}
#[test]
fn test_update_frontmatter_block_scalar_replace() {
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String("## New\n\n## Content".to_string())),
)
.unwrap();
assert!(updated.contains(" ## New"));
assert!(!updated.contains("## Old"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_delete_frontmatter_block_scalar() {
let content =
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(content, "template", None).unwrap();
assert!(!updated.contains("template"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_update_frontmatter_no_body_after_closing() {
let content = "---\ntitle: Old\n---\n";
let updated = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New".to_string())),
)
.unwrap();
assert!(updated.contains("title: New"));
assert!(!updated.contains("title: Old"));
}
#[test]
fn test_roundtrip_block_scalar() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let roundtripped = map.get("template").unwrap().as_string().unwrap();
assert!(roundtripped.contains("## Objective"));
assert!(roundtripped.contains("## Timeline"));
assert!(roundtripped.contains("Describe the goal."));
} else {
panic!("Expected hash");
}
}
}

View File

@@ -0,0 +1,331 @@
use super::yaml::{format_yaml_field, FrontmatterValue};
/// Check if a line defines a specific key (handles quoted and unquoted keys)
fn line_is_key(line: &str, key: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
return true;
}
let dq = format!("\"{}\":", key);
if trimmed.starts_with(&dq) {
return true;
}
let sq = format!("'{}\':", key);
if trimmed.starts_with(&sq) {
return true;
}
false
}
/// Check if a line continues the previous key's value (indented list item,
/// block scalar content, or blank line inside a block scalar).
fn is_value_continuation(line: &str) -> bool {
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
}
/// Split content into frontmatter body and the rest after the closing `---`.
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
let after_open = &content[4..];
// Handle empty frontmatter: closing --- immediately after opening ---\n
if let Some(stripped) = after_open.strip_prefix("---") {
return Ok(("", stripped));
}
let fm_end = after_open
.find("\n---")
.map(|i| i + 4)
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
Ok((&content[4..fm_end], &content[fm_end + 4..]))
}
/// Wrap content in a new frontmatter block containing a single field.
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
let field_lines = format_yaml_field(key, value);
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
}
/// Apply a field update to existing frontmatter lines.
/// Replaces the matching key (and its list continuations) with the new value,
/// or appends if the key is not found. If `value` is None, removes the key.
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
let mut new_lines: Vec<String> = Vec::new();
let mut found_key = false;
let mut i = 0;
while i < lines.len() {
if !line_is_key(lines[i], key) {
new_lines.push(lines[i].to_string());
i += 1;
continue;
}
found_key = true;
i += 1;
// Skip continuation lines belonging to this key (lists, block scalars)
while i < lines.len() && is_value_continuation(lines[i]) {
i += 1;
}
// Insert replacement value (if any)
if let Some(v) = value {
new_lines.extend(format_yaml_field(key, v));
}
}
if let (false, Some(v)) = (found_key, value) {
new_lines.extend(format_yaml_field(key, v));
}
new_lines
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(
content: &str,
key: &str,
value: Option<FrontmatterValue>,
) -> Result<String, String> {
if !content.starts_with("---\n") {
return match value {
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
None => Ok(content.to_string()),
};
}
let (fm_content, rest) = split_frontmatter(content)?;
let lines: Vec<&str> = fm_content.lines().collect();
let new_lines = apply_field_update(&lines, key, value.as_ref());
let new_fm = new_lines.join("\n");
Ok(format!("---\n{}\n---{}", new_fm, rest))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_frontmatter_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
assert!(updated.contains("Status: Active"));
assert!(!updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_add_new_key() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(updated.contains("Owner: Luca"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_quoted_key() {
let content = "---\n\"Is A\": Note\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Is A",
Some(FrontmatterValue::String("Project".to_string())),
)
.unwrap();
assert!(updated.contains("\"Is A\": Project"));
assert!(!updated.contains("Note"));
}
#[test]
fn test_update_frontmatter_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"Alias1".to_string(),
"Alias2".to_string(),
])),
)
.unwrap();
assert!(updated.contains("aliases:"));
assert!(updated.contains(" - \"Alias1\""));
assert!(updated.contains(" - \"Alias2\""));
}
#[test]
fn test_update_frontmatter_replace_list() {
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec!["New1".to_string()])),
)
.unwrap();
assert!(updated.contains(" - \"New1\""));
assert!(!updated.contains("Old1"));
assert!(!updated.contains("Old2"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_delete_frontmatter_property() {
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
assert!(!updated.contains("Owner"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_delete_frontmatter_list_property() {
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
assert!(!updated.contains("aliases"));
assert!(!updated.contains("Alias1"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_no_existing() {
let content = "# Test\n\nSome content here.";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Draft".to_string())),
)
.unwrap();
assert!(updated.starts_with("---\n"));
assert!(updated.contains("Status: Draft"));
assert!(updated.contains("# Test"));
}
#[test]
fn test_update_frontmatter_bool() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
.unwrap();
assert!(updated.contains("Reviewed: true"));
}
#[test]
fn test_update_frontmatter_number() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
.unwrap();
assert!(updated.contains("Priority: 5"));
}
#[test]
fn test_update_frontmatter_number_float() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
.unwrap();
assert!(updated.contains("Score: 9.5"));
}
#[test]
fn test_update_frontmatter_null() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
assert!(updated.contains("ClearMe: null"));
}
#[test]
fn test_update_frontmatter_empty_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
.unwrap();
assert!(updated.contains("tags: []"));
}
#[test]
fn test_update_frontmatter_malformed_no_closing_fence() {
let content = "---\nStatus: Draft\nNo closing fence here";
let result = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Malformed frontmatter"));
}
#[test]
fn test_delete_nonexistent_key_noop() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
assert_eq!(updated, content);
}
#[test]
fn test_delete_from_no_frontmatter_noop() {
let content = "# Test\n\nSome content.";
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
assert_eq!(updated, content);
}
#[test]
fn test_line_is_key_unquoted() {
assert!(line_is_key("Status: Draft", "Status"));
assert!(!line_is_key("Status: Draft", "Owner"));
}
#[test]
fn test_line_is_key_double_quoted() {
assert!(line_is_key("\"Is A\": Note", "Is A"));
assert!(!line_is_key("\"Is A\": Note", "Status"));
}
#[test]
fn test_line_is_key_single_quoted() {
assert!(line_is_key("'Is A': Note", "Is A"));
}
#[test]
fn test_line_is_key_leading_whitespace() {
assert!(line_is_key(" Status: Draft", "Status"));
}
#[test]
fn test_line_is_key_partial_match() {
assert!(!line_is_key("StatusBar: value", "Status"));
}
#[test]
fn test_split_frontmatter_empty_block() {
let result = split_frontmatter("---\n---\n");
assert!(result.is_ok());
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert_eq!(rest, "\n");
}
#[test]
fn test_split_frontmatter_empty_block_no_trailing_newline() {
let result = split_frontmatter("---\n---");
assert!(result.is_ok());
}
#[test]
fn test_split_frontmatter_empty_block_with_body() {
let result = split_frontmatter("---\n---\n\n# Title\n");
assert!(result.is_ok());
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert!(rest.contains("# Title"));
}
}

View File

@@ -0,0 +1,262 @@
use serde::{Deserialize, Serialize};
/// Value type for frontmatter updates
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum FrontmatterValue {
String(String),
Number(f64),
Bool(bool),
List(Vec<String>),
Null,
}
/// Characters that require a YAML string value to be quoted.
fn has_yaml_special_chars(s: &str) -> bool {
s.contains(':') || s.contains('#')
}
/// Check if a string starts with a YAML collection indicator (array or map).
fn starts_as_yaml_collection(s: &str) -> bool {
s.starts_with('[') || s.starts_with('{')
}
/// Check whether a YAML string value needs quoting to avoid ambiguity.
fn needs_yaml_quoting(s: &str) -> bool {
has_yaml_special_chars(s)
|| starts_as_yaml_collection(s)
|| matches!(s, "true" | "false" | "null")
|| s.parse::<f64>().is_ok()
}
/// Quote a string value for YAML, escaping internal double quotes.
fn quote_yaml_string(s: &str) -> String {
format!("\"{}\"", s.replace('\"', "\\\""))
}
/// Format a single YAML list item as ` - "value"`.
fn format_list_item(item: &str) -> String {
format!(" - {}", quote_yaml_string(item))
}
/// Format a multi-line string as a YAML block scalar (`|`).
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
fn format_block_scalar(s: &str) -> String {
let indented = s
.lines()
.map(|l| {
if l.is_empty() {
String::new()
} else {
format!(" {}", l)
}
})
.collect::<Vec<_>>()
.join("\n");
format!("|\n{}", indented)
}
/// Format a number for YAML (integers without decimal, floats with).
fn format_yaml_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
impl FrontmatterValue {
pub fn to_yaml_value(&self) -> String {
match self {
FrontmatterValue::String(s) => {
if s.contains('\n') {
format_block_scalar(s)
} else if needs_yaml_quoting(s) {
quote_yaml_string(s)
} else {
s.clone()
}
}
FrontmatterValue::Number(n) => format_yaml_number(*n),
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
FrontmatterValue::List(items) => items
.iter()
.map(|item| format_list_item(item))
.collect::<Vec<_>>()
.join("\n"),
FrontmatterValue::Null => "null".to_string(),
}
}
}
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
fn needs_key_quoting(key: &str) -> bool {
key.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
}
/// Format a key for YAML output (quote if necessary)
pub fn format_yaml_key(key: &str) -> String {
if needs_key_quoting(key) {
format!("\"{}\"", key)
} else {
key.to_string()
}
}
/// Format a key-value pair as one or more YAML lines.
pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
let yaml_key = format_yaml_key(key);
let yaml_value = value.to_yaml_value();
if yaml_value.starts_with("|\n") {
// Block scalar: key and indicator on the same line, content follows
vec![format!("{}: {}", yaml_key, yaml_value)]
} else if yaml_value.contains('\n') {
vec![format!("{}:", yaml_key), yaml_value]
} else {
vec![format!("{}: {}", yaml_key, yaml_value)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_yaml_value_string_needs_quoting_colon() {
let v = FrontmatterValue::String("key: value".to_string());
assert_eq!(v.to_yaml_value(), "\"key: value\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_hash() {
let v = FrontmatterValue::String("has # comment".to_string());
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bracket() {
let v = FrontmatterValue::String("[array-like]".to_string());
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_brace() {
let v = FrontmatterValue::String("{object-like}".to_string());
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bool_like() {
assert_eq!(
FrontmatterValue::String("true".to_string()).to_yaml_value(),
"\"true\""
);
assert_eq!(
FrontmatterValue::String("false".to_string()).to_yaml_value(),
"\"false\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_null_like() {
assert_eq!(
FrontmatterValue::String("null".to_string()).to_yaml_value(),
"\"null\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_number_like() {
assert_eq!(
FrontmatterValue::String("42".to_string()).to_yaml_value(),
"\"42\""
);
assert_eq!(
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
"\"3.14\""
);
}
#[test]
fn test_to_yaml_value_string_plain() {
let v = FrontmatterValue::String("Hello World".to_string());
assert_eq!(v.to_yaml_value(), "Hello World");
}
#[test]
fn test_to_yaml_value_number_integer() {
let v = FrontmatterValue::Number(42.0);
assert_eq!(v.to_yaml_value(), "42");
}
#[test]
fn test_to_yaml_value_number_float() {
let v = FrontmatterValue::Number(3.14);
assert_eq!(v.to_yaml_value(), "3.14");
}
#[test]
fn test_to_yaml_value_null() {
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
}
#[test]
fn test_to_yaml_value_empty_list() {
let v = FrontmatterValue::List(vec![]);
assert_eq!(v.to_yaml_value(), "[]");
}
#[test]
fn test_to_yaml_value_list_with_colon() {
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
}
#[test]
fn test_to_yaml_value_multiline_uses_block_scalar() {
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
let yaml = v.to_yaml_value();
assert!(yaml.starts_with("|\n"));
assert!(yaml.contains(" line 1"));
assert!(yaml.contains(" line 2"));
}
#[test]
fn test_format_yaml_key_simple() {
assert_eq!(format_yaml_key("Status"), "Status");
assert_eq!(format_yaml_key("is_a"), "is_a");
}
#[test]
fn test_format_yaml_key_with_spaces() {
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
}
#[test]
fn test_format_yaml_key_with_colon() {
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
}
#[test]
fn test_format_yaml_key_with_hash() {
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
}
#[test]
fn test_format_yaml_key_with_period() {
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
}
#[test]
fn test_format_yaml_field_block_scalar() {
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
let lines = format_yaml_field("template", &v);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("template: |\n"));
assert!(lines[0].contains(" ## Objective"));
assert!(lines[0].contains(" ## Timeline"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
use std::path::Path;
use std::process::Command;
/// Commit all changes with a message.
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Stage all changes
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git add: {}", e))?;
if !add.status.success() {
let stderr = String::from_utf8_lossy(&add.stderr);
return Err(format!("git add failed: {}", stderr));
}
// Commit
let commit = Command::new("git")
.args(["commit", "-m", message])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
let stdout = String::from_utf8_lossy(&commit.stdout);
// git writes "nothing to commit" to stdout, not stderr
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
return Err(format!("git commit failed: {}", detail.trim()));
}
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_git_commit() {
let dir = setup_git_repo();
let vault = dir.path();
fs::write(vault.join("commit-test.md"), "# Test\n").unwrap();
let result = git_commit(vault.to_str().unwrap(), "Test commit");
assert!(result.is_ok());
// Verify the commit exists
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Test commit"));
}
#[test]
fn test_commit_nothing_to_commit_returns_error() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Create and commit, so working tree is clean
fs::write(vault.join("clean.md"), "# Clean\n").unwrap();
git_commit(vp, "initial").unwrap();
// Committing again with no changes should fail
let result = git_commit(vp, "nothing here");
assert!(result.is_err(), "Commit should fail when nothing to commit");
assert!(
result.unwrap_err().contains("nothing to commit"),
"Error should mention 'nothing to commit'"
);
}
}

View File

@@ -0,0 +1,366 @@
use std::path::Path;
use std::process::Command;
use super::run_git;
/// List files with merge conflicts (unmerged paths).
///
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
/// ls-files reliably detects unmerged index entries even when the merge state is
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["ls-files", "--unmerged"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
// Format: "<mode> <hash> <stage>\t<path>"
let mut files: Vec<String> = stdout
.lines()
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
.collect();
files.sort();
files.dedup();
Ok(files)
}
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
/// then stage the result.
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let checkout_flag = match strategy {
"ours" => "--ours",
"theirs" => "--theirs",
_ => {
return Err(format!(
"Invalid strategy '{}': must be 'ours' or 'theirs'",
strategy
))
}
};
run_git(vault, &["checkout", checkout_flag, "--", file])?;
run_git(vault, &["add", "--", file])?;
Ok(())
}
/// Check whether a rebase is currently in progress.
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
let vault = Path::new(vault_path);
let git_dir = vault.join(".git");
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
}
/// Check whether a merge is currently in progress.
pub fn is_merge_in_progress(vault_path: &str) -> bool {
Path::new(vault_path)
.join(".git")
.join("MERGE_HEAD")
.exists()
}
/// Returns the current conflict mode: "rebase", "merge", or "none".
pub fn get_conflict_mode(vault_path: &str) -> String {
if is_rebase_in_progress(vault_path) {
"rebase".to_string()
} else if is_merge_in_progress(vault_path) {
"merge".to_string()
} else {
"none".to_string()
}
}
/// Commit after all conflicts have been resolved.
/// Detects whether the repo is in a merge or rebase state and uses the
/// appropriate command (`git commit` vs `git rebase --continue`).
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Verify no remaining conflicts
let remaining = get_conflict_files(vault_path)?;
if !remaining.is_empty() {
return Err(format!(
"Cannot commit: {} file(s) still have unresolved conflicts",
remaining.len()
));
}
let mode = get_conflict_mode(vault_path);
let output = match mode.as_str() {
"rebase" => Command::new("git")
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
_ => Command::new("git")
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?,
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
let cmd_name = if mode == "rebase" {
"git rebase --continue"
} else {
"git commit"
};
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use crate::git::{git_commit, git_pull, git_push};
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn test_get_conflict_files_empty_when_clean() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let conflicts = get_conflict_files(vp).unwrap();
assert!(conflicts.is_empty());
}
#[test]
fn test_resolve_conflict_invalid_strategy() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid strategy"));
}
#[test]
fn test_conflict_mode_none_for_clean_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
assert_eq!(get_conflict_mode(vp), "none");
assert!(!is_rebase_in_progress(vp));
assert!(!is_merge_in_progress(vp));
}
/// Set up a pair of clones that have a merge conflict on the same file.
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
// A creates the file and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
// B pulls to get the file
git_pull(vp_b).unwrap();
// A modifies and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
// B modifies the same file locally and commits
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
// B pulls — this causes a merge conflict
let result = git_pull(vp_b).unwrap();
assert_eq!(result.status, "conflict");
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_resolve_conflict_ours() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(conflicts.contains(&"conflict.md".to_string()));
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version B\n");
}
#[test]
fn test_resolve_conflict_theirs() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version A\n");
}
#[test]
fn test_commit_conflict_resolution() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(clone_b.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Resolve merge conflicts"));
}
#[test]
fn test_commit_conflict_resolution_fails_with_unresolved() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("still have unresolved conflicts"));
}
#[test]
fn test_conflict_mode_merge_during_merge_conflict() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
assert!(is_merge_in_progress(vp_b));
assert!(!is_rebase_in_progress(vp_b));
}
#[test]
fn test_commit_conflict_resolution_merge_mode() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
assert_eq!(get_conflict_mode(vp_b), "none");
}
/// Set up a rebase conflict: clone_b has diverged from origin and
/// `git pull --rebase` causes a conflict.
fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
let output = Command::new("git")
.args(["pull", "--rebase"])
.current_dir(clone_b_dir.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"Expected rebase conflict, but pull succeeded"
);
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_conflict_mode_rebase_during_rebase_conflict() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
assert!(is_rebase_in_progress(vp_b));
assert!(!is_merge_in_progress(vp_b));
}
#[test]
fn test_get_conflict_files_during_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(
conflicts.contains(&"conflict.md".to_string()),
"Should detect conflict.md during rebase, got: {:?}",
conflicts
);
}
#[test]
fn test_resolve_and_continue_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
assert_eq!(get_conflict_mode(vp_b), "none");
}
}

View File

@@ -0,0 +1,349 @@
use std::path::Path;
use std::process::Command;
use super::GitCommit;
/// Get git log history for a specific file in the vault.
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let output = Command::new("git")
.args([
"log",
"--format=%H|%h|%an|%aI|%s",
"-n",
"20",
"--",
relative_str,
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// No commits yet is not an error - just return empty history
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let commits = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
// Format: hash|short_hash|author|date|message
// Use splitn(5) so message (last) can contain '|'
let parts: Vec<&str> = line.splitn(5, '|').collect();
if parts.len() != 5 {
return None;
}
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
Some(GitCommit {
hash: parts[0].to_string(),
short_hash: parts[1].to_string(),
author: parts[2].to_string(),
date,
message: parts[4].to_string(),
})
})
.collect();
Ok(commits)
}
/// Get git diff for a specific file.
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// First try tracked file diff
let output = Command::new("git")
.args(["diff", "--", relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// If no diff (maybe staged or untracked), try diff --cached
if stdout.is_empty() {
let cached = Command::new("git")
.args(["diff", "--cached", "--", relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string();
if !cached_stdout.is_empty() {
return Ok(cached_stdout);
}
// Try showing untracked file as all-new
let status = Command::new("git")
.args(["status", "--porcelain", "--", relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
let status_out = String::from_utf8_lossy(&status.stdout);
if status_out.starts_with("??") {
// Untracked file: show entire content as added
let content =
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
return Ok(format!(
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
relative_str,
lines.len(),
lines.join("\n")
));
}
}
Ok(stdout)
}
/// Get git diff for a specific file at a given commit (compared to its parent).
pub fn get_file_diff_at_commit(
vault_path: &str,
file_path: &str,
commit_hash: &str,
) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// Show diff between commit^ and commit for this file
let output = Command::new("git")
.args([
"diff",
&format!("{}^", commit_hash),
commit_hash,
"--",
relative_str,
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// If diff is empty, it might be the initial commit (no parent).
// Fall back to showing the full file content as added.
if stdout.is_empty() {
let show = Command::new("git")
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git show: {}", e))?;
if show.status.success() {
let content = String::from_utf8_lossy(&show.stdout);
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
return Ok(format!(
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
relative_str,
lines.len(),
lines.join("\n")
));
}
}
Ok(stdout)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_get_file_history_with_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("test.md");
fs::write(&file, "# Initial\n").unwrap();
Command::new("git")
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# Updated\n\nNew content.").unwrap();
Command::new("git")
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Update test"])
.current_dir(vault)
.output()
.unwrap();
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].message, "Update test");
assert_eq!(history[1].message, "Initial commit");
assert_eq!(history[0].author, "Test User");
assert!(!history[0].hash.is_empty());
assert!(!history[0].short_hash.is_empty());
}
#[test]
fn test_get_file_history_no_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("new.md");
fs::write(&file, "# New\n").unwrap();
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(history.is_empty());
}
#[test]
fn test_get_file_diff() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("diff-test.md");
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
Command::new("git")
.args(["add", "diff-test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add diff-test"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# Test\n\nModified content.").unwrap();
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
assert!(diff.contains("+Modified content."));
}
#[test]
fn test_get_file_diff_at_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("diff-at-commit.md");
fs::write(&file, "# First\n\nOriginal content.").unwrap();
Command::new("git")
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "First commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# First\n\nModified content.").unwrap();
Command::new("git")
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(vault)
.output()
.unwrap();
// Get hash of second commit
let log = Command::new("git")
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()
.unwrap();
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
assert!(diff.contains("+Modified content."));
}
#[test]
fn test_get_file_diff_at_initial_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("initial.md");
fs::write(&file, "# Initial\n\nHello world.").unwrap();
Command::new("git")
.args(["add", "initial.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
let log = Command::new("git")
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()
.unwrap();
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("+# Initial"));
assert!(diff.contains("+Hello world."));
}
}

278
src-tauri/src/git/mod.rs Normal file
View File

@@ -0,0 +1,278 @@
mod commit;
mod conflict;
mod history;
mod pulse;
mod remote;
mod status;
use std::path::Path;
use std::process::Command;
pub use commit::git_commit;
pub use conflict::{
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
is_merge_in_progress, is_rebase_in_progress,
};
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
pub use status::{get_modified_files, ModifiedFile};
use serde::Serialize;
#[derive(Debug, Serialize, Clone)]
pub struct GitCommit {
pub hash: String,
#[serde(rename = "shortHash")]
pub short_hash: String,
pub message: String,
pub author: String,
pub date: i64,
}
/// Initialize a new git repository, stage all files, and create an initial commit.
pub fn init_repo(path: &str) -> Result<(), String> {
let dir = Path::new(path);
run_git(dir, &["init"])?;
ensure_author_config(dir)?;
run_git(dir, &["add", "."])?;
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
Ok(())
}
/// Run a git command in the given directory, returning an error on failure.
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
if output.status.success() {
return Ok(());
}
Err(format!(
"git {} failed: {}",
args[0],
String::from_utf8_lossy(&output.stderr)
))
}
/// Set local user.name and user.email if not already configured.
fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
let check = Command::new("git")
.args(["config", key])
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to check git config: {}", e))?;
let value = String::from_utf8_lossy(&check.stdout);
if !check.status.success() || value.trim().is_empty() {
run_git(dir, &["config", key, fallback])?;
}
}
Ok(())
}
/// Extract "owner/repo" from a GitHub remote URL.
/// Supports HTTPS (https://github.com/owner/repo.git) and
/// SSH (git@github.com:owner/repo.git) formats.
fn parse_github_repo_path(url: &str) -> Option<String> {
let trimmed = url.trim();
// SSH format: git@github.com:owner/repo.git
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
let path = rest.strip_suffix(".git").unwrap_or(rest);
if path.contains('/') {
return Some(path.to_string());
}
}
// HTTPS format: https://github.com/owner/repo.git
// Also handle token-embedded URLs: https://token@github.com/owner/repo.git
if trimmed.contains("github.com/") {
let after = trimmed.split("github.com/").nth(1)?;
let path = after.strip_suffix(".git").unwrap_or(after);
if path.contains('/') {
return Some(path.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
pub(crate) fn setup_git_repo() -> TempDir {
let dir = TempDir::new().unwrap();
let path = dir.path();
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output()
.unwrap();
dir
}
/// Set up a bare "remote" and a clone that acts as the working vault.
pub(crate) fn setup_remote_pair() -> (TempDir, TempDir, TempDir) {
let bare_dir = TempDir::new().unwrap();
let bare = bare_dir.path();
Command::new("git")
.args(["init", "--bare"])
.current_dir(bare)
.output()
.unwrap();
let clone_a_dir = TempDir::new().unwrap();
Command::new("git")
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_a_dir.path())
.output()
.unwrap();
for cmd in &[
&["config", "user.email", "a@test.com"][..],
&["config", "user.name", "User A"][..],
] {
Command::new("git")
.args(*cmd)
.current_dir(clone_a_dir.path())
.output()
.unwrap();
}
let clone_b_dir = TempDir::new().unwrap();
Command::new("git")
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_b_dir.path())
.output()
.unwrap();
for cmd in &[
&["config", "user.email", "b@test.com"][..],
&["config", "user.name", "User B"][..],
] {
Command::new("git")
.args(*cmd)
.current_dir(clone_b_dir.path())
.output()
.unwrap();
}
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_init_repo_creates_git_directory() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
assert!(vault.join(".git").exists());
}
#[test]
fn test_init_repo_creates_initial_commit() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_init_repo_stages_all_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(vault.join("sub")).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let status = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&vault)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"All files should be committed"
);
}
#[test]
fn test_parse_github_repo_path_https() {
assert_eq!(
parse_github_repo_path("https://github.com/owner/repo.git"),
Some("owner/repo".to_string())
);
assert_eq!(
parse_github_repo_path("https://github.com/owner/repo"),
Some("owner/repo".to_string())
);
}
#[test]
fn test_parse_github_repo_path_ssh() {
assert_eq!(
parse_github_repo_path("git@github.com:owner/repo.git"),
Some("owner/repo".to_string())
);
assert_eq!(
parse_github_repo_path("git@github.com:owner/repo"),
Some("owner/repo".to_string())
);
}
#[test]
fn test_parse_github_repo_path_token_embedded() {
assert_eq!(
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn test_parse_github_repo_path_non_github() {
assert_eq!(
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
None
);
}
}

479
src-tauri/src/git/pulse.rs Normal file
View File

@@ -0,0 +1,479 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use super::parse_github_repo_path;
#[derive(Debug, Serialize, Clone)]
pub struct PulseFile {
pub path: String,
pub status: String,
pub title: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct PulseCommit {
pub hash: String,
#[serde(rename = "shortHash")]
pub short_hash: String,
pub message: String,
pub date: i64,
#[serde(rename = "githubUrl")]
pub github_url: Option<String>,
pub files: Vec<PulseFile>,
pub added: usize,
pub modified: usize,
pub deleted: usize,
}
#[derive(Debug, Serialize, Clone)]
pub struct LastCommitInfo {
#[serde(rename = "shortHash")]
pub short_hash: String,
#[serde(rename = "commitUrl")]
pub commit_url: Option<String>,
}
fn title_from_path(path: &str) -> String {
path.rsplit('/')
.next()
.unwrap_or(path)
.strip_suffix(".md")
.unwrap_or(path)
.replace('-', " ")
}
fn parse_file_status(code: &str) -> &str {
match code {
"A" => "added",
"M" => "modified",
"D" => "deleted",
_ => "modified",
}
}
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit>, String> {
let vault = Path::new(vault_path);
if !vault.join(".git").exists() {
return Err("Not a git repository".to_string());
}
let limit_str = limit.to_string();
let output = Command::new("git")
.args([
"log",
"--name-status",
"--pretty=format:%H|%h|%s|%aI",
"--diff-filter=ADM",
"-n",
&limit_str,
"--",
"*.md",
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let github_base = get_github_base_url(vault_path);
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pulse_output(&stdout, &github_base))
}
fn get_github_base_url(vault_path: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!("https://github.com/{}", repo_path))
}
fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCommit> {
let mut commits: Vec<PulseCommit> = Vec::new();
let mut current: Option<PulseCommit> = None;
for line in stdout.lines() {
if line.is_empty() {
continue;
}
if line.contains('|')
&& !line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
{
// Commit header line: hash|short_hash|message|date
if let Some(commit) = current.take() {
commits.push(commit);
}
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
current = Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
});
}
} else if let Some(ref mut commit) = current {
// File status line: A\tpath or M\tpath
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() == 2 {
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
}
}
if let Some(commit) = current {
commits.push(commit);
}
commits
}
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["log", "-1", "--format=%H|%h"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(None);
}
return Err(format!("git log failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.trim();
if line.is_empty() {
return Ok(None);
}
let parts: Vec<&str> = line.splitn(2, '|').collect();
if parts.len() != 2 {
return Ok(None);
}
let full_hash = parts[0];
let short_hash = parts[1].to_string();
let commit_url = get_github_commit_url(vault_path, full_hash);
Ok(Some(LastCommitInfo {
short_hash,
commit_url,
}))
}
/// Try to build a GitHub commit URL from the origin remote URL.
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!(
"https://github.com/{}/commit/{}",
repo_path, full_hash
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn test_get_vault_pulse_with_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("project.md"), "# Project\n").unwrap();
git_commit(vp, "Add project").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse.len(), 2);
assert_eq!(pulse[0].message, "Add project");
assert_eq!(pulse[1].message, "Add note");
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "project.md");
assert_eq!(pulse[0].files[0].status, "added");
assert_eq!(pulse[0].added, 1);
assert_eq!(pulse[0].modified, 0);
assert!(!pulse[0].short_hash.is_empty());
}
#[test]
fn test_get_vault_pulse_no_git() {
let dir = TempDir::new().unwrap();
let vp = dir.path().to_str().unwrap();
let result = get_vault_pulse(vp, 30);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a git repository"));
}
#[test]
fn test_get_vault_pulse_empty_repo() {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse.is_empty());
}
#[test]
fn test_get_vault_pulse_only_md_files() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
fs::write(vault.join("config.json"), "{}").unwrap();
git_commit(vp, "Add files").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse.len(), 1);
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "note.md");
}
#[test]
fn test_get_vault_pulse_respects_limit() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
for i in 0..5 {
fs::write(
vault.join(format!("note{}.md", i)),
format!("# Note {}\n", i),
)
.unwrap();
git_commit(vp, &format!("Add note {}", i)).unwrap();
}
let pulse = get_vault_pulse(vp, 3).unwrap();
assert_eq!(pulse.len(), 3);
}
#[test]
fn test_get_vault_pulse_modified_and_deleted() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
git_commit(vp, "Update note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse[0].message, "Update note");
assert_eq!(pulse[0].files[0].status, "modified");
assert_eq!(pulse[0].modified, 1);
}
#[test]
fn test_get_vault_pulse_github_url() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(vault)
.output()
.unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse[0].github_url.is_some());
let url = pulse[0].github_url.as_ref().unwrap();
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
}
#[test]
fn test_get_vault_pulse_no_github_url_without_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse[0].github_url.is_none());
}
#[test]
fn test_title_from_path() {
assert_eq!(title_from_path("note/my-project.md"), "my project");
assert_eq!(title_from_path("simple.md"), "simple");
assert_eq!(title_from_path("deep/nested/file.md"), "file");
}
#[test]
fn test_parse_pulse_output_basic() {
let stdout =
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message, "Add notes");
assert_eq!(commits[0].files.len(), 2);
assert_eq!(commits[0].files[0].status, "added");
assert_eq!(commits[0].files[1].status, "modified");
assert_eq!(commits[0].added, 1);
assert_eq!(commits[0].modified, 1);
assert!(commits[0].github_url.is_none());
}
#[test]
fn test_parse_pulse_output_with_github() {
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
let base = Some("https://github.com/o/r".to_string());
let commits = parse_pulse_output(stdout, &base);
assert_eq!(
commits[0].github_url.as_deref(),
Some("https://github.com/o/r/commit/abc123")
);
}
#[test]
fn test_parse_pulse_output_multiple_commits() {
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message, "First");
assert_eq!(commits[1].message, "Second");
assert_eq!(commits[1].files.len(), 2);
assert_eq!(commits[1].deleted, 1);
}
#[test]
fn test_get_last_commit_info_with_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let info = get_last_commit_info(vp).unwrap();
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.short_hash.len(), 7);
assert!(info.commit_url.is_none());
}
#[test]
fn test_get_last_commit_info_no_commits() {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let info = get_last_commit_info(vp).unwrap();
assert!(info.is_none());
}
#[test]
fn test_get_last_commit_info_with_github_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/lucaong/laputa-vault.git",
])
.current_dir(vault)
.output()
.unwrap();
let info = get_last_commit_info(vp).unwrap().unwrap();
assert!(info.commit_url.is_some());
let url = info.commit_url.unwrap();
assert!(url.starts_with("https://github.com/lucaong/laputa-vault/commit/"));
}
}

246
src-tauri/src/git/remote.rs Normal file
View File

@@ -0,0 +1,246 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use super::conflict::get_conflict_files;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPullResult {
pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error"
pub message: String,
#[serde(rename = "updatedFiles")]
pub updated_files: Vec<String>,
#[serde(rename = "conflictFiles")]
pub conflict_files: Vec<String>,
}
/// Check whether the vault repo has at least one remote configured.
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git remote: {}", e))?;
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
}
/// Pull latest changes from remote. Uses --no-rebase to merge.
/// Returns a structured result with status and affected files.
pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
let vault = Path::new(vault_path);
if !has_remote(vault_path)? {
return Ok(GitPullResult {
status: "no_remote".to_string(),
message: "No remote configured".to_string(),
updated_files: vec![],
conflict_files: vec![],
});
}
let output = Command::new("git")
.args(["pull", "--no-rebase"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git pull: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
return Ok(GitPullResult {
status: "up_to_date".to_string(),
message: "Already up to date".to_string(),
updated_files: vec![],
conflict_files: vec![],
});
}
let updated = parse_updated_files(&stdout);
return Ok(GitPullResult {
status: "updated".to_string(),
message: format!("{} file(s) updated", updated.len()),
updated_files: updated,
conflict_files: vec![],
});
}
// Check for merge conflicts
let conflicts = get_conflict_files(vault_path).unwrap_or_default();
if !conflicts.is_empty() {
return Ok(GitPullResult {
status: "conflict".to_string(),
message: format!("Merge conflict in {} file(s)", conflicts.len()),
updated_files: vec![],
conflict_files: conflicts,
});
}
// Network error or other failure — report as error
let detail = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
Ok(GitPullResult {
status: "error".to_string(),
message: detail,
updated_files: vec![],
conflict_files: vec![],
})
}
/// Parse `git pull` output to extract updated file paths.
fn parse_updated_files(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
// Lines like " path/to/file.md | 5 ++-" in diffstat
if trimmed.contains('|') {
let path = trimmed.split('|').next()?.trim();
if !path.is_empty() {
return Some(path.to_string());
}
}
None
})
.collect()
}
/// Push to remote.
pub fn git_push(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["push"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git push: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git push failed: {}", stderr));
}
// git push often writes to stderr even on success
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(format!("{}{}", stdout, stderr))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_commit;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use std::fs;
use std::process::Command;
#[test]
fn test_has_remote_returns_false_for_local_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
assert!(!has_remote(vp).unwrap());
}
#[test]
fn test_has_remote_returns_true_when_remote_exists() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
Command::new("git")
.args(["remote", "add", "origin", "https://example.com/repo.git"])
.current_dir(vault)
.output()
.unwrap();
assert!(has_remote(vp).unwrap());
}
#[test]
fn test_git_pull_no_remote_returns_no_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let result = git_pull(vp).unwrap();
assert_eq!(result.status, "no_remote");
assert!(result.updated_files.is_empty());
assert!(result.conflict_files.is_empty());
}
#[test]
fn test_git_pull_up_to_date() {
let (_bare, clone_a, _clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
git_push(vp_a).unwrap();
let result = git_pull(vp_a).unwrap();
assert_eq!(result.status, "up_to_date");
}
#[test]
fn test_git_pull_updated_files() {
let (_bare, clone_a, clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
let vp_b = clone_b.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_a.path().join("note.md"), "# Updated Note\n").unwrap();
git_commit(vp_a, "update note").unwrap();
git_push(vp_a).unwrap();
let result = git_pull(vp_b).unwrap();
assert_eq!(result.status, "updated");
assert!(result.conflict_files.is_empty());
}
#[test]
fn test_parse_updated_files_diffstat() {
let stdout =
" Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n";
let files = parse_updated_files(stdout);
assert_eq!(files, vec!["note.md", "project/plan.md"]);
}
#[test]
fn test_parse_updated_files_empty() {
let stdout = "Already up to date.\n";
let files = parse_updated_files(stdout);
assert!(files.is_empty());
}
#[test]
fn test_git_pull_result_serialization() {
let result = GitPullResult {
status: "updated".to_string(),
message: "2 file(s) updated".to_string(),
updated_files: vec!["note.md".to_string()],
conflict_files: vec![],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"updatedFiles\""));
assert!(json.contains("\"conflictFiles\""));
let parsed: GitPullResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, "updated");
assert_eq!(parsed.updated_files.len(), 1);
}
}

175
src-tauri/src/git/status.rs Normal file
View File

@@ -0,0 +1,175 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Clone)]
pub struct ModifiedFile {
pub path: String,
#[serde(rename = "relativePath")]
pub relative_path: String,
pub status: String,
}
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git status failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
if line.len() < 4 {
return None;
}
let status_code = &line[..2];
let path = line[3..].trim().to_string();
// Only include markdown files
if !path.ends_with(".md") {
return None;
}
let status = match status_code.trim() {
"M" | "MM" | "AM" => "modified",
"A" => "added",
"D" => "deleted",
"??" => "untracked",
"R" | "RM" => "renamed",
_ => "modified",
};
let full_path = vault.join(&path).to_string_lossy().to_string();
Some(ModifiedFile {
path: full_path,
relative_path: path,
status: status.to_string(),
})
})
.collect();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_get_modified_files() {
let dir = setup_git_repo();
let vault = dir.path();
// Create and commit a file
fs::write(vault.join("note.md"), "# Note\n").unwrap();
Command::new("git")
.args(["add", "note.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add note"])
.current_dir(vault)
.output()
.unwrap();
// Modify it
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
// Add an untracked file
fs::write(vault.join("new.md"), "# New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert!(modified.len() >= 2);
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
assert!(statuses.contains(&"modified"));
assert!(statuses.contains(&"untracked"));
}
#[test]
fn test_get_modified_files_untracked_in_subdirectory() {
let dir = setup_git_repo();
let vault = dir.path();
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
Command::new("git")
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()
.unwrap();
// Create a new untracked file in a subdirectory (simulates new note creation)
fs::create_dir_all(vault.join("note")).unwrap();
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].status, "untracked");
assert_eq!(modified[0].relative_path, "note/brand-new.md");
assert!(
modified[0].path.ends_with("/note/brand-new.md"),
"Full path should end with relative path: {}",
modified[0].path
);
}
#[test]
fn test_commit_flow_modified_files_then_commit_clears() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Create and commit initial file
fs::write(vault.join("flow.md"), "# Original\n").unwrap();
git_commit(vp, "initial").unwrap();
// Modify the file on disk
fs::write(vault.join("flow.md"), "# Modified\n").unwrap();
// get_modified_files should detect the change
let modified = get_modified_files(vp).unwrap();
assert!(
modified.iter().any(|f| f.relative_path == "flow.md"),
"Modified file should be detected after write"
);
// Commit the change
let result = git_commit(vp, "update flow").unwrap();
assert!(
result.contains("1 file changed") || result.contains("flow.md"),
"Commit output should reference the changed file: {}",
result
);
// After commit, get_modified_files should return empty
let after = get_modified_files(vp).unwrap();
assert!(
after.is_empty(),
"No modified files should remain after commit, found: {:?}",
after
);
}
}

View File

@@ -1,886 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
/// GitHub App client ID for OAuth device flow.
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GithubRepo {
pub name: String,
pub full_name: String,
pub description: Option<String>,
pub private: bool,
pub clone_url: String,
pub html_url: String,
pub updated_at: Option<String>,
}
/// Lists the authenticated user's GitHub repositories.
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
github_list_repos_with_base(token, "https://api.github.com").await
}
async fn github_list_repos_with_base(
token: &str,
api_base: &str,
) -> Result<Vec<GithubRepo>, String> {
let client = reqwest::Client::new();
let mut all_repos: Vec<GithubRepo> = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{}/user/repos?per_page=100&sort=updated&page={}",
api_base, page
);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
let repos: Vec<GithubRepo> = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
let count = repos.len();
all_repos.extend(repos);
if count < 100 {
break;
}
page += 1;
if page > 10 {
break; // safety limit: 1000 repos max
}
}
Ok(all_repos)
}
/// Creates a new GitHub repository for the authenticated user.
pub async fn github_create_repo(
token: &str,
name: &str,
private: bool,
) -> Result<GithubRepo, String> {
github_create_repo_with_base(token, name, private, "https://api.github.com").await
}
async fn github_create_repo_with_base(
token: &str,
name: &str,
private: bool,
api_base: &str,
) -> Result<GithubRepo, String> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"name": name,
"private": private,
"auto_init": true,
"description": "Laputa vault"
});
let response = client
.post(format!("{}/user/repos", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 422 && body.contains("name already exists") {
return Err("Repository name already exists on your account".to_string());
}
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GithubRepo>()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
}
// --- OAuth Device Flow ---
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowStart {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowPollResult {
pub status: String,
pub access_token: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubUser {
pub login: String,
pub name: Option<String>,
pub avatar_url: String,
}
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github_device_flow_start_with_base("https://github.com").await
}
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/device/code", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
.send()
.await
.map_err(|e| format!("Device flow request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 404 {
return Err(
"GitHub device flow not available. Ensure a GitHub App is registered with \
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
.to_string(),
);
}
return Err(format!("Device flow start failed ({}): {}", status, body));
}
response
.json::<DeviceFlowStart>()
.await
.map_err(|e| format!("Failed to parse device flow response: {}", e))
}
/// Polls GitHub for the device flow authorization result.
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
github_device_flow_poll_with_base(device_code, "https://github.com").await
}
async fn github_device_flow_poll_with_base(
device_code: &str,
base_url: &str,
) -> Result<DeviceFlowPollResult, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/oauth/access_token", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| format!("Device flow poll failed: {}", e))?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Device flow poll HTTP error: {}", body));
}
#[derive(Deserialize)]
struct RawResponse {
access_token: Option<String>,
error: Option<String>,
}
let raw: RawResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
if let Some(token) = raw.access_token {
Ok(DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some(token),
error: None,
})
} else {
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
let status = match error.as_str() {
"authorization_pending" | "slow_down" => "pending",
"expired_token" => "expired",
_ => "error",
};
Ok(DeviceFlowPollResult {
status: status.to_string(),
access_token: None,
error: Some(error),
})
}
}
/// Gets the authenticated GitHub user's profile.
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
github_get_user_with_base(token, "https://api.github.com").await
}
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/user", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub user request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GitHubUser>()
.await
.map_err(|e| format!("Failed to parse user response: {}", e))
}
/// Clones a GitHub repo to a local path using HTTPS + token auth.
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
if dest.exists()
&& dest
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false)
{
return Err(format!(
"Destination '{}' already exists and is not empty",
local_path
));
}
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
let auth_url = inject_token_into_url(url, token)?;
let output = Command::new("git")
.args(["clone", "--progress", &auth_url, local_path])
.output()
.map_err(|e| format!("Failed to run git clone: {}", e))?;
if !output.status.success() {
// Clean up partial clone on failure
if dest.exists() {
let _ = std::fs::remove_dir_all(dest);
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {}", stderr));
}
// Configure the remote to use token auth for future pushes
configure_remote_auth(local_path, url, token)?;
Ok(format!("Cloned to {}", local_path))
}
/// Injects an OAuth token into an HTTPS GitHub URL.
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
if let Some(rest) = url.strip_prefix("https://github.com/") {
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
} else if let Some(rest) = url.strip_prefix("https://") {
// Handle URLs that already have a host
Ok(format!("https://oauth2:{}@{}", token, rest))
} else {
Err(format!(
"Unsupported URL format: {}. Use an HTTPS URL.",
url
))
}
}
/// Sets up the git remote to use token-based HTTPS auth.
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
let auth_url = inject_token_into_url(original_url, token)?;
let vault = Path::new(local_path);
let output = Command::new("git")
.args(["remote", "set-url", "origin", &auth_url])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to configure remote: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to set remote URL: {}", stderr));
}
// Also configure git user if not set
let _ = Command::new("git")
.args(["config", "user.email", "laputa@app.local"])
.current_dir(vault)
.output();
let _ = Command::new("git")
.args(["config", "user.name", "Laputa App"])
.current_dir(vault)
.output();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
// ── Mock helpers ─────────────────────────────────────────────────────────
async fn mock_json(
method: &str,
path: &str,
status: usize,
body: &str,
) -> (mockito::ServerGuard, mockito::Mock) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap();
mock.assert_async().await;
result
}
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
let (server, mock) = mock_json("GET", "/user", status, body).await;
let result = github_get_user_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
fn clone_err_contains(url: &str, expected: &str) {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("dest");
let result = clone_repo(url, "token", dest.to_str().unwrap());
assert!(result.unwrap_err().contains(expected));
}
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
let (server, mock) = mock_json(
"GET",
"/user/repos?per_page=100&sort=updated&page=1",
status,
body,
)
.await;
let result = github_list_repos_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
let result = github_device_flow_start_with_base(&server.url()).await;
mock.assert_async().await;
result
}
// ── Sync/pure logic tests ────────────────────────────────────────────────
#[test]
fn test_inject_token_basic_github_url() {
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
assert_eq!(
result.unwrap(),
"https://oauth2:gho_abc123@github.com/user/repo.git"
);
}
#[test]
fn test_inject_token_generic_https_url() {
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
assert_eq!(
result.unwrap(),
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
);
}
#[test]
fn test_inject_token_ssh_url_rejected() {
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
assert!(err.contains("Unsupported URL format"));
}
#[test]
fn test_inject_token_http_url_rejected() {
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
}
#[test]
fn test_inject_token_github_without_dot_git() {
let result = inject_token_into_url("https://github.com/user/repo", "tok");
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
}
#[test]
fn test_clone_repo_nonempty_dest() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
let result = clone_repo(
"https://github.com/test/repo.git",
"token",
dir.path().to_str().unwrap(),
);
assert!(result.unwrap_err().contains("not empty"));
}
#[test]
fn test_clone_repo_ssh_url_rejected() {
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
}
#[test]
fn test_clone_repo_empty_dest_allowed() {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("empty-dir");
std::fs::create_dir(&dest).unwrap();
let result = clone_repo(
"https://github.com/nonexistent/repo.git",
"token",
dest.to_str().unwrap(),
);
// Should fail at git clone, not at directory check
assert!(result.unwrap_err().contains("git clone failed"));
}
#[test]
fn test_configure_remote_auth_on_git_repo() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/user/repo.git",
])
.current_dir(path)
.output()
.unwrap();
configure_remote_auth(
path.to_str().unwrap(),
"https://github.com/user/repo.git",
"gho_test123",
)
.unwrap();
let output = StdCommand::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(path)
.output()
.unwrap();
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
}
// ── Serialization roundtrip tests ────────────────────────────────────────
#[test]
fn test_github_repo_serialization_roundtrip() {
let repo = GithubRepo {
name: "test-repo".to_string(),
full_name: "user/test-repo".to_string(),
description: Some("A test repo".to_string()),
private: true,
clone_url: "https://github.com/user/test-repo.git".to_string(),
html_url: "https://github.com/user/test-repo".to_string(),
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
};
let json = serde_json::to_string(&repo).unwrap();
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
}
#[test]
fn test_github_repo_deserialization_null_fields() {
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
let repo: GithubRepo = serde_json::from_str(json).unwrap();
assert_eq!(repo.name, "r");
assert!(!repo.private);
assert!(repo.description.is_none());
assert!(repo.updated_at.is_none());
}
#[test]
fn test_device_flow_start_serialization_roundtrip() {
let start = DeviceFlowStart {
device_code: "dc_123".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
};
let json = serde_json::to_string(&start).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
start
);
}
#[test]
fn test_device_flow_poll_result_roundtrip() {
let complete = DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_abc123".to_string()),
error: None,
};
let json = serde_json::to_string(&complete).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
complete
);
let pending = DeviceFlowPollResult {
status: "pending".to_string(),
access_token: None,
error: Some("authorization_pending".to_string()),
};
let json = serde_json::to_string(&pending).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
pending
);
}
#[test]
fn test_github_user_serialization_roundtrip() {
let user = GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
};
let json = serde_json::to_string(&user).unwrap();
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
}
#[test]
fn test_github_user_deserialization_null_name() {
let user: GitHubUser =
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
.unwrap();
assert_eq!(user.login, "bot");
assert!(user.name.is_none());
}
// ── HTTP mock: list repos ────────────────────────────────────────────────
#[tokio::test]
async fn test_github_list_repos_success() {
let repos = mock_list_repos(
200,
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
)
.await
.unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "my-repo");
}
#[tokio::test]
async fn test_github_list_repos_empty() {
let repos = mock_list_repos(200, "[]").await.unwrap();
assert!(repos.is_empty());
}
#[tokio::test]
async fn test_github_list_repos_auth_error() {
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error"));
}
#[tokio::test]
async fn test_github_list_repos_paginated() {
let mut server = mockito::Server::new_async().await;
let repos_page1: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"name": format!("repo-{}", i),
"full_name": format!("user/repo-{}", i),
"description": null,
"private": false,
"clone_url": format!("https://github.com/user/repo-{}.git", i),
"html_url": format!("https://github.com/user/repo-{}", i),
"updated_at": null
})
})
.collect();
let mock1 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&repos_page1).unwrap())
.create_async()
.await;
let mock2 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
)
.create_async()
.await;
let repos = github_list_repos_with_base("token", &server.url())
.await
.unwrap();
mock1.assert_async().await;
mock2.assert_async().await;
assert_eq!(repos.len(), 101);
assert_eq!(repos[100].name, "extra-repo");
}
// ── HTTP mock: create repo ───────────────────────────────────────────────
#[tokio::test]
async fn test_github_create_repo_success() {
let repo = mock_create_repo(
201,
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
)
.await
.unwrap();
assert_eq!(repo.name, "new-repo");
assert!(repo.private);
}
#[tokio::test]
async fn test_github_create_repo_name_exists() {
let err = mock_create_repo(
422,
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
)
.await
.unwrap_err();
assert!(err.contains("Repository name already exists"));
}
#[tokio::test]
async fn test_github_create_repo_server_error() {
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 500"));
}
// ── HTTP mock: device flow start ─────────────────────────────────────────
#[tokio::test]
async fn test_github_device_flow_start_success() {
let start = mock_device_start(
200,
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
)
.await
.unwrap();
assert_eq!(
start,
DeviceFlowStart {
device_code: "dev_abc".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
}
);
}
#[tokio::test]
async fn test_github_device_flow_start_error() {
let err = mock_device_start(400, "bad request").await.unwrap_err();
assert!(err.contains("Device flow start failed"));
}
#[tokio::test]
async fn test_github_device_flow_start_404_gives_clear_message() {
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
.await
.unwrap_err();
assert!(err.contains("device flow not available"));
assert!(err.contains("Device authorization flow"));
}
// ── HTTP mock: device flow poll ──────────────────────────────────────────
#[tokio::test]
async fn test_github_device_flow_poll_complete() {
let poll =
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
.await;
assert_eq!(
poll,
DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_secret123".to_string()),
error: None,
}
);
}
#[tokio::test]
async fn test_github_device_flow_poll_pending() {
let poll = mock_poll(
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
)
.await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("authorization_pending".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_slow_down() {
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("slow_down".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_expired() {
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
assert_eq!(poll.status, "expired");
assert_eq!(poll.error, Some("expired_token".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_other_error() {
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("access_denied".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_http_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/login/oauth/access_token")
.with_status(503)
.with_body("Service Unavailable")
.create_async()
.await;
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap_err();
mock.assert_async().await;
assert!(err.contains("Device flow poll HTTP error"));
}
#[tokio::test]
async fn test_github_device_flow_poll_unknown_error() {
let poll = mock_poll(r#"{}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("unknown".to_string()));
}
// ── HTTP mock: get user ──────────────────────────────────────────────────
#[tokio::test]
async fn test_github_get_user_success() {
let user = mock_user(
200,
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
)
.await
.unwrap();
assert_eq!(
user,
GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
}
);
}
#[tokio::test]
async fn test_github_get_user_unauthorized() {
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 401"));
}
#[tokio::test]
async fn test_github_get_user_null_name() {
let user = mock_user(
200,
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
)
.await
.unwrap();
assert_eq!(user.login, "bot-account");
assert!(user.name.is_none());
}
}

326
src-tauri/src/github/api.rs Normal file
View File

@@ -0,0 +1,326 @@
use super::{GitHubUser, GithubRepo};
/// Lists the authenticated user's GitHub repositories.
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
github_list_repos_with_base(token, "https://api.github.com").await
}
async fn github_list_repos_with_base(
token: &str,
api_base: &str,
) -> Result<Vec<GithubRepo>, String> {
let client = reqwest::Client::new();
let mut all_repos: Vec<GithubRepo> = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{}/user/repos?per_page=100&sort=updated&page={}",
api_base, page
);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
let repos: Vec<GithubRepo> = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
let count = repos.len();
all_repos.extend(repos);
if count < 100 {
break;
}
page += 1;
if page > 10 {
break; // safety limit: 1000 repos max
}
}
Ok(all_repos)
}
/// Creates a new GitHub repository for the authenticated user.
pub async fn github_create_repo(
token: &str,
name: &str,
private: bool,
) -> Result<GithubRepo, String> {
github_create_repo_with_base(token, name, private, "https://api.github.com").await
}
async fn github_create_repo_with_base(
token: &str,
name: &str,
private: bool,
api_base: &str,
) -> Result<GithubRepo, String> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"name": name,
"private": private,
"auto_init": true,
"description": "Laputa vault"
});
let response = client
.post(format!("{}/user/repos", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 422 && body.contains("name already exists") {
return Err("Repository name already exists on your account".to_string());
}
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GithubRepo>()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
}
/// Gets the authenticated GitHub user's profile.
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
github_get_user_with_base(token, "https://api.github.com").await
}
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/user", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub user request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GitHubUser>()
.await
.map_err(|e| format!("Failed to parse user response: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
async fn mock_json(
method: &str,
path: &str,
status: usize,
body: &str,
) -> (mockito::ServerGuard, mockito::Mock) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
let (server, mock) = mock_json(
"GET",
"/user/repos?per_page=100&sort=updated&page=1",
status,
body,
)
.await;
let result = github_list_repos_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
let (server, mock) = mock_json("GET", "/user", status, body).await;
let result = github_get_user_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
#[tokio::test]
async fn test_github_list_repos_success() {
let repos = mock_list_repos(
200,
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
)
.await
.unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "my-repo");
}
#[tokio::test]
async fn test_github_list_repos_empty() {
let repos = mock_list_repos(200, "[]").await.unwrap();
assert!(repos.is_empty());
}
#[tokio::test]
async fn test_github_list_repos_auth_error() {
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error"));
}
#[tokio::test]
async fn test_github_list_repos_paginated() {
let mut server = mockito::Server::new_async().await;
let repos_page1: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"name": format!("repo-{}", i),
"full_name": format!("user/repo-{}", i),
"description": null,
"private": false,
"clone_url": format!("https://github.com/user/repo-{}.git", i),
"html_url": format!("https://github.com/user/repo-{}", i),
"updated_at": null
})
})
.collect();
let mock1 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&repos_page1).unwrap())
.create_async()
.await;
let mock2 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
)
.create_async()
.await;
let repos = github_list_repos_with_base("token", &server.url())
.await
.unwrap();
mock1.assert_async().await;
mock2.assert_async().await;
assert_eq!(repos.len(), 101);
assert_eq!(repos[100].name, "extra-repo");
}
#[tokio::test]
async fn test_github_create_repo_success() {
let repo = mock_create_repo(
201,
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
)
.await
.unwrap();
assert_eq!(repo.name, "new-repo");
assert!(repo.private);
}
#[tokio::test]
async fn test_github_create_repo_name_exists() {
let err = mock_create_repo(
422,
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
)
.await
.unwrap_err();
assert!(err.contains("Repository name already exists"));
}
#[tokio::test]
async fn test_github_create_repo_server_error() {
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 500"));
}
#[tokio::test]
async fn test_github_get_user_success() {
let user = mock_user(
200,
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
)
.await
.unwrap();
assert_eq!(
user,
GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
}
);
}
#[tokio::test]
async fn test_github_get_user_unauthorized() {
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 401"));
}
#[tokio::test]
async fn test_github_get_user_null_name() {
let user = mock_user(
200,
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
)
.await
.unwrap();
assert_eq!(user.login, "bot-account");
assert!(user.name.is_none());
}
}

View File

@@ -0,0 +1,242 @@
use serde::Deserialize;
use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID};
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github_device_flow_start_with_base("https://github.com").await
}
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/device/code", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
.send()
.await
.map_err(|e| format!("Device flow request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 404 {
return Err(
"GitHub device flow not available. Ensure a GitHub App is registered with \
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
.to_string(),
);
}
return Err(format!("Device flow start failed ({}): {}", status, body));
}
response
.json::<DeviceFlowStart>()
.await
.map_err(|e| format!("Failed to parse device flow response: {}", e))
}
/// Polls GitHub for the device flow authorization result.
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
github_device_flow_poll_with_base(device_code, "https://github.com").await
}
async fn github_device_flow_poll_with_base(
device_code: &str,
base_url: &str,
) -> Result<DeviceFlowPollResult, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/oauth/access_token", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| format!("Device flow poll failed: {}", e))?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Device flow poll HTTP error: {}", body));
}
#[derive(Deserialize)]
struct RawResponse {
access_token: Option<String>,
error: Option<String>,
}
let raw: RawResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
if let Some(token) = raw.access_token {
Ok(DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some(token),
error: None,
})
} else {
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
let status = match error.as_str() {
"authorization_pending" | "slow_down" => "pending",
"expired_token" => "expired",
_ => "error",
};
Ok(DeviceFlowPollResult {
status: status.to_string(),
access_token: None,
error: Some(error),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn mock_json(
method: &str,
path: &str,
status: usize,
body: &str,
) -> (mockito::ServerGuard, mockito::Mock) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
let result = github_device_flow_start_with_base(&server.url()).await;
mock.assert_async().await;
result
}
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap();
mock.assert_async().await;
result
}
#[tokio::test]
async fn test_github_device_flow_start_success() {
let start = mock_device_start(
200,
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
)
.await
.unwrap();
assert_eq!(
start,
DeviceFlowStart {
device_code: "dev_abc".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
}
);
}
#[tokio::test]
async fn test_github_device_flow_start_error() {
let err = mock_device_start(400, "bad request").await.unwrap_err();
assert!(err.contains("Device flow start failed"));
}
#[tokio::test]
async fn test_github_device_flow_start_404_gives_clear_message() {
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
.await
.unwrap_err();
assert!(err.contains("device flow not available"));
assert!(err.contains("Device authorization flow"));
}
#[tokio::test]
async fn test_github_device_flow_poll_complete() {
let poll =
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
.await;
assert_eq!(
poll,
DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_secret123".to_string()),
error: None,
}
);
}
#[tokio::test]
async fn test_github_device_flow_poll_pending() {
let poll = mock_poll(
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
)
.await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("authorization_pending".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_slow_down() {
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("slow_down".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_expired() {
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
assert_eq!(poll.status, "expired");
assert_eq!(poll.error, Some("expired_token".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_other_error() {
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("access_denied".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_http_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/login/oauth/access_token")
.with_status(503)
.with_body("Service Unavailable")
.create_async()
.await;
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap_err();
mock.assert_async().await;
assert!(err.contains("Device flow poll HTTP error"));
}
#[tokio::test]
async fn test_github_device_flow_poll_unknown_error() {
let poll = mock_poll(r#"{}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("unknown".to_string()));
}
}

View File

@@ -0,0 +1,203 @@
use std::path::Path;
use std::process::Command;
/// Clones a GitHub repo to a local path using HTTPS + token auth.
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
if dest.exists()
&& dest
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false)
{
return Err(format!(
"Destination '{}' already exists and is not empty",
local_path
));
}
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
let auth_url = inject_token_into_url(url, token)?;
let output = Command::new("git")
.args(["clone", "--progress", &auth_url, local_path])
.output()
.map_err(|e| format!("Failed to run git clone: {}", e))?;
if !output.status.success() {
// Clean up partial clone on failure
if dest.exists() {
let _ = std::fs::remove_dir_all(dest);
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {}", stderr));
}
// Configure the remote to use token auth for future pushes
configure_remote_auth(local_path, url, token)?;
Ok(format!("Cloned to {}", local_path))
}
/// Injects an OAuth token into an HTTPS GitHub URL.
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
if let Some(rest) = url.strip_prefix("https://github.com/") {
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
} else if let Some(rest) = url.strip_prefix("https://") {
// Handle URLs that already have a host
Ok(format!("https://oauth2:{}@{}", token, rest))
} else {
Err(format!(
"Unsupported URL format: {}. Use an HTTPS URL.",
url
))
}
}
/// Sets up the git remote to use token-based HTTPS auth.
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
let auth_url = inject_token_into_url(original_url, token)?;
let vault = Path::new(local_path);
let output = Command::new("git")
.args(["remote", "set-url", "origin", &auth_url])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to configure remote: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to set remote URL: {}", stderr));
}
// Also configure git user if not set
let _ = Command::new("git")
.args(["config", "user.email", "laputa@app.local"])
.current_dir(vault)
.output();
let _ = Command::new("git")
.args(["config", "user.name", "Laputa App"])
.current_dir(vault)
.output();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
fn clone_err_contains(url: &str, expected: &str) {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("dest");
let result = clone_repo(url, "token", dest.to_str().unwrap());
assert!(result.unwrap_err().contains(expected));
}
#[test]
fn test_inject_token_basic_github_url() {
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
assert_eq!(
result.unwrap(),
"https://oauth2:gho_abc123@github.com/user/repo.git"
);
}
#[test]
fn test_inject_token_generic_https_url() {
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
assert_eq!(
result.unwrap(),
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
);
}
#[test]
fn test_inject_token_ssh_url_rejected() {
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
assert!(err.contains("Unsupported URL format"));
}
#[test]
fn test_inject_token_http_url_rejected() {
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
}
#[test]
fn test_inject_token_github_without_dot_git() {
let result = inject_token_into_url("https://github.com/user/repo", "tok");
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
}
#[test]
fn test_clone_repo_nonempty_dest() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
let result = clone_repo(
"https://github.com/test/repo.git",
"token",
dir.path().to_str().unwrap(),
);
assert!(result.unwrap_err().contains("not empty"));
}
#[test]
fn test_clone_repo_ssh_url_rejected() {
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
}
#[test]
fn test_clone_repo_empty_dest_allowed() {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("empty-dir");
std::fs::create_dir(&dest).unwrap();
let result = clone_repo(
"https://github.com/nonexistent/repo.git",
"token",
dest.to_str().unwrap(),
);
// Should fail at git clone, not at directory check
assert!(result.unwrap_err().contains("git clone failed"));
}
#[test]
fn test_configure_remote_auth_on_git_repo() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/user/repo.git",
])
.current_dir(path)
.output()
.unwrap();
configure_remote_auth(
path.to_str().unwrap(),
"https://github.com/user/repo.git",
"gho_test123",
)
.unwrap();
let output = StdCommand::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(path)
.output()
.unwrap();
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
}
}

139
src-tauri/src/github/mod.rs Normal file
View File

@@ -0,0 +1,139 @@
mod api;
mod auth;
mod clone;
use serde::{Deserialize, Serialize};
pub use api::{github_create_repo, github_get_user, github_list_repos};
pub use auth::{github_device_flow_poll, github_device_flow_start};
pub use clone::clone_repo;
/// GitHub App client ID for OAuth device flow.
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GithubRepo {
pub name: String,
pub full_name: String,
pub description: Option<String>,
pub private: bool,
pub clone_url: String,
pub html_url: String,
pub updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowStart {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowPollResult {
pub status: String,
pub access_token: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubUser {
pub login: String,
pub name: Option<String>,
pub avatar_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_github_repo_serialization_roundtrip() {
let repo = GithubRepo {
name: "test-repo".to_string(),
full_name: "user/test-repo".to_string(),
description: Some("A test repo".to_string()),
private: true,
clone_url: "https://github.com/user/test-repo.git".to_string(),
html_url: "https://github.com/user/test-repo".to_string(),
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
};
let json = serde_json::to_string(&repo).unwrap();
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
}
#[test]
fn test_github_repo_deserialization_null_fields() {
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
let repo: GithubRepo = serde_json::from_str(json).unwrap();
assert_eq!(repo.name, "r");
assert!(!repo.private);
assert!(repo.description.is_none());
assert!(repo.updated_at.is_none());
}
#[test]
fn test_device_flow_start_serialization_roundtrip() {
let start = DeviceFlowStart {
device_code: "dc_123".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
};
let json = serde_json::to_string(&start).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
start
);
}
#[test]
fn test_device_flow_poll_result_roundtrip() {
let complete = DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_abc123".to_string()),
error: None,
};
let json = serde_json::to_string(&complete).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
complete
);
let pending = DeviceFlowPollResult {
status: "pending".to_string(),
access_token: None,
error: Some("authorization_pending".to_string()),
};
let json = serde_json::to_string(&pending).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
pending
);
}
#[test]
fn test_github_user_serialization_roundtrip() {
let user = GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
};
let json = serde_json::to_string(&user).unwrap();
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
}
#[test]
fn test_github_user_deserialization_null_name() {
let user: GitHubUser =
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
.unwrap();
assert_eq!(user.login, "bot");
assert!(user.name.is_none());
}
}

View File

@@ -1,5 +1,6 @@
pub mod ai_chat;
pub mod claude_cli;
mod commands;
pub mod frontmatter;
pub mod git;
pub mod github;
@@ -13,544 +14,11 @@ pub mod vault;
pub mod vault_config;
pub mod vault_list;
use std::borrow::Cow;
use std::path::Path;
use std::process::Child;
use std::sync::Mutex;
use ai_chat::{AiChatRequest, AiChatResponse};
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
use frontmatter::FrontmatterValue;
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use indexing::{IndexStatus, IndexingProgress};
use search::SearchResponse;
use settings::Settings;
use theme::{ThemeFile, VaultSettings};
use vault::{RenameResult, VaultEntry};
use vault_config::VaultConfig;
use vault_list::VaultList;
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
/// home directory cannot be determined.
fn expand_tilde(path: &str) -> Cow<'_, str> {
if path == "~" {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(home.to_string_lossy().into_owned());
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
}
}
Cow::Borrowed(path)
}
#[tauri::command]
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::scan_vault_cached(Path::new(path.as_ref()))
}
#[tauri::command]
fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::get_note_content(Path::new(path.as_ref()))
}
#[tauri::command]
fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
#[tauri::command]
fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_history(&vault_path, &path)
}
#[tauri::command]
fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_modified_files(&vault_path)
}
#[tauri::command]
fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff(&vault_path, &path)
}
#[tauri::command]
fn get_file_diff_at_commit(
vault_path: String,
path: String,
commit_hash: String,
) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
fn get_vault_pulse(vault_path: String, limit: Option<usize>) -> Result<Vec<PulseCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let limit = limit.unwrap_or(30);
git::get_vault_pulse(&vault_path, limit)
}
#[tauri::command]
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit(&vault_path, &message)
}
fn parse_build_label(version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
match parts.as_slice() {
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
[_, _, _] => "dev".to_string(),
_ => "b?".to_string(),
}
}
#[tauri::command]
fn get_build_number(app_handle: tauri::AppHandle) -> String {
let version = app_handle.package_info().version.to_string();
parse_build_label(&version)
}
#[tauri::command]
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_last_commit_info(&vault_path)
}
#[tauri::command]
fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_pull(&vault_path)
}
#[tauri::command]
fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_files(&vault_path)
}
#[tauri::command]
fn get_conflict_mode(vault_path: String) -> String {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_mode(&vault_path)
}
#[tauri::command]
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[tauri::command]
fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit_conflict_resolution(&vault_path)
}
#[tauri::command]
fn git_push(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
}
#[tauri::command]
async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
ai_chat::send_chat(request).await
}
#[tauri::command]
fn check_claude_cli() -> ClaudeCliStatus {
claude_cli::check_cli()
}
#[tauri::command]
async fn stream_claude_chat(
app_handle: tauri::AppHandle,
request: ChatStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
async fn stream_claude_agent(
app_handle: tauri::AppHandle,
request: AgentStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-agent-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
#[tauri::command]
fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title)
}
#[tauri::command]
fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
vault::purge_trash(&vault_path)
}
#[tauri::command]
fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = match target_path {
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
};
vault::create_getting_started_vault(&path)
}
#[tauri::command]
fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
#[tauri::command]
fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(
&path,
"Trashed at",
FrontmatterValue::String(now.clone()),
)?;
count += 1;
}
Ok(count)
}
#[tauri::command]
fn update_menu_state(
app_handle: tauri::AppHandle,
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
if let Some(v) = has_modified_files {
menu::set_git_commit_items_enabled(&app_handle, v);
}
if let Some(v) = has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
Ok(())
}
#[tauri::command]
fn get_settings() -> Result<Settings, String> {
settings::get_settings()
}
#[tauri::command]
fn save_settings(settings: Settings) -> Result<(), String> {
settings::save_settings(settings)
}
#[tauri::command]
fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
}
#[tauri::command]
async fn github_create_repo(
token: String,
name: String,
private: bool,
) -> Result<GithubRepo, String> {
github::github_create_repo(&token, &name, private).await
}
#[tauri::command]
fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
github::clone_repo(&url, &token, &local_path)
}
#[tauri::command]
async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github::github_device_flow_start().await
}
#[tauri::command]
async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
github::github_device_flow_poll(&device_code).await
}
#[tauri::command]
async fn github_get_user(token: String) -> Result<GitHubUser, String> {
github::github_get_user(&token).await
}
#[tauri::command]
async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
fn emit_unavailable(app_handle: &tauri::AppHandle) {
use tauri::Emitter;
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,
error: Some("qmd not available".to_string()),
},
);
}
#[tauri::command]
async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if indexing::find_qmd_binary().is_none() {
log::info!("qmd binary not found — attempting auto-install via bun");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::try_auto_install_qmd() {
Ok(()) if indexing::find_qmd_binary().is_some() => {
log::info!("qmd auto-installed successfully, proceeding with indexing");
}
Ok(()) => {
log::warn!("qmd auto-install reported success but binary still not found");
emit_unavailable(&app_handle);
return Err("qmd not available after install".to_string());
}
Err(e) => {
log::info!("qmd auto-install failed: {e}");
emit_unavailable(&app_handle);
return Err(format!("qmd not available: {e}"));
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
struct WsBridgeChild(Mutex<Option<Child>>);
#[tauri::command]
async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || mcp::register_mcp(&vault_path))
.await
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[tauri::command]
async fn check_mcp_status() -> Result<mcp::McpStatus, String> {
tokio::task::spawn_blocking(mcp::check_mcp_status)
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[tauri::command]
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
theme::list_themes(&vault_path)
}
#[tauri::command]
fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_theme(&vault_path, &theme_id)
}
#[tauri::command]
fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_vault_settings(&vault_path)
}
#[tauri::command]
fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::save_vault_settings(&vault_path, settings)
}
#[tauri::command]
fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, theme_id.as_deref())
}
#[tauri::command]
fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_theme(&vault_path, source_id.as_deref())
}
#[tauri::command]
fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_vault_theme(&vault_path, name.as_deref())
}
#[tauri::command]
fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::ensure_vault_themes(&vault_path)
}
#[tauri::command]
fn restore_default_themes(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::restore_default_themes(&vault_path)
}
#[tauri::command]
fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
let vault_path = expand_tilde(&vault_path);
vault_config::get_vault_config(&vault_path)
}
#[tauri::command]
fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
vault_config::save_vault_config(&vault_path, config)
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -589,61 +57,6 @@ fn run_startup_tasks() {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_with_subpath() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~/Documents/vault");
assert_eq!(result, format!("{}/Documents/vault", home.display()));
}
#[test]
fn expand_tilde_alone() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~");
assert_eq!(result, home.to_string_lossy());
}
#[test]
fn expand_tilde_noop_for_absolute_path() {
let result = expand_tilde("/usr/local/bin");
assert_eq!(result, "/usr/local/bin");
}
#[test]
fn expand_tilde_noop_for_relative_path() {
let result = expand_tilde("some/relative/path");
assert_eq!(result, "some/relative/path");
}
#[test]
fn expand_tilde_noop_for_tilde_in_middle() {
let result = expand_tilde("/home/~user/path");
assert_eq!(result, "/home/~user/path");
}
#[test]
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
}
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
@@ -670,11 +83,6 @@ pub fn run() {
.level(log::LevelFilter::Info)
.build(),
)?;
// Open devtools automatically in debug builds
// use tauri::Manager;
// if let Some(window) = app.get_webview_window("main") {
// window.open_devtools();
// }
}
app.handle().plugin(tauri_plugin_dialog::init())?;
@@ -693,68 +101,68 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
list_vault,
get_note_content,
save_note_content,
update_frontmatter,
delete_frontmatter_property,
rename_note,
get_file_history,
get_modified_files,
get_file_diff,
get_file_diff_at_commit,
get_vault_pulse,
git_commit,
get_build_number,
get_last_commit_info,
git_pull,
git_push,
get_conflict_files,
get_conflict_mode,
git_resolve_conflict,
git_commit_conflict_resolution,
ai_chat,
check_claude_cli,
stream_claude_chat,
stream_claude_agent,
save_image,
copy_image_to_vault,
purge_trash,
delete_note,
migrate_is_a_to_type,
batch_archive_notes,
batch_trash_notes,
get_settings,
update_menu_state,
save_settings,
load_vault_list,
save_vault_list,
github_list_repos,
github_create_repo,
clone_repo,
github_device_flow_start,
github_device_flow_poll,
github_get_user,
search_vault,
get_index_status,
start_indexing,
trigger_incremental_index,
create_getting_started_vault,
check_vault_exists,
get_default_vault_path,
register_mcp_tools,
check_mcp_status,
list_themes,
get_theme,
get_vault_settings,
save_vault_settings,
set_active_theme,
create_theme,
create_vault_theme,
ensure_vault_themes,
restore_default_themes,
get_vault_config,
save_vault_config
commands::list_vault,
commands::get_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
commands::get_file_diff_at_commit,
commands::get_vault_pulse,
commands::git_commit,
commands::get_build_number,
commands::get_last_commit_info,
commands::git_pull,
commands::git_push,
commands::get_conflict_files,
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::ai_chat,
commands::check_claude_cli,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::save_image,
commands::copy_image_to_vault,
commands::purge_trash,
commands::delete_note,
commands::migrate_is_a_to_type,
commands::batch_archive_notes,
commands::batch_trash_notes,
commands::get_settings,
commands::update_menu_state,
commands::save_settings,
commands::load_vault_list,
commands::save_vault_list,
commands::github_list_repos,
commands::github_create_repo,
commands::clone_repo,
commands::github_device_flow_start,
commands::github_device_flow_poll,
commands::github_get_user,
commands::search_vault,
commands::get_index_status,
commands::start_indexing,
commands::trigger_incremental_index,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::check_mcp_status,
commands::list_themes,
commands::get_theme,
commands::get_vault_settings,
commands::save_vault_settings,
commands::set_active_theme,
commands::create_theme,
commands::create_vault_theme,
commands::ensure_vault_themes,
commands::restore_default_themes,
commands::get_vault_config,
commands::save_vault_config
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
use std::fs;
use std::path::Path;
use super::defaults::DEFAULT_VAULT_THEME_VARS;
/// Create a new vault theme note in `theme/` directory.
/// Returns the absolute path to the newly created theme note.
pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String, String> {
let theme_dir = Path::new(vault_path).join("theme");
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
let display_name = name.unwrap_or("Untitled Theme");
let slug = slugify(display_name);
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
let path = theme_dir.join(&filename);
let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS);
fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?;
Ok(path.to_string_lossy().to_string())
}
/// Create a new theme file by copying the active theme (or default).
/// Returns the ID of the new theme.
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
let themes_dir = Path::new(vault_path).join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let new_id = find_available_stem(&themes_dir, "untitled", "json");
let source = source_id.unwrap_or("default");
let source_path = themes_dir.join(format!("{source}.json"));
let content = if source_path.exists() {
let mut theme: serde_json::Value = serde_json::from_str(
&fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read source theme: {e}"))?,
)
.map_err(|e| format!("Failed to parse source theme: {e}"))?;
if let Some(obj) = theme.as_object_mut() {
obj.insert(
"name".to_string(),
serde_json::Value::String("Untitled Theme".to_string()),
);
}
serde_json::to_string_pretty(&theme)
.map_err(|e| format!("Failed to serialize new theme: {e}"))?
} else {
default_theme_json("Untitled Theme")
};
fs::write(themes_dir.join(format!("{new_id}.json")), content)
.map_err(|e| format!("Failed to write new theme: {e}"))?;
Ok(new_id)
}
/// Convert a display name to a URL-safe slug.
fn slugify(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Find an available filename stem (base, base-2, base-3, …) that doesn't
/// conflict when `ext` is appended.
fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
if !dir.join(format!("{base}.{ext}")).exists() {
return base.to_string();
}
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.{ext}")).exists() {
return candidate;
}
}
unreachable!()
}
/// Build a vault theme note markdown string from a name and CSS variable map.
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
for (key, value) in vars {
if value.contains('#') || value.contains('\'') || value.contains(',') {
fm.push_str(&format!("{key}: \"{value}\"\n"));
} else {
fm.push_str(&format!("{key}: {value}\n"));
}
}
fm.push_str("---\n\n");
fm.push_str(&format!(
"# {name} Theme\n\nA custom {name} theme for Laputa.\n"
));
fm
}
/// Generate the default light theme JSON.
fn default_theme_json(name: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"name": name,
"description": "Custom theme",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"sidebar-background": "#F7F6F3",
"accent": "#155DFF",
"muted": "#787774",
"border": "#E9E9E7"
},
"typography": {
"font-family": "system-ui",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "240px"
}
}))
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::defaults::*;
use crate::theme::get_theme;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_create_theme_copies_source() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let new_id = create_theme(&vault, Some("default")).unwrap();
assert_eq!(new_id, "untitled");
let theme = get_theme(&vault, &new_id).unwrap();
assert_eq!(theme.name, "Untitled Theme");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_create_theme_increments_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let id1 = create_theme(&vault, None).unwrap();
assert_eq!(id1, "untitled");
let id2 = create_theme(&vault, None).unwrap();
assert_eq!(id2, "untitled-2");
}
#[test]
fn test_create_vault_theme_creates_md_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, Some("My Theme")).unwrap();
assert!(std::path::Path::new(&path).exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("# My Theme"));
assert!(content.contains("background:"));
}
#[test]
fn test_create_vault_theme_default_name() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, None).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# Untitled Theme"));
}
#[test]
fn test_create_vault_theme_avoids_conflicts() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let p1 = create_vault_theme(vp, Some("Custom")).unwrap();
let p2 = create_vault_theme(vp, Some("Custom")).unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_slugify() {
assert_eq!(slugify("My Cool Theme"), "my-cool-theme");
assert_eq!(slugify("default"), "default");
assert_eq!(slugify("Dark Mode!"), "dark-mode");
}
}

View File

@@ -0,0 +1,331 @@
/// Content for the built-in default (light) theme.
pub const DEFAULT_THEME: &str = r##"{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in dark theme.
pub const DARK_THEME: &str = r##"{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in minimal theme.
pub const MINIMAL_THEME: &str = r##"{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}"##;
/// CSS variable key-value pairs for the default light vault theme.
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
// shadcn/ui base
("background", "#FFFFFF"),
("foreground", "#37352F"),
("card", "#FFFFFF"),
("popover", "#FFFFFF"),
("primary", "#155DFF"),
("primary-foreground", "#FFFFFF"),
("secondary", "#EBEBEA"),
("secondary-foreground", "#37352F"),
("muted", "#F0F0EF"),
("muted-foreground", "#787774"),
("accent", "#EBEBEA"),
("accent-foreground", "#37352F"),
("destructive", "#E03E3E"),
("border", "#E9E9E7"),
("input", "#E9E9E7"),
("ring", "#155DFF"),
("sidebar", "#F7F6F3"),
("sidebar-foreground", "#37352F"),
("sidebar-border", "#E9E9E7"),
("sidebar-accent", "#EBEBEA"),
// Text hierarchy
("text-primary", "#37352F"),
("text-secondary", "#787774"),
("text-muted", "#B4B4B4"),
("text-heading", "#37352F"),
// Backgrounds
("bg-primary", "#FFFFFF"),
("bg-sidebar", "#F7F6F3"),
("bg-hover", "#EBEBEA"),
("bg-hover-subtle", "#F0F0EF"),
("bg-selected", "#E8F4FE"),
("border-primary", "#E9E9E7"),
// Accent colours
("accent-blue", "#155DFF"),
("accent-green", "#00B38B"),
("accent-orange", "#D9730D"),
("accent-red", "#E03E3E"),
("accent-purple", "#A932FF"),
("accent-yellow", "#F0B100"),
("accent-blue-light", "#155DFF14"),
("accent-green-light", "#00B38B14"),
("accent-purple-light", "#A932FF14"),
("accent-red-light", "#E03E3E14"),
("accent-yellow-light", "#F0B10014"),
// Typography
(
"font-family",
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
),
("font-size-base", "14px"),
// Editor
("editor-font-size", "16"),
("editor-line-height", "1.5"),
("editor-max-width", "720"),
];
/// Vault-based theme note for the built-in Default theme.
pub const DEFAULT_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Light theme with warm, paper-like tones\n\
background: \"#FFFFFF\"\n\
foreground: \"#37352F\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#EBEBEA\"\n\
secondary-foreground: \"#37352F\"\n\
muted: \"#F0F0EF\"\n\
muted-foreground: \"#787774\"\n\
accent: \"#EBEBEA\"\n\
accent-foreground: \"#37352F\"\n\
destructive: \"#E03E3E\"\n\
border: \"#E9E9E7\"\n\
input: \"#E9E9E7\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#F7F6F3\"\n\
sidebar-foreground: \"#37352F\"\n\
sidebar-border: \"#E9E9E7\"\n\
sidebar-accent: \"#EBEBEA\"\n\
text-primary: \"#37352F\"\n\
text-secondary: \"#787774\"\n\
text-muted: \"#B4B4B4\"\n\
text-heading: \"#37352F\"\n\
bg-primary: \"#FFFFFF\"\n\
bg-sidebar: \"#F7F6F3\"\n\
bg-hover: \"#EBEBEA\"\n\
bg-hover-subtle: \"#F0F0EF\"\n\
bg-selected: \"#E8F4FE\"\n\
border-primary: \"#E9E9E7\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#E03E3E\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF14\"\n\
accent-green-light: \"#00B38B14\"\n\
accent-purple-light: \"#A932FF14\"\n\
accent-red-light: \"#E03E3E14\"\n\
accent-yellow-light: \"#F0B10014\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Default Theme\n\
\n\
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
/// Vault-based theme note for the built-in Dark theme.
pub const DARK_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Dark variant with deep navy tones\n\
background: \"#0f0f1a\"\n\
foreground: \"#e0e0e0\"\n\
card: \"#16162a\"\n\
popover: \"#1e1e3a\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#2a2a4a\"\n\
secondary-foreground: \"#e0e0e0\"\n\
muted: \"#1e1e3a\"\n\
muted-foreground: \"#888888\"\n\
accent: \"#2a2a4a\"\n\
accent-foreground: \"#e0e0e0\"\n\
destructive: \"#f44336\"\n\
border: \"#2a2a4a\"\n\
input: \"#2a2a4a\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#1a1a2e\"\n\
sidebar-foreground: \"#e0e0e0\"\n\
sidebar-border: \"#2a2a4a\"\n\
sidebar-accent: \"#2a2a4a\"\n\
text-primary: \"#e0e0e0\"\n\
text-secondary: \"#888888\"\n\
text-muted: \"#666666\"\n\
text-heading: \"#e0e0e0\"\n\
bg-primary: \"#0f0f1a\"\n\
bg-sidebar: \"#1a1a2e\"\n\
bg-hover: \"#2a2a4a\"\n\
bg-hover-subtle: \"#1e1e3a\"\n\
bg-selected: \"#155DFF22\"\n\
border-primary: \"#2a2a4a\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#f44336\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF33\"\n\
accent-green-light: \"#00B38B33\"\n\
accent-purple-light: \"#A932FF33\"\n\
accent-red-light: \"#f4433633\"\n\
accent-yellow-light: \"#F0B10033\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Dark Theme\n\
\n\
A dark theme with deep navy tones for comfortable night-time reading.\n";
/// Vault-based theme note for the built-in Minimal theme.
pub const MINIMAL_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: High contrast, minimal chrome\n\
background: \"#FAFAFA\"\n\
foreground: \"#111111\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#000000\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#F0F0F0\"\n\
secondary-foreground: \"#111111\"\n\
muted: \"#F5F5F5\"\n\
muted-foreground: \"#666666\"\n\
accent: \"#F0F0F0\"\n\
accent-foreground: \"#111111\"\n\
destructive: \"#CC0000\"\n\
border: \"#E0E0E0\"\n\
input: \"#E0E0E0\"\n\
ring: \"#000000\"\n\
sidebar: \"#F5F5F5\"\n\
sidebar-foreground: \"#111111\"\n\
sidebar-border: \"#E0E0E0\"\n\
sidebar-accent: \"#E8E8E8\"\n\
text-primary: \"#111111\"\n\
text-secondary: \"#666666\"\n\
text-muted: \"#999999\"\n\
text-heading: \"#111111\"\n\
bg-primary: \"#FAFAFA\"\n\
bg-sidebar: \"#F5F5F5\"\n\
bg-hover: \"#EBEBEB\"\n\
bg-hover-subtle: \"#F5F5F5\"\n\
bg-selected: \"#00000014\"\n\
border-primary: \"#E0E0E0\"\n\
accent-blue: \"#000000\"\n\
accent-green: \"#006600\"\n\
accent-orange: \"#996600\"\n\
accent-red: \"#CC0000\"\n\
accent-purple: \"#660099\"\n\
accent-yellow: \"#996600\"\n\
accent-blue-light: \"#00000014\"\n\
accent-green-light: \"#00660014\"\n\
accent-purple-light: \"#66009914\"\n\
accent-red-light: \"#CC000014\"\n\
accent-yellow-light: \"#99660014\"\n\
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
font-size-base: 13px\n\
editor-font-size: 15\n\
editor-line-height: 1.6\n\
editor-max-width: 680\n\
---\n\
\n\
# Minimal Theme\n\
\n\
High contrast, minimal chrome. Monospace typography throughout.\n";

268
src-tauri/src/theme/mod.rs Normal file
View File

@@ -0,0 +1,268 @@
mod create;
pub mod defaults;
mod seed;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub use create::{create_theme, create_vault_theme};
pub use defaults::*;
pub use seed::{
ensure_vault_themes, restore_default_themes, seed_default_themes, seed_vault_themes,
};
/// A theme file parsed from _themes/*.json in the vault.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeFile {
/// Filename stem (e.g. "default" for _themes/default.json)
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub colors: HashMap<String, String>,
#[serde(default)]
pub typography: HashMap<String, String>,
#[serde(default)]
pub spacing: HashMap<String, String>,
}
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultSettings {
#[serde(default)]
pub theme: Option<String>,
}
/// List all theme files in _themes/ directory of the vault.
/// Seeds built-in themes if the directory is missing.
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
let themes_dir = Path::new(vault_path).join("_themes");
if !themes_dir.is_dir() {
seed_default_themes(vault_path);
}
if !themes_dir.is_dir() {
return Ok(Vec::new());
}
let mut themes = Vec::new();
let entries =
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match parse_theme_file(&path) {
Ok(theme) => themes.push(theme),
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
}
}
themes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(themes)
}
/// Parse a single theme JSON file.
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
let id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "Invalid theme filename".to_string())?;
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let mut theme: ThemeFile = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
theme.id = id;
Ok(theme)
}
/// Read vault-level settings from .laputa/settings.json.
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
if !settings_path.exists() {
return Ok(VaultSettings::default());
}
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
}
/// Save vault-level settings to .laputa/settings.json.
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
let laputa_dir = Path::new(vault_path).join(".laputa");
fs::create_dir_all(&laputa_dir)
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
let json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
fs::write(laputa_dir.join("settings.json"), json)
.map_err(|e| format!("Failed to write vault settings: {e}"))
}
/// Set the active theme in vault settings. Pass `None` to clear.
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
let mut settings = get_vault_settings(vault_path)?;
settings.theme = theme_id.map(|s| s.to_string());
save_vault_settings(vault_path, settings)
}
/// Read a single theme file by ID from the vault's _themes/ directory.
pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String> {
let path = Path::new(vault_path)
.join("_themes")
.join(format!("{theme_id}.json"));
if !path.exists() {
return Err(format!("Theme not found: {theme_id}"));
}
parse_theme_file(&path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_list_themes_returns_sorted_list() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
assert_eq!(themes[0].id, "dark");
assert_eq!(themes[1].id, "default");
}
#[test]
fn test_list_themes_seeds_defaults_when_no_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("empty-vault");
fs::create_dir_all(&vault).unwrap();
let themes = list_themes(vault.to_str().unwrap()).unwrap();
assert_eq!(themes.len(), 3);
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"Default"));
assert!(names.contains(&"Dark"));
assert!(names.contains(&"Minimal"));
}
#[test]
fn test_get_theme_by_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let theme = get_theme(&vault, "default").unwrap();
assert_eq!(theme.name, "Default");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_get_theme_not_found() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let result = get_theme(&vault, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_vault_settings_roundtrip() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let settings = get_vault_settings(vp).unwrap();
assert!(settings.theme.is_none());
set_active_theme(vp, Some("dark")).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme.as_deref(), Some("dark"));
set_active_theme(vp, None).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme, None);
}
#[test]
fn test_vault_settings_creates_laputa_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join(".laputa").exists());
save_vault_settings(
vp,
VaultSettings {
theme: Some("light".into()),
},
)
.unwrap();
assert!(vault.join(".laputa").join("settings.json").exists());
}
#[test]
fn test_parse_all_builtin_themes() {
for (name, content) in [
("default", DEFAULT_THEME),
("dark", DARK_THEME),
("minimal", MINIMAL_THEME),
] {
let theme: ThemeFile = serde_json::from_str(content)
.unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}"));
assert!(!theme.name.is_empty(), "{name} theme should have a name");
assert!(!theme.colors.is_empty(), "{name} theme should have colors");
}
}
#[test]
fn test_list_themes_ignores_non_json_files() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
fs::write(themes_dir.join(".DS_Store"), "").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
}
#[test]
fn test_list_themes_skips_malformed_json() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
}
#[test]
fn test_vault_theme_content_contains_all_vars() {
let content = DEFAULT_VAULT_THEME;
assert!(content.contains("background:"));
assert!(content.contains("primary:"));
assert!(content.contains("sidebar:"));
assert!(content.contains("text-primary:"));
assert!(content.contains("accent-blue:"));
assert!(content.contains("editor-font-size:"));
}
}

288
src-tauri/src/theme/seed.rs Normal file
View File

@@ -0,0 +1,288 @@
use std::fs;
use std::path::Path;
use super::defaults::*;
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
if dir.is_dir() {
return;
}
if fs::create_dir_all(dir).is_err() {
return;
}
for (name, content) in files {
let _ = fs::write(dir.join(name), content);
}
log::info!("{log_msg}");
}
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
/// Safe to call multiple times — only writes files that are missing.
pub fn seed_default_themes(vault_path: &str) {
seed_dir_with_files(
&Path::new(vault_path).join("_themes"),
&[
("default.json", DEFAULT_THEME),
("dark.json", DARK_THEME),
("minimal.json", MINIMAL_THEME),
],
"Seeded _themes/ with built-in themes",
);
}
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
/// Per-file idempotent: creates the directory if missing, writes each default
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
/// existing files that have content.
pub fn seed_vault_themes(vault_path: &str) {
let theme_dir = Path::new(vault_path).join("theme");
if fs::create_dir_all(&theme_dir).is_err() {
return;
}
let defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
];
let mut seeded = false;
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&path, content);
seeded = true;
}
}
if seeded {
log::info!("Seeded theme/ with built-in vault themes");
}
}
/// Ensure vault theme files exist. Returns an error if the theme directory
/// cannot be created (e.g. read-only filesystem).
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
let theme_dir = Path::new(vault_path).join("theme");
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
let defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
];
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
}
}
Ok(())
}
/// Restore default themes for a vault: seeds both `_themes/` (JSON) and
/// `theme/` (markdown notes). Per-file idempotent — never overwrites files
/// that already have content. Returns an error on read-only filesystems.
pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
// Seed _themes/ JSON files (per-file idempotent)
let themes_dir = Path::new(vault_path).join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let json_defaults: &[(&str, &str)] = &[
("default.json", DEFAULT_THEME),
("dark.json", DARK_THEME),
("minimal.json", MINIMAL_THEME),
];
for (name, content) in json_defaults {
let path = themes_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, content)
.map_err(|e| format!("Failed to write _themes/{name}: {e}"))?;
}
}
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
ensure_vault_themes(vault_path)?;
Ok("Default themes restored".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_seed_vault_themes_creates_theme_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join("theme").exists());
seed_vault_themes(vp);
assert!(vault.join("theme").is_dir());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
seed_vault_themes(vp); // second call should be a no-op
assert!(vault.join("theme").join("default.md").exists());
}
#[test]
fn test_seed_vault_themes_writes_missing_files_in_existing_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
assert!(theme_dir.join("dark.md").exists());
assert!(theme_dir.join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_seed_vault_themes_preserves_existing_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(
content.contains("#FF0000"),
"existing content must be preserved"
);
}
#[test]
fn test_ensure_vault_themes_creates_dir_and_defaults() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
assert!(vault.join("theme").is_dir());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
}
#[test]
fn test_ensure_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_ensure_vault_themes_preserves_custom_themes() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("#123456"));
}
#[test]
fn test_restore_default_themes_creates_both_dirs() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let msg = restore_default_themes(vp).unwrap();
assert_eq!(msg, "Default themes restored");
assert!(vault.join("_themes").join("default.json").exists());
assert!(vault.join("_themes").join("dark.json").exists());
assert!(vault.join("_themes").join("minimal.json").exists());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
}
#[test]
fn test_restore_default_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
restore_default_themes(vp).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n";
fs::write(vault.join("theme").join("default.md"), custom).unwrap();
restore_default_themes(vp).unwrap();
let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap();
assert!(
content.contains("#CUSTOM"),
"must not overwrite existing content"
);
}
#[test]
fn test_restore_default_themes_fills_partial_state() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
let theme_dir = vault.join("theme");
fs::create_dir_all(&themes_dir).unwrap();
fs::create_dir_all(&theme_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
let vp = vault.to_str().unwrap();
restore_default_themes(vp).unwrap();
assert!(themes_dir.join("dark.json").exists());
assert!(themes_dir.join("minimal.json").exists());
assert!(theme_dir.join("dark.md").exists());
assert!(theme_dir.join("minimal.md").exists());
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Light theme with warm"));
}
}