refactor: extract frontmatter.rs module from vault.rs with with_frontmatter() helper

This commit is contained in:
lucaronin
2026-02-17 12:05:39 +01:00
parent 9b4fe05883
commit c7b20fd938
5 changed files with 561 additions and 293 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"mcp__codescene__*",
"Read(*)",
"Bash(cat*)",
"Bash(ls*)",
"Write(*)"
]
}
}

261
CODE-HEALTH-REPORT.md Normal file
View File

@@ -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` (L452536) | 4 bumps of nested logic | High |
| Bumpy Road | `days_from_epoch` (L253285) | 3 bumps | High |
| Deep Nesting | `update_frontmatter_content` (L452536) | 4 levels of conditionals | High |
| Complex Method | `FrontmatterValue::to_yaml_value` (L371412) | cc = 17 | Medium |
| Complex Method | `update_frontmatter_content` (L452536) | cc = 16 | Medium |
| Complex Method | `days_from_epoch` (L253285) | cc = 14 | Medium |
| Complex Conditional | `to_yaml_value` (L375377) | 8 complex expressions | Medium |
| Complex Conditional | `format_yaml_key` (L565566) | 5 complex expressions | Medium |
| Complex Conditional | `days_from_epoch` (L255) | 3 complex expressions | Medium |
| Code Duplication | `update_frontmatter` / `delete_frontmatter_property` (L416449) | Near-identical functions | Medium |
| Large Method | `parse_md_file` (L101190) | 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 27% defect reduction and 13% speed improvement.
---
### 2. `src/components/Editor.tsx` — Score: 6.94
**Code Smells Found:**
| Smell | Location | Details | Severity |
|-------|----------|---------|----------|
| Deep Nesting | `expandWikilinksInContent` (L119146) | 4 levels of conditionals | High |
| Complex Method | `Editor` (L263540) | cc = 29 | Medium |
| Complex Method | `DiffView` (L148186) | cc = 13 | Medium |
| Complex Conditional | `DiffView:169` | 4 complex expressions | Medium |
| Complex Conditional | `expandWikilinksInContent:122` | 2 complex expressions | Medium |
| Large Method | `Editor` (L263540) | 265 LoC (limit: 120) | Medium |
| Overall Code Complexity | File-wide | High mean cyclomatic complexity | Medium |
**Business Case:** Improving to 9.1 yields 2740% defect reduction and 2132% 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` (L2188) | 2 bumps | High |
| Deep Nesting | `updateMockFrontmatter` (L2188) | 4 levels deep | High |
| Complex Method | `App` (L131591) | cc = 56 (**Brain Method**) | Medium |
| Complex Method | `updateMockFrontmatter` (L2188) | cc = 17 | Medium |
| Large Method | `App` (L131591) | 381 LoC (limit: 120, **3x over**) | Medium |
**Business Case:** Improving to 9.1 yields 2739% defect reduction and 2031% speed improvement.
---
### 4. `src/components/Inspector.tsx` — Score: 7.49
**Code Smells Found:**
| Smell | Location | Details | Severity |
|-------|----------|---------|----------|
| Bumpy Road | `parseFrontmatter` (L78150) | 2 bumps | High |
| Bumpy Road | `DynamicPropertiesPanel` (L441516) | 2 bumps | High |
| Complex Method | `parseFrontmatter` (L78150) | cc = 21 | Medium |
| Complex Method | `DynamicPropertiesPanel` (L441516) | cc = 18 | Medium |
| Complex Method | `renderEditableValue` (L527624) | cc = 16 | Medium |
| Complex Method | `Inspector` (L806912) | 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 2636% defect reduction and 1828% 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` (L2188) | **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` (L416449) | **Impact:** Eliminates code duplication smell
- Both functions share the same file-read + frontmatter-parse + write-back pattern
- Extract shared logic into a `with_frontmatter<F>(path, f: F)` helper
### 4. Flatten `expandWikilinksInContent`
**File:** `src/components/Editor.tsx` (L119146) | **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 45 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 34 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 | 2740% | 2132% |
| `App.tsx` | 7.13 | 9.1 | 2739% | 2031% |
| `Inspector.tsx` | 7.49 | 9.1 | 2636% | 1828% |
| `vault.rs` | 4.80 | 5.15* | 27% | 13% |
*\*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*

View File

@@ -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<String>),
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::<f64>().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::<Vec<_>>()
.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<FrontmatterValue>) -> Result<String, String> {
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<String> = 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<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)
}
#[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\"");
}
}

View File

@@ -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<Vec<VaultEntry>, String> {

View File

@@ -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<Vec<VaultEntry>, 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<String>),
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::<f64>().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::<Vec<_>>()
.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<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 = 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<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 = 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<FrontmatterValue>) -> Result<String, String> {
// 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<String> = 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
}