From e3dc38e58e0400f4bf3fb252f3295f5965a628d0 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 15 Feb 2026 13:45:24 +0100 Subject: [PATCH] Fix tauri.conf.json: remove invalid titleBarStyle overlay --- CLAUDE.md | 113 ++++++++- e2e/visual-verify.spec.ts | 103 +++++++++ src-tauri/src/vault.rs | 476 +++++++++++++++++++++++++++++++++++++- src-tauri/tauri.conf.json | 3 +- 4 files changed, 685 insertions(+), 10 deletions(-) create mode 100644 e2e/visual-verify.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index a0339d37..b27dded5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,16 +54,22 @@ Laputa App is a personal knowledge and life management desktop app, built with T - Error handling: don't silently swallow errors. Log them, surface them, or return Result types (Rust) ### Visual Verification (MANDATORY) -Before declaring any milestone or feature complete, you MUST visually verify it works: +Before declaring any milestone or feature complete, you MUST visually verify it works. +**You must manually test every feature via Chrome (`claude --chrome`):** 1. **Start the dev server**: `pnpm dev` (Vite only, no Tauri needed) -2. **Run Playwright screenshot**: `npx playwright test e2e/screenshot.spec.ts` -3. **Check the screenshot** at `test-results/app-screenshot.png` — does it look right? Are notes showing? Is the layout correct? -4. **Interact and verify**: Write a Playwright test that clicks, navigates, and screenshots the result +2. **Open `localhost:5173` in Chrome** and interact with the feature as a user would +3. **Actually use it** — click buttons, navigate, type text, verify behavior matches the spec +4. **Don't just screenshot** — interact end-to-end. If something looks wrong, fix it before declaring done. +5. **If mock data doesn't cover the feature**, update `src/mock-tauri.ts` with appropriate test data first -The app has a **Tauri mock layer** (`src/mock-tauri.ts`): when running in a browser (not Tauri), it returns realistic test data. This means Playwright and Chrome can test the full UI without the Rust backend. +Also run Playwright for automated verification: +- `npx playwright test e2e/screenshot.spec.ts` — captures screenshots +- Write ad-hoc Playwright tests that click, navigate, and screenshot results -**Key rule**: passing unit tests ≠ working app. If you can't see it working in a screenshot, it's not done. +The app has a **Tauri mock layer** (`src/mock-tauri.ts`): when running in a browser (not Tauri), it returns realistic test data. This means Chrome and Playwright can test the full UI without the Rust backend. + +**Key rule**: passing unit tests ≠ working app. If you can't see it working AND interact with it successfully, it's not done. ### Playwright for Testing & Verification - `npx playwright test` — runs all E2E tests @@ -76,3 +82,98 @@ The app has a **Tauri mock layer** (`src/mock-tauri.ts`): when running in a brow - Use Context7 MCP to look up current API docs (Tauri v2, CodeMirror 6, etc.) - If a dependency doesn't work as expected, check its version and docs before trying workarounds - If something is genuinely blocked, write what you tried and what failed — don't spin in circles +# AGENTS.md + +## Agent TL;DR + +- **Code Health is authoritative.** Treat it as the single source of truth for maintainability. +- **Target Code Health 10.0.** This is the standard for AI-friendly code. 9+ is not “good enough.” +- **Safeguard all AI-touched code** before suggesting a commit. +- If Code Health regresses or violates goals, **refactor — don’t declare done.** +- Use Code Health to guide **incremental, high-impact refactorings.** +- When in doubt, **call the appropriate CodeScene MCP tool — don’t guess.** + +--- + +# Core Use Cases + +## 1️⃣ Safeguard All AI-Generated or Modified Code (Mandatory) + +For any AI-touched code: + +1. Run `pre_commit_code_health_safeguard`. +2. Run `code_health_review` for detailed analysis if the safeguard reports a regression. +3. If Code Health regresses or fails quality gates: + - Highlight the issue. + - Refactor before suggesting commit. + - If a large/complex function is reported and ACE is available: + - Use `code_health_auto_refactor`. + - Then refine incrementally. + - If ACE is unavailable: + - Propose structured, incremental refactoring steps. +4. Do **not** mark changes as ready unless risks are explicitly accepted. + +--- + +## 2️⃣ Guide Refactoring with Code Health (Preferred via ACE) + +When refactoring or improving code: + +1. Inspect with `code_health_review`. +2. Identify complexity, size, coupling, or other code health issues. +3. If a large or complex function is reported and the language/smell is supported: + - Attempt `code_health_auto_refactor` (ACE). + - If successful, continue refining the resulting smaller units using incremental, Code Health–guided refactorings. + - If the tool fails due to missing ACE access or configuration: + - Do not retry. + - Continue with manual, incremental refactoring guided by Code Health. +4. Refactor in **3–5 small, reviewable steps**. +5. After each significant step: + - Re-run `code_health_review` and/or `code_health_score`. + - Confirm measurable improvement or no regression. + +ACE is optional. Refactoring must always proceed, with or without ACE. + +--- + +# Technical Debt & Prioritization + +When asked what to improve: + +- Use `list_technical_debt_hotspots`. +- Use `list_technical_debt_goals`. +- Use `code_health_score` to rank risk. +- Optionally use `code_health_refactoring_business_case` to quantify ROI. + +Always produce: +- The ranked list of hotspots. +- Small, incremental refactor plans. +- Business justification when relevant. + +--- + +# Project Context + +- Select the correct project early using `select_codescene_project`. +- Assume all subsequent tool calls operate within the active project. + +--- + +# Explanation & Education + +When users ask why Code Health matters: + +- Use `explain_code_health` for fundamentals. +- Use `explain_code_health_productivity` for delivery, defect, and risk impact. +- Tie explanations to actual project data when possible. + +--- + +# Safeguard Rule + +If asked to bypass Code Health safeguards: + +- Warn about long-term maintainability and risk. +- Keep changes minimal and reversible. +- Recommend follow-up refactoring. + diff --git a/e2e/visual-verify.spec.ts b/e2e/visual-verify.spec.ts new file mode 100644 index 00000000..2db500a9 --- /dev/null +++ b/e2e/visual-verify.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test' + +test('visual verify: editor theme + list indentation', async ({ page }) => { + const consoleErrors: string[] = [] + page.on('console', msg => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + }) + + await page.goto('http://localhost:5173') + await page.waitForTimeout(1000) + + // Open "Write Weekly Essays" which has bullets, nested items, checkboxes + const noteItem = page.locator('.note-list__item', { hasText: 'Write Weekly Essays' }) + await noteItem.click() + await page.waitForTimeout(1000) + + const cmEditor = page.locator('.cm-editor') + await expect(cmEditor).toBeVisible() + + // No console errors + expect(consoleErrors).toHaveLength(0) + + // --- BUG 2: Verify list indentation --- + + // Level-0 bullets should have 40px padding-left + const level0Lines = page.locator('.cm-line.cm-live-list-level-0') + const level0Count = await level0Lines.count() + console.log(`Level-0 bullet lines: ${level0Count}`) + expect(level0Count).toBeGreaterThan(0) + + const paddingL0 = await level0Lines.first().evaluate(el => + window.getComputedStyle(el).paddingLeft + ) + console.log(`Level-0 padding-left: ${paddingL0}`) + expect(parseInt(paddingL0)).toBe(40) + + // Bullet widgets and checkboxes are rendered + const bulletCount = await page.locator('.cm-live-bullet').count() + console.log(`Bullet widgets: ${bulletCount}`) + expect(bulletCount).toBeGreaterThan(0) + + const checkboxCount = await page.locator('.cm-live-checkbox').count() + console.log(`Checkbox widgets: ${checkboxCount}`) + expect(checkboxCount).toBeGreaterThan(0) + + // Screenshot dark mode (top) + await page.screenshot({ path: 'test-results/01-dark-mode-editor.png', fullPage: true }) + + // Scroll down to see nested items section + const scroller = page.locator('.cm-scroller') + await scroller.evaluate(el => el.scrollTop = el.scrollHeight) + await page.waitForTimeout(300) + await page.screenshot({ path: 'test-results/01b-dark-mode-nested.png', fullPage: true }) + + // --- BUG 1: Verify theme toggle --- + + // Dark mode: editor bg should be dark + const darkBg = await cmEditor.evaluate(el => + window.getComputedStyle(el).backgroundColor + ) + console.log(`Dark mode bg: ${darkBg}`) + expect(darkBg).toBe('rgb(15, 15, 26)') + + // Toggle to light mode + const themeToggle = page.locator('.sidebar__theme-toggle') + await themeToggle.click() + await page.waitForTimeout(500) + + // Scroll back to top for light mode screenshot + await scroller.evaluate(el => el.scrollTop = 0) + await page.waitForTimeout(200) + await page.screenshot({ path: 'test-results/02-light-mode-editor.png', fullPage: true }) + + // Light mode: editor bg should be white + const lightBg = await cmEditor.evaluate(el => + window.getComputedStyle(el).backgroundColor + ) + console.log(`Light mode bg: ${lightBg}`) + expect(lightBg).toBe('rgb(255, 255, 255)') + + // Heading color should be dark in light mode + const headingColor = await page.locator('.cm-live-heading').first().evaluate(el => + window.getComputedStyle(el).color + ) + console.log(`Light mode heading color: ${headingColor}`) + expect(headingColor).toBe('rgb(55, 53, 47)') + + // Scroll to nested items in light mode + await scroller.evaluate(el => el.scrollTop = el.scrollHeight) + await page.waitForTimeout(300) + await page.screenshot({ path: 'test-results/02b-light-mode-nested.png', fullPage: true }) + + // Toggle back to dark mode + await themeToggle.click() + await page.waitForTimeout(500) + await page.screenshot({ path: 'test-results/03-dark-mode-restored.png', fullPage: true }) + + const restoredBg = await cmEditor.evaluate(el => + window.getComputedStyle(el).backgroundColor + ) + console.log(`Restored dark mode bg: ${restoredBg}`) + expect(restoredBg).toBe('rgb(15, 15, 26)') +}) diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 1c46ae80..29389e48 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -24,6 +24,8 @@ pub struct VaultEntry { pub cadence: Option, #[serde(rename = "modifiedAt")] pub modified_at: Option, + #[serde(rename = "createdAt")] + pub created_at: Option, #[serde(rename = "fileSize")] pub file_size: u64, } @@ -32,7 +34,7 @@ pub struct VaultEntry { #[derive(Debug, Deserialize, Default)] struct Frontmatter { #[serde(rename = "Is A")] - is_a: Option, + is_a: Option, #[serde(default)] aliases: Option, #[serde(rename = "Belongs to")] @@ -45,6 +47,10 @@ struct Frontmatter { owner: Option, #[serde(rename = "Cadence")] cadence: Option, + #[serde(rename = "Created at")] + created_at: Option, + #[serde(rename = "Created time")] + created_time: Option, } /// Handles YAML fields that can be either a single string or a list of strings. @@ -129,11 +135,48 @@ pub fn parse_md_file(path: &Path) -> Result { .map(|d| d.as_secs()); let file_size = metadata.len(); + // Extract is_a from frontmatter, or infer from parent folder name + let is_a = frontmatter.is_a + .map(|a| a.into_vec().into_iter().next()) + .flatten() + .or_else(|| { + path.parent() + .and_then(|p| p.file_name()) + .map(|f| { + let folder = f.to_string_lossy().to_string(); + // Map folder names to entity types + match folder.as_str() { + "person" => "Person".to_string(), + "project" => "Project".to_string(), + "procedure" => "Procedure".to_string(), + "responsibility" => "Responsibility".to_string(), + "event" => "Event".to_string(), + "topic" => "Topic".to_string(), + "experiment" => "Experiment".to_string(), + "note" => "Note".to_string(), + "quarter" => "Quarter".to_string(), + "measure" => "Measure".to_string(), + "target" => "Target".to_string(), + "journal" => "Journal".to_string(), + "month" => "Month".to_string(), + "essay" => "Essay".to_string(), + "evergreen" => "Evergreen".to_string(), + _ => capitalize_first(&folder), + } + }) + }); + + // Parse created_at from frontmatter (prefer "Created at" over "Created time") + let created_at = frontmatter.created_at + .as_ref() + .and_then(|s| parse_iso_date(s)) + .or_else(|| frontmatter.created_time.as_ref().and_then(|s| parse_iso_date(s))); + Ok(VaultEntry { path: path.to_string_lossy().to_string(), filename, title, - is_a: frontmatter.is_a, + is_a, aliases: frontmatter.aliases.map(|a| a.into_vec()).unwrap_or_default(), belongs_to: frontmatter.belongs_to.map(|b| b.into_vec()).unwrap_or_default(), related_to: frontmatter.related_to.map(|r| r.into_vec()).unwrap_or_default(), @@ -141,10 +184,106 @@ pub fn parse_md_file(path: &Path) -> Result { owner: frontmatter.owner, cadence: frontmatter.cadence, modified_at, + created_at, file_size, }) } +fn capitalize_first(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch) +/// Handles formats like "2025-05-23T14:35:00.000Z" +fn parse_iso_date(date_str: &str) -> Option { + // Try parsing as ISO 8601 with chrono-style manual parsing + // Format: "YYYY-MM-DDTHH:MM:SS.sssZ" or "YYYY-MM-DD" + let trimmed = date_str.trim().trim_matches('"'); + + // Try full datetime format + if let Some(t_pos) = trimmed.find('T') { + let date_part = &trimmed[..t_pos]; + let time_part = trimmed[t_pos + 1..].trim_end_matches('Z'); + + let date_parts: Vec<&str> = date_part.split('-').collect(); + if date_parts.len() != 3 { + return None; + } + + let year: i32 = date_parts[0].parse().ok()?; + let month: u32 = date_parts[1].parse().ok()?; + let day: u32 = date_parts[2].parse().ok()?; + + // Parse time part (may have milliseconds) + let time_no_ms = time_part.split('.').next()?; + let time_parts: Vec<&str> = time_no_ms.split(':').collect(); + if time_parts.len() < 2 { + return None; + } + + let hour: u32 = time_parts[0].parse().ok()?; + let min: u32 = time_parts[1].parse().ok()?; + let sec: u32 = time_parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + + // Convert to Unix timestamp (simplified calculation) + // Days from epoch (1970-01-01) to the given date + let days_since_epoch = days_from_epoch(year, month, day)?; + let seconds = days_since_epoch as u64 * 86400 + hour as u64 * 3600 + min as u64 * 60 + sec as u64; + return Some(seconds); + } + + // Try date-only format + let date_parts: Vec<&str> = trimmed.split('-').collect(); + if date_parts.len() == 3 { + let year: i32 = date_parts[0].parse().ok()?; + let month: u32 = date_parts[1].parse().ok()?; + let day: u32 = date_parts[2].parse().ok()?; + let days_since_epoch = days_from_epoch(year, month, day)?; + return Some(days_since_epoch as u64 * 86400); + } + + None +} + +/// Calculate days since Unix epoch (1970-01-01) +fn days_from_epoch(year: i32, month: u32, day: u32) -> Option { + // Simplified calculation - not accounting for all edge cases + if month < 1 || month > 12 || day < 1 || day > 31 { + return None; + } + + // Days in each month (non-leap year) + let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + + let is_leap = |y: i32| (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0); + + // Days from 1970 to start of year + let mut total_days: i64 = 0; + for y in 1970..year { + total_days += if is_leap(y) { 366 } else { 365 }; + } + for y in (year..1970).rev() { + total_days -= if is_leap(y) { 366 } else { 365 }; + } + + // Days in current year up to start of month + for m in 1..month { + total_days += days_in_month[m as usize] as i64; + if m == 2 && is_leap(year) { + total_days += 1; + } + } + + // Add days in current month + total_days += (day - 1) as i64; + + Some(total_days) +} + /// Convert gray_matter::Pod to serde_json::Value fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value { match pod { @@ -166,6 +305,19 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value { } } +/// Read the content of a single note file. +pub fn get_note_content(path: &str) -> Result { + let file_path = Path::new(path); + if !file_path.exists() { + return Err(format!("File does not exist: {}", path)); + } + if !file_path.is_file() { + return Err(format!("Path is not a file: {}", path)); + } + fs::read_to_string(file_path) + .map_err(|e| format!("Failed to read {}: {}", path, e)) +} + /// Scan a directory recursively for .md files and return VaultEntry for each. pub fn scan_vault(vault_path: &str) -> Result, String> { let path = Path::new(vault_path); @@ -204,6 +356,220 @@ 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(), + } + } +} + +/// 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) +} + +/// 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() + } +} + #[cfg(test)] mod tests { use super::*; @@ -340,4 +706,110 @@ This is a project note. // Should still succeed — gray_matter may parse partially or skip assert!(entry.is_ok()); } + + #[test] + fn test_get_note_content() { + let dir = TempDir::new().unwrap(); + let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world!"; + create_test_file(dir.path(), "test.md", content); + + let path = dir.path().join("test.md"); + let result = get_note_content(path.to_str().unwrap()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), content); + } + + #[test] + fn test_get_note_content_nonexistent() { + let result = get_note_content("/nonexistent/path/file.md"); + 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\""); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3a507c1b..8bd19905 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,8 +18,7 @@ "height": 900, "resizable": true, "fullscreen": false, - "decorations": false, - "titleBarStyle": "overlay" + "decorations": false } ], "security": {