diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..fba3ea8c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "mcp__codescene__*", + "Read(*)", + "Bash(cat*)", + "Bash(ls*)", + "Write(*)" + ] + } +} diff --git a/CODE-HEALTH-REPORT.md b/CODE-HEALTH-REPORT.md new file mode 100644 index 00000000..9f6d96bd --- /dev/null +++ b/CODE-HEALTH-REPORT.md @@ -0,0 +1,261 @@ +# Code Health Report — Laputa App + +**Date:** 2026-02-17 +**Branch:** `experiment/shadcn-migration` +**Overall Project Score:** 8.34 / 10.0 (Yellow — approaching Green) +**Tool:** CodeScene Code Health Analysis (project ID: 76865) + +--- + +## Summary + +The Laputa App codebase scores **8.34** overall — solid but with four files in the "Yellow" zone (< 8.0) that are dragging down maintainability. One file (`vault.rs`) is in the **Red zone** at 4.8, representing the most critical technical debt. + +| Zone | Score Range | File Count | Description | +|------|------------|------------|-------------| +| Optimal | 10.0 | 4 | Perfect — optimized for human and AI comprehension | +| Green | 9.0 – 9.9 | 2 | High quality, minor issues only | +| Yellow | 4.0 – 8.9 | 4 | Problematic technical debt | +| Red | 1.0 – 3.9 | 0 | — | +| N/A | — | 2 | CSS files (unsupported by CodeScene) | + +--- + +## File-by-File Scores + +| File | LoC | Score | Zone | Key Issues | +|------|-----|-------|------|------------| +| `src-tauri/src/main.rs` | 6 | **10.0** | Optimal | None | +| `src-tauri/src/git.rs` | 423 | **10.0** | Optimal | None | +| `src/mock-tauri.ts` | 707 | **10.0** | Optimal | None | +| `src/components/StatusBar.tsx` | 62 | **10.0** | Optimal | None | +| `src-tauri/src/lib.rs` | 78 | **9.68** | Green | String-heavy function arguments (93%) | +| `src/components/QuickOpenPalette.tsx` | 145 | **9.55** | Green | Complex Method (cc=16) | +| `src/components/Sidebar.tsx` | 233 | **9.02** | Green | Complex Method (cc=25), Large Method (181 LoC) | +| `src/components/NoteList.tsx` | 339 | **8.11** | Yellow | Bumpy Road, Complex Method (cc=30), Large Method (166 LoC) | +| `src/components/Inspector.tsx` | 912 | **7.49** | Yellow | Bumpy Road (x2), Complex Methods (x4), Complex Conditionals | +| `src/App.tsx` | 593 | **7.13** | Yellow | Brain Method: App() at cc=56 / 381 LoC, Bumpy Road, Deep Nesting | +| `src/components/Editor.tsx` | 540 | **6.94** | Yellow | Deep Nesting, Complex Methods (cc=29), Large Method (265 LoC) | +| `src-tauri/src/vault.rs` | 815 | **4.80** | Yellow (near Red) | Bumpy Road (x2), Deep Nesting, Complex Methods (x3), Code Duplication, Large Method, String-heavy args | +| `src/App.css` | 48 | N/A | — | CSS not supported | +| `src/components/Editor.css` | 106 | N/A | — | CSS not supported | + +--- + +## Technical Debt Hotspots + +CodeScene reports **0 hotspots** from the project-level analysis (the project may need more commit history to identify change-frequency hotspots). However, based on the code health scores and file sizes, the **de facto hotspots** are: + +| Priority | File | Score | LoC | Risk Factor | +|----------|------|-------|-----|-------------| +| 1 | `src-tauri/src/vault.rs` | 4.80 | 815 | Largest Rust file, near-Red health, 8 distinct code smells | +| 2 | `src/components/Editor.tsx` | 6.94 | 540 | Core editor component, deep nesting, high complexity | +| 3 | `src/App.tsx` | 7.13 | 593 | Root component, Brain Method (cc=56, 381 LoC) | +| 4 | `src/components/Inspector.tsx` | 7.49 | 912 | Largest file in codebase, 4 complex methods | + +--- + +## Detailed Analysis — Files Scoring Below 8.0 + +### 1. `src-tauri/src/vault.rs` — Score: 4.80 (CRITICAL) + +This is the **worst-scoring file** in the codebase and the highest priority for refactoring. + +**Code Smells Found:** + +| Smell | Location | Details | Severity | +|-------|----------|---------|----------| +| Bumpy Road | `update_frontmatter_content` (L452–536) | 4 bumps of nested logic | High | +| Bumpy Road | `days_from_epoch` (L253–285) | 3 bumps | High | +| Deep Nesting | `update_frontmatter_content` (L452–536) | 4 levels of conditionals | High | +| Complex Method | `FrontmatterValue::to_yaml_value` (L371–412) | cc = 17 | Medium | +| Complex Method | `update_frontmatter_content` (L452–536) | cc = 16 | Medium | +| Complex Method | `days_from_epoch` (L253–285) | cc = 14 | Medium | +| Complex Conditional | `to_yaml_value` (L375–377) | 8 complex expressions | Medium | +| Complex Conditional | `format_yaml_key` (L565–566) | 5 complex expressions | Medium | +| Complex Conditional | `days_from_epoch` (L255) | 3 complex expressions | Medium | +| Code Duplication | `update_frontmatter` / `delete_frontmatter_property` (L416–449) | Near-identical functions | Medium | +| Large Method | `parse_md_file` (L101–190) | 78 LoC (limit: 70) | Medium | +| String-Heavy Args | Module-wide | 63% of args are strings | Medium | + +**Business Case:** Improving to industry average (5.15) yields 2–7% defect reduction and 1–3% speed improvement. + +--- + +### 2. `src/components/Editor.tsx` — Score: 6.94 + +**Code Smells Found:** + +| Smell | Location | Details | Severity | +|-------|----------|---------|----------| +| Deep Nesting | `expandWikilinksInContent` (L119–146) | 4 levels of conditionals | High | +| Complex Method | `Editor` (L263–540) | cc = 29 | Medium | +| Complex Method | `DiffView` (L148–186) | cc = 13 | Medium | +| Complex Conditional | `DiffView:169` | 4 complex expressions | Medium | +| Complex Conditional | `expandWikilinksInContent:122` | 2 complex expressions | Medium | +| Large Method | `Editor` (L263–540) | 265 LoC (limit: 120) | Medium | +| Overall Code Complexity | File-wide | High mean cyclomatic complexity | Medium | + +**Business Case:** Improving to 9.1 yields 27–40% defect reduction and 21–32% speed improvement. **This is the highest-ROI refactoring target.** + +--- + +### 3. `src/App.tsx` — Score: 7.13 + +**Code Smells Found:** + +| Smell | Location | Details | Severity | +|-------|----------|---------|----------| +| Bumpy Road | `updateMockFrontmatter` (L21–88) | 2 bumps | High | +| Deep Nesting | `updateMockFrontmatter` (L21–88) | 4 levels deep | High | +| Complex Method | `App` (L131–591) | cc = 56 (**Brain Method**) | Medium | +| Complex Method | `updateMockFrontmatter` (L21–88) | cc = 17 | Medium | +| Large Method | `App` (L131–591) | 381 LoC (limit: 120, **3x over**) | Medium | + +**Business Case:** Improving to 9.1 yields 27–39% defect reduction and 20–31% speed improvement. + +--- + +### 4. `src/components/Inspector.tsx` — Score: 7.49 + +**Code Smells Found:** + +| Smell | Location | Details | Severity | +|-------|----------|---------|----------| +| Bumpy Road | `parseFrontmatter` (L78–150) | 2 bumps | High | +| Bumpy Road | `DynamicPropertiesPanel` (L441–516) | 2 bumps | High | +| Complex Method | `parseFrontmatter` (L78–150) | cc = 21 | Medium | +| Complex Method | `DynamicPropertiesPanel` (L441–516) | cc = 18 | Medium | +| Complex Method | `renderEditableValue` (L527–624) | cc = 16 | Medium | +| Complex Method | `Inspector` (L806–912) | cc = 14 | Medium | +| Complex Conditional | `renderEditableValue:590` | 3 complex expressions | Medium | +| Complex Conditional | `parseFrontmatter:115` | 2 complex expressions | Medium | +| Overall Code Complexity | File-wide | High mean cyclomatic complexity | Medium | + +**Business Case:** Improving to 9.1 yields 26–36% defect reduction and 18–28% speed improvement. + +--- + +## Quick Wins (Low Effort, High Impact) + +These are targeted refactorings that can improve scores immediately: + +### 1. Extract `App` component state into custom hooks +**File:** `src/App.tsx` | **Impact:** cc 56 → ~10 per hook +- Extract `useVaultData()` — vault loading, entry management +- Extract `useEditorState()` — selected entry, editor content, undo/redo +- Extract `useKeyboardShortcuts()` — all keyboard event handlers +- Extract `useSearch()` — search/filter state and logic +- The `App` component becomes a thin composition layer + +### 2. Extract `updateMockFrontmatter` helper functions +**File:** `src/App.tsx` (L21–88) | **Impact:** Eliminates deep nesting + bumpy road +- Extract date-comparison logic into a helper +- Extract frontmatter field-update logic per field type +- Flatten conditionals with early returns + +### 3. Deduplicate `update_frontmatter` / `delete_frontmatter_property` +**File:** `src-tauri/src/vault.rs` (L416–449) | **Impact:** Eliminates code duplication smell +- Both functions share the same file-read + frontmatter-parse + write-back pattern +- Extract shared logic into a `with_frontmatter(path, f: F)` helper + +### 4. Flatten `expandWikilinksInContent` +**File:** `src/components/Editor.tsx` (L119–146) | **Impact:** Eliminates deep nesting +- Use early `continue` in the loop +- Extract inner matching logic into a `resolveWikilink()` helper + +### 5. Split `DynamicPropertiesPanel` into sub-components +**File:** `src/components/Inspector.tsx` | **Impact:** Reduces 4 complex methods to focused units +- Extract `EditableValue` component for `renderEditableValue` +- Extract `FrontmatterParser` utility for `parseFrontmatter` +- Extract `PropertyRow` component for individual property rendering + +--- + +## Larger Refactoring Plans + +### Plan A: Decompose `vault.rs` (Priority 1 — Score 4.80 → 8+) + +**Goal:** Raise from near-Red (4.80) to Green (9+) in 4–5 steps. + +| Step | Action | Target Smell | +|------|--------|--------------| +| 1 | Extract `frontmatter.rs` module — move `FrontmatterValue`, `to_yaml_value`, `update_frontmatter_content`, `format_yaml_key` | Reduces file size, isolates complexity | +| 2 | Introduce `with_frontmatter()` helper to deduplicate `update_frontmatter` / `delete_frontmatter_property` | Code Duplication | +| 3 | Refactor `update_frontmatter_content` — flatten nesting with early returns, extract `apply_field_update()` helper | Deep Nesting, Bumpy Road | +| 4 | Refactor `days_from_epoch` — use a lookup table or `chrono` crate helpers instead of manual conditionals | Bumpy Road, Complex Conditional | +| 5 | Replace string args with newtype wrappers (`VaultPath`, `FilePath`, `PropertyKey`) | String-Heavy Args | + +### Plan B: Decompose `Editor.tsx` (Priority 2 — Score 6.94 → 9+) + +**Goal:** Raise from Yellow (6.94) to Green (9+) in 3–4 steps. + +| Step | Action | Target Smell | +|------|--------|--------------| +| 1 | Extract `useEditorExtensions()` hook — all CodeMirror extension setup | Reduces Editor cc from 29 | +| 2 | Extract `useWikilinks()` hook — wikilink expansion, resolution | Deep Nesting, Complex Conditional | +| 3 | Extract `DiffView` into its own file `DiffView.tsx` | Complex Method (cc=13) | +| 4 | Extract `useEditorContent()` hook — content loading, saving, dirty state | Further reduces Editor complexity | + +### Plan C: Decompose `App.tsx` (Priority 3 — Score 7.13 → 9+) + +**Goal:** Break the Brain Method pattern. The `App` function at cc=56 and 381 LoC is the single worst function in the frontend. + +| Step | Action | Target Smell | +|------|--------|--------------| +| 1 | Extract `useVaultLoader()` — vault path selection, entry loading, refresh | Reduces App cc by ~15 | +| 2 | Extract `useNoteActions()` — create, delete, rename, move note | Reduces App cc by ~10 | +| 3 | Extract `useAppKeyboard()` — all keyboard shortcut handlers | Reduces App cc by ~8 | +| 4 | Extract `useFrontmatterSync()` — mock frontmatter update logic | Eliminates Bumpy Road + Deep Nesting | +| 5 | Move layout JSX into `AppLayout.tsx` presentational component | Reduces App to pure orchestration | + +### Plan D: Decompose `Inspector.tsx` (Priority 4 — Score 7.49 → 9+) + +**Goal:** The largest file (912 LoC) with 4 complex methods needs structural decomposition. + +| Step | Action | Target Smell | +|------|--------|--------------| +| 1 | Extract `parseFrontmatter()` to `src/utils/frontmatter.ts` shared utility | Complex Method (cc=21), Bumpy Road | +| 2 | Extract `EditableValue.tsx` component from `renderEditableValue` | Complex Method (cc=16) | +| 3 | Extract `DynamicPropertiesPanel.tsx` as standalone component | Bumpy Road, Complex Method (cc=18) | +| 4 | Simplify `Inspector` to compose sub-components | Reduces cc from 14 to ~5 | + +--- + +## Refactoring ROI Summary + +| File | Current | Target | Defect Reduction | Speed Improvement | +|------|---------|--------|------------------|-------------------| +| `Editor.tsx` | 6.94 | 9.1 | 27–40% | 21–32% | +| `App.tsx` | 7.13 | 9.1 | 27–39% | 20–31% | +| `Inspector.tsx` | 7.49 | 9.1 | 26–36% | 18–28% | +| `vault.rs` | 4.80 | 5.15* | 2–7% | 1–3% | + +*\*vault.rs target is conservative (industry average). A full refactoring to 9+ would yield dramatically higher ROI but requires more structural work.* + +--- + +## Recommended Execution Order + +1. **`vault.rs`** — Lowest score, near-Red zone. Foundational backend code that all frontmatter operations depend on. Fix this first to prevent defect accumulation. +2. **`Editor.tsx`** — Highest ROI. Core user-facing component where bugs are most visible. Decomposition into hooks is straightforward. +3. **`App.tsx`** — Brain Method must be broken up. The cc=56 function is a maintenance trap — any new feature added here increases risk nonlinearly. +4. **`Inspector.tsx`** — Largest file but lower priority because it's more self-contained. Decompose after the above three. +5. **`NoteList.tsx`** (8.11) and **`Sidebar.tsx`** (9.02) — minor improvements, address opportunistically. + +--- + +## Files in Good Shape + +These files need no immediate attention: + +- `src-tauri/src/main.rs` — 10.0 (6 LoC, clean entry point) +- `src-tauri/src/git.rs` — 10.0 (423 LoC, well-structured) +- `src/mock-tauri.ts` — 10.0 (707 LoC, clean mock layer) +- `src/components/StatusBar.tsx` — 10.0 (62 LoC, minimal component) +- `src-tauri/src/lib.rs` — 9.68 (minor: string-heavy args) +- `src/components/QuickOpenPalette.tsx` — 9.55 (minor: cc=16) + +--- + +*Report generated by CodeScene MCP analysis. For interactive exploration, visit: https://codescene.io/projects/76865* diff --git a/src-tauri/src/frontmatter.rs b/src-tauri/src/frontmatter.rs new file mode 100644 index 00000000..5e55059b --- /dev/null +++ b/src-tauri/src/frontmatter.rs @@ -0,0 +1,279 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +/// Value type for frontmatter updates +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum FrontmatterValue { + String(String), + Number(f64), + Bool(bool), + List(Vec), + Null, +} + +impl FrontmatterValue { + pub fn to_yaml_value(&self) -> String { + match self { + FrontmatterValue::String(s) => { + if s.contains(':') || s.contains('#') || s.contains('\n') || + s.starts_with('[') || s.starts_with('{') || + s == "true" || s == "false" || s == "null" || + s.parse::().is_ok() { + format!("\"{}\"", s.replace('\"', "\\\"")) + } else { + s.clone() + } + } + FrontmatterValue::Number(n) => { + if n.fract() == 0.0 { + format!("{}", *n as i64) + } else { + format!("{}", n) + } + } + FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(), + FrontmatterValue::List(items) => { + if items.is_empty() { + "[]".to_string() + } else { + items.iter() + .map(|item| { + let quoted = if item.contains(':') || item.starts_with('[') || item.starts_with('{') { + format!("\"{}\"", item.replace('\"', "\\\"")) + } else { + format!("\"{}\"", item) + }; + format!(" - {}", quoted) + }) + .collect::>() + .join("\n") + } + } + FrontmatterValue::Null => "null".to_string(), + } + } +} + +/// Format a key for YAML output (quote if necessary) +pub fn format_yaml_key(key: &str) -> String { + if key.contains(' ') || key.contains(':') || key.contains('#') || + key.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-') { + 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 +} + +/// Internal function to update frontmatter content +pub fn update_frontmatter_content(content: &str, key: &str, value: Option) -> Result { + if !content.starts_with("---\n") { + return match value { + Some(v) => { + let yaml_key = format_yaml_key(key); + let yaml_value = v.to_yaml_value(); + let fm = if yaml_value.contains('\n') { + format!("---\n{}:\n{}\n---\n", yaml_key, yaml_value) + } else { + format!("---\n{}: {}\n---\n", yaml_key, yaml_value) + }; + Ok(format!("{}{}", fm, content)) + } + None => Ok(content.to_string()), + }; + } + + let fm_end = content[4..].find("\n---") + .map(|i| i + 4) + .ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?; + + let fm_content = &content[4..fm_end]; + let rest = &content[fm_end + 4..]; + + let lines: Vec<&str> = fm_content.lines().collect(); + let mut new_lines: Vec = Vec::new(); + let mut found_key = false; + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + + if line_is_key(line, key) { + found_key = true; + i += 1; + while i < lines.len() && (lines[i].starts_with(" - ") || lines[i].trim().is_empty()) { + if lines[i].trim().is_empty() { + break; + } + i += 1; + } + + if let Some(ref v) = value { + let yaml_key = format_yaml_key(key); + let yaml_value = v.to_yaml_value(); + if yaml_value.contains('\n') { + new_lines.push(format!("{}:", yaml_key)); + new_lines.push(yaml_value); + } else { + new_lines.push(format!("{}: {}", yaml_key, yaml_value)); + } + } + continue; + } + + new_lines.push(line.to_string()); + i += 1; + } + + if !found_key { + if let Some(ref v) = value { + let yaml_key = format_yaml_key(key); + let yaml_value = v.to_yaml_value(); + if yaml_value.contains('\n') { + new_lines.push(format!("{}:", yaml_key)); + new_lines.push(yaml_value); + } else { + new_lines.push(format!("{}: {}", yaml_key, yaml_value)); + } + } + } + + let new_fm = new_lines.join("\n"); + Ok(format!("---\n{}\n---{}", new_fm, rest)) +} + +/// Helper to read a file, apply a frontmatter transformation, and write back. +pub fn with_frontmatter(path: &str, transform: F) -> Result +where + F: FnOnce(&str) -> Result, +{ + let file_path = Path::new(path); + if !file_path.exists() { + return Err(format!("File does not exist: {}", path)); + } + + let content = fs::read_to_string(file_path) + .map_err(|e| format!("Failed to read {}: {}", path, e))?; + + let updated = transform(&content)?; + + fs::write(file_path, &updated) + .map_err(|e| format!("Failed to write {}: {}", path, e))?; + + Ok(updated) +} + +#[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\""); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d9ef0d14..9be7d839 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ +pub mod frontmatter; pub mod git; pub mod vault; use git::{GitCommit, ModifiedFile}; -use vault::{VaultEntry, FrontmatterValue}; +use vault::VaultEntry; +use frontmatter::FrontmatterValue; #[tauri::command] fn list_vault(path: String) -> Result, String> { diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 9810fe0e..6bc94042 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -7,6 +7,8 @@ use std::path::Path; use std::time::UNIX_EPOCH; use walkdir::WalkDir; +use crate::frontmatter::{with_frontmatter, update_frontmatter_content}; + #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct VaultEntry { pub path: String, @@ -356,218 +358,17 @@ pub fn scan_vault(vault_path: &str) -> Result, String> { Ok(entries) } -/// Value type for frontmatter updates -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(untagged)] -pub enum FrontmatterValue { - String(String), - Number(f64), - Bool(bool), - List(Vec), - Null, -} - -impl FrontmatterValue { - fn to_yaml_value(&self) -> String { - match self { - FrontmatterValue::String(s) => { - // Quote strings that need it (contain special chars or look like other types) - if s.contains(':') || s.contains('#') || s.contains('\n') || - s.starts_with('[') || s.starts_with('{') || - s == "true" || s == "false" || s == "null" || - s.parse::().is_ok() { - format!("\"{}\"", s.replace('\"', "\\\"")) - } else { - s.clone() - } - } - FrontmatterValue::Number(n) => { - if n.fract() == 0.0 { - format!("{}", *n as i64) - } else { - format!("{}", n) - } - } - FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(), - FrontmatterValue::List(items) => { - if items.is_empty() { - "[]".to_string() - } else { - // Multi-line list format - items.iter() - .map(|item| { - let quoted = if item.contains(':') || item.starts_with('[') || item.starts_with('{') { - format!("\"{}\"", item.replace('\"', "\\\"")) - } else { - format!("\"{}\"", item) - }; - format!(" - {}", quoted) - }) - .collect::>() - .join("\n") - } - } - FrontmatterValue::Null => "null".to_string(), - } - } -} +// Re-export for external consumers +pub use crate::frontmatter::FrontmatterValue; /// Update a single frontmatter property in a markdown file pub fn update_frontmatter(path: &str, key: &str, value: FrontmatterValue) -> Result { - let file_path = Path::new(path); - if !file_path.exists() { - return Err(format!("File does not exist: {}", path)); - } - - let content = fs::read_to_string(file_path) - .map_err(|e| format!("Failed to read {}: {}", path, e))?; - - let updated = update_frontmatter_content(&content, key, Some(value))?; - - fs::write(file_path, &updated) - .map_err(|e| format!("Failed to write {}: {}", path, e))?; - - Ok(updated) + 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 { - 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 = update_frontmatter_content(&content, key, None)?; - - fs::write(file_path, &updated) - .map_err(|e| format!("Failed to write {}: {}", path, e))?; - - Ok(updated) -} - -/// Internal function to update frontmatter content -fn update_frontmatter_content(content: &str, key: &str, value: Option) -> Result { - // Check if file has frontmatter - if !content.starts_with("---\n") { - // No frontmatter - add it if we're setting a value - return match value { - Some(v) => { - let yaml_key = format_yaml_key(key); - let yaml_value = v.to_yaml_value(); - let fm = if yaml_value.contains('\n') { - format!("---\n{}:\n{}\n---\n", yaml_key, yaml_value) - } else { - format!("---\n{}: {}\n---\n", yaml_key, yaml_value) - }; - Ok(format!("{}{}", fm, content)) - } - None => Ok(content.to_string()), // Nothing to delete - }; - } - - // Find the end of frontmatter - let fm_end = content[4..].find("\n---") - .map(|i| i + 4) - .ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?; - - let fm_content = &content[4..fm_end]; - let rest = &content[fm_end + 4..]; // Skip the closing "---" - - // Parse frontmatter line by line, preserving structure - let lines: Vec<&str> = fm_content.lines().collect(); - let mut new_lines: Vec = Vec::new(); - let mut found_key = false; - let mut i = 0; - - while i < lines.len() { - let line = lines[i]; - - // Check if this line is our target key - if line_is_key(line, key) { - found_key = true; - - // Skip this key and any list items that follow - i += 1; - while i < lines.len() && (lines[i].starts_with(" - ") || lines[i].trim().is_empty()) { - if lines[i].trim().is_empty() { - break; - } - i += 1; - } - - // Add the updated value (if not deleting) - if let Some(ref v) = value { - let yaml_key = format_yaml_key(key); - let yaml_value = v.to_yaml_value(); - if yaml_value.contains('\n') { - new_lines.push(format!("{}:", yaml_key)); - new_lines.push(yaml_value); - } else { - new_lines.push(format!("{}: {}", yaml_key, yaml_value)); - } - } - continue; - } - - new_lines.push(line.to_string()); - i += 1; - } - - // If key wasn't found and we're adding, append it - if !found_key { - if let Some(ref v) = value { - let yaml_key = format_yaml_key(key); - let yaml_value = v.to_yaml_value(); - if yaml_value.contains('\n') { - new_lines.push(format!("{}:", yaml_key)); - new_lines.push(yaml_value); - } else { - new_lines.push(format!("{}: {}", yaml_key, yaml_value)); - } - } - } - - // Rebuild the file - let new_fm = new_lines.join("\n"); - Ok(format!("---\n{}\n---{}", new_fm, rest)) -} - -/// 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(); - - // Check unquoted: `key:` - if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') { - return true; - } - - // Check double-quoted: `"key":` - let dq = format!("\"{}\":", key); - if trimmed.starts_with(&dq) { - return true; - } - - // Check single-quoted: `'key':` - let sq = format!("'{}\':", key); - if trimmed.starts_with(&sq) { - return true; - } - - false -} - -/// Format a key for YAML output (quote if necessary) -fn format_yaml_key(key: &str) -> String { - // Quote keys that contain spaces or special characters - if key.contains(' ') || key.contains(':') || key.contains('#') || - key.chars().any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-') { - format!("\"{}\"", key) - } else { - key.to_string() - } + with_frontmatter(path, |content| update_frontmatter_content(content, key, None)) } #[cfg(test)] @@ -725,91 +526,5 @@ This is a project note. assert!(result.is_err()); } - #[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")); // Original key preserved - } - - #[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")); // Other keys preserved - } - - #[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")); // Other key preserved - } - - #[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\""); - } + // Frontmatter update/delete tests are in frontmatter.rs }