Initialize Tauri v2 + React + TypeScript project with vault scanner

- Scaffold Vite + React + TypeScript frontend
- Add Tauri v2 backend with Rust
- Implement list_vault command: recursively scans .md files, parses
  YAML frontmatter (Is A, aliases, Belongs to, Related to, Status,
  Owner, Cadence), extracts title from H1 or filename
- 10 passing Rust tests for frontmatter parsing and vault scanning
- Configure Vitest and Playwright (test infrastructure)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-14 18:20:07 +01:00
commit 6b53cf8f50
44 changed files with 9192 additions and 0 deletions

26
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,26 @@
pub mod vault;
use vault::VaultEntry;
#[tauri::command]
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
vault::scan_vault(&path)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.invoke_handler(tauri::generate_handler![list_vault])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
laputa_lib::run();
}

343
src-tauri/src/vault.rs Normal file
View File

@@ -0,0 +1,343 @@
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct VaultEntry {
pub path: String,
pub filename: String,
pub title: String,
#[serde(rename = "isA")]
pub is_a: Option<String>,
pub aliases: Vec<String>,
#[serde(rename = "belongsTo")]
pub belongs_to: Vec<String>,
#[serde(rename = "relatedTo")]
pub related_to: Vec<String>,
pub status: Option<String>,
pub owner: Option<String>,
pub cadence: Option<String>,
#[serde(rename = "modifiedAt")]
pub modified_at: Option<u64>,
#[serde(rename = "fileSize")]
pub file_size: u64,
}
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
#[serde(rename = "Is A")]
is_a: Option<String>,
#[serde(default)]
aliases: Option<StringOrList>,
#[serde(rename = "Belongs to")]
belongs_to: Option<StringOrList>,
#[serde(rename = "Related to")]
related_to: Option<StringOrList>,
#[serde(rename = "Status")]
status: Option<String>,
#[serde(rename = "Owner")]
owner: Option<String>,
#[serde(rename = "Cadence")]
cadence: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
enum StringOrList {
Single(String),
List(Vec<String>),
}
impl StringOrList {
fn into_vec(self) -> Vec<String> {
match self {
StringOrList::Single(s) => vec![s],
StringOrList::List(v) => v,
}
}
}
/// Extract the title from a markdown file's content.
/// Tries the first H1 heading (`# Title`), falls back to filename without extension.
fn extract_title(content: &str, filename: &str) -> String {
for line in content.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed.strip_prefix("# ") {
let title = heading.trim();
if !title.is_empty() {
return title.to_string();
}
}
}
// Fallback: filename without .md extension
filename.strip_suffix(".md").unwrap_or(filename).to_string()
}
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
// Convert HashMap to serde_json::Value for deserialization
let value = serde_json::Value::Object(
data.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
serde_json::from_value(value).unwrap_or_default()
}
/// Parse a single markdown file into a VaultEntry.
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
let frontmatter: Frontmatter = if let Some(data) = parsed.data {
match data {
gray_matter::Pod::Hash(map) => {
// Convert Pod HashMap to serde_json HashMap
let json_map: HashMap<String, serde_json::Value> = map
.into_iter()
.map(|(k, v)| (k, pod_to_json(v)))
.collect();
parse_frontmatter(&json_map)
}
_ => Frontmatter::default(),
}
} else {
Frontmatter::default()
};
let title = extract_title(&parsed.content, &filename);
let metadata = fs::metadata(path).map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
let modified_at = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let file_size = metadata.len();
Ok(VaultEntry {
path: path.to_string_lossy().to_string(),
filename,
title,
is_a: frontmatter.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(),
status: frontmatter.status,
owner: frontmatter.owner,
cadence: frontmatter.cadence,
modified_at,
file_size,
})
}
/// Convert gray_matter::Pod to serde_json::Value
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
gray_matter::Pod::String(s) => serde_json::Value::String(s),
gray_matter::Pod::Integer(i) => serde_json::json!(i),
gray_matter::Pod::Float(f) => serde_json::json!(f),
gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b),
gray_matter::Pod::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> = map
.into_iter()
.map(|(k, v)| (k, pod_to_json(v)))
.collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
}
}
/// Scan a directory recursively for .md files and return VaultEntry for each.
pub fn scan_vault(vault_path: &str) -> Result<Vec<VaultEntry>, String> {
let path = Path::new(vault_path);
if !path.exists() {
return Err(format!("Vault path does not exist: {}", vault_path));
}
if !path.is_dir() {
return Err(format!("Vault path is not a directory: {}", vault_path));
}
let mut entries = Vec::new();
for entry in WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let entry_path = entry.path();
if entry_path.is_file()
&& entry_path
.extension()
.map(|ext| ext == "md")
.unwrap_or(false)
{
match parse_md_file(entry_path) {
Ok(vault_entry) => entries.push(vault_entry),
Err(e) => {
log::warn!("Skipping file: {}", e);
}
}
}
}
// Sort by modified date descending (newest first)
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::TempDir;
fn create_test_file(dir: &Path, name: &str, content: &str) {
let file_path = dir.join(name);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = fs::File::create(file_path).unwrap();
file.write_all(content.as_bytes()).unwrap();
}
#[test]
fn test_extract_title_from_h1() {
let content = "---\nIs A: Note\n---\n# My Great Note\n\nSome content here.";
assert_eq!(extract_title(content, "my-great-note.md"), "My Great Note");
}
#[test]
fn test_extract_title_fallback_to_filename() {
let content = "Just some content without a heading.";
assert_eq!(extract_title(content, "fallback-title.md"), "fallback-title");
}
#[test]
fn test_extract_title_empty_h1_falls_back() {
let content = "# \n\nSome content.";
assert_eq!(extract_title(content, "empty-h1.md"), "empty-h1");
}
#[test]
fn test_parse_full_frontmatter() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Project
aliases:
- Laputa
- Castle in the Sky
Belongs to:
- Studio Ghibli
Related to:
- Miyazaki
Status: Active
Owner: Luca
Cadence: Weekly
---
# Laputa Project
This is a project note.
"#;
create_test_file(dir.path(), "laputa.md", content);
let entry = parse_md_file(&dir.path().join("laputa.md")).unwrap();
assert_eq!(entry.title, "Laputa Project");
assert_eq!(entry.is_a, Some("Project".to_string()));
assert_eq!(entry.aliases, vec!["Laputa", "Castle in the Sky"]);
assert_eq!(entry.belongs_to, vec!["Studio Ghibli"]);
assert_eq!(entry.related_to, vec!["Miyazaki"]);
assert_eq!(entry.status, Some("Active".to_string()));
assert_eq!(entry.owner, Some("Luca".to_string()));
assert_eq!(entry.cadence, Some("Weekly".to_string()));
assert_eq!(entry.filename, "laputa.md");
}
#[test]
fn test_parse_empty_frontmatter() {
let dir = TempDir::new().unwrap();
let content = "---\n---\n# Just a Title\n\nNo frontmatter fields.";
create_test_file(dir.path(), "empty-fm.md", content);
let entry = parse_md_file(&dir.path().join("empty-fm.md")).unwrap();
assert_eq!(entry.title, "Just a Title");
assert_eq!(entry.is_a, None);
assert!(entry.aliases.is_empty());
assert!(entry.belongs_to.is_empty());
assert!(entry.related_to.is_empty());
assert_eq!(entry.status, None);
}
#[test]
fn test_parse_no_frontmatter() {
let dir = TempDir::new().unwrap();
let content = "# A Note Without Frontmatter\n\nJust markdown.";
create_test_file(dir.path(), "no-fm.md", content);
let entry = parse_md_file(&dir.path().join("no-fm.md")).unwrap();
assert_eq!(entry.title, "A Note Without Frontmatter");
assert_eq!(entry.is_a, None);
}
#[test]
fn test_parse_single_string_aliases() {
let dir = TempDir::new().unwrap();
let content = "---\naliases: SingleAlias\n---\n# Test\n";
create_test_file(dir.path(), "single-alias.md", content);
let entry = parse_md_file(&dir.path().join("single-alias.md")).unwrap();
assert_eq!(entry.aliases, vec!["SingleAlias"]);
}
#[test]
fn test_scan_vault_recursive() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "root.md", "# Root Note\n");
create_test_file(dir.path(), "sub/nested.md", "---\nIs A: Task\n---\n# Nested\n");
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
let entries = scan_vault(dir.path().to_str().unwrap()).unwrap();
assert_eq!(entries.len(), 2);
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
assert!(filenames.contains(&"root.md"));
assert!(filenames.contains(&"nested.md"));
}
#[test]
fn test_scan_vault_nonexistent_path() {
let result = scan_vault("/nonexistent/path/that/does/not/exist");
assert!(result.is_err());
}
#[test]
fn test_parse_malformed_yaml() {
let dir = TempDir::new().unwrap();
// Malformed YAML — gray_matter should handle this gracefully
let content = "---\nIs A: [unclosed bracket\n---\n# Malformed\n";
create_test_file(dir.path(), "malformed.md", content);
let entry = parse_md_file(&dir.path().join("malformed.md"));
// Should still succeed — gray_matter may parse partially or skip
assert!(entry.is_ok());
}
}