Complete implementation across all 13 phases: - vault-core: types, YAML frontmatter parsing, entity classification, filesystem ops, config, prompt composition, validation, search - vault-watch: filesystem watcher with daemon write filtering, event classification - vault-scheduler: cron engine, process executor, task runner with retry logic and concurrency limiting - vault-api: Axum REST API (15 route modules), WebSocket with broadcast, AI assistant proxy, validation, templates - Dashboard: React + TypeScript + Tailwind v4 with kanban, CodeMirror editor, dynamic view system, AI chat sidebar - Nix flake with dev shell and NixOS module - Graceful shutdown, inotify overflow recovery, tracing instrumentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
167 lines
5.1 KiB
Rust
167 lines
5.1 KiB
Rust
use crate::entity::VaultEntity;
|
|
use crate::error::VaultError;
|
|
use serde::{de::DeserializeOwned, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Read and parse a vault entity from a markdown file.
|
|
pub fn read_entity<T: DeserializeOwned + Serialize>(path: &Path) -> Result<VaultEntity<T>, VaultError> {
|
|
let content =
|
|
std::fs::read_to_string(path).map_err(|e| VaultError::io(e, path))?;
|
|
VaultEntity::from_content(path.to_path_buf(), &content)
|
|
}
|
|
|
|
/// Write a vault entity to disk.
|
|
pub fn write_entity<T: DeserializeOwned + Serialize>(entity: &VaultEntity<T>) -> Result<(), VaultError> {
|
|
let content = entity.to_string()?;
|
|
if let Some(parent) = entity.path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| VaultError::io(e, parent))?;
|
|
}
|
|
std::fs::write(&entity.path, content).map_err(|e| VaultError::io(e, &entity.path))
|
|
}
|
|
|
|
/// Move a file from one path to another, creating parent dirs as needed.
|
|
pub fn move_file(from: &Path, to: &Path) -> Result<(), VaultError> {
|
|
if let Some(parent) = to.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| VaultError::io(e, parent))?;
|
|
}
|
|
std::fs::rename(from, to).map_err(|e| VaultError::io(e, from))
|
|
}
|
|
|
|
/// Ensure the standard vault directory structure exists.
|
|
pub fn ensure_vault_structure(vault_root: &Path) -> Result<(), VaultError> {
|
|
let dirs = [
|
|
"agents",
|
|
"skills/vault",
|
|
"crons/active",
|
|
"crons/paused",
|
|
"crons/templates",
|
|
"todos/harald/urgent",
|
|
"todos/harald/open",
|
|
"todos/harald/in-progress",
|
|
"todos/harald/done",
|
|
"todos/agent/queued",
|
|
"todos/agent/running",
|
|
"todos/agent/done",
|
|
"todos/agent/failed",
|
|
"knowledge",
|
|
"views/pages",
|
|
"views/widgets",
|
|
"views/layouts",
|
|
"views/custom",
|
|
"views/notifications",
|
|
".vault/logs",
|
|
".vault/templates",
|
|
];
|
|
|
|
for dir in &dirs {
|
|
let path = vault_root.join(dir);
|
|
std::fs::create_dir_all(&path).map_err(|e| VaultError::io(e, &path))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// List all .md files in a directory (non-recursive).
|
|
pub fn list_md_files(dir: &Path) -> Result<Vec<PathBuf>, VaultError> {
|
|
if !dir.exists() {
|
|
return Ok(vec![]);
|
|
}
|
|
let mut files = Vec::new();
|
|
let entries = std::fs::read_dir(dir).map_err(|e| VaultError::io(e, dir))?;
|
|
for entry in entries {
|
|
let entry = entry.map_err(|e| VaultError::io(e, dir))?;
|
|
let path = entry.path();
|
|
if path.is_file() && path.extension().is_some_and(|e| e == "md") {
|
|
files.push(path);
|
|
}
|
|
}
|
|
files.sort();
|
|
Ok(files)
|
|
}
|
|
|
|
/// List all .md files in a directory tree (recursive).
|
|
pub fn list_md_files_recursive(dir: &Path) -> Result<Vec<PathBuf>, VaultError> {
|
|
if !dir.exists() {
|
|
return Ok(vec![]);
|
|
}
|
|
let mut files = Vec::new();
|
|
walk_dir_recursive(dir, &mut files)?;
|
|
files.sort();
|
|
Ok(files)
|
|
}
|
|
|
|
fn walk_dir_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), VaultError> {
|
|
let entries = std::fs::read_dir(dir).map_err(|e| VaultError::io(e, dir))?;
|
|
for entry in entries {
|
|
let entry = entry.map_err(|e| VaultError::io(e, dir))?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
// Skip dotfiles/dirs
|
|
if path
|
|
.file_name()
|
|
.is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
|
|
{
|
|
continue;
|
|
}
|
|
walk_dir_recursive(&path, files)?;
|
|
} else if path.is_file() && path.extension().is_some_and(|e| e == "md") {
|
|
files.push(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Convert a string to a URL-safe slug.
|
|
pub fn slugify(s: &str) -> String {
|
|
s.to_lowercase()
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_alphanumeric() {
|
|
c
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect::<String>()
|
|
.split('-')
|
|
.filter(|s| !s.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join("-")
|
|
}
|
|
|
|
/// Create a timestamped slug: `YYYYMMDD-HHMMSS-slug`
|
|
pub fn timestamped_slug(title: &str) -> String {
|
|
let now = chrono::Utc::now();
|
|
format!("{}-{}", now.format("%Y%m%d-%H%M%S"), slugify(title))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_slugify() {
|
|
assert_eq!(slugify("Hello World!"), "hello-world");
|
|
assert_eq!(slugify("Review PR #1234"), "review-pr-1234");
|
|
assert_eq!(slugify(" spaces everywhere "), "spaces-everywhere");
|
|
}
|
|
|
|
#[test]
|
|
fn test_timestamped_slug() {
|
|
let slug = timestamped_slug("My Task");
|
|
assert!(slug.ends_with("-my-task"));
|
|
assert!(slug.len() > 20);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ensure_vault_structure() {
|
|
let tmp = std::env::temp_dir().join("vault-os-test-structure");
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
ensure_vault_structure(&tmp).unwrap();
|
|
assert!(tmp.join("agents").is_dir());
|
|
assert!(tmp.join("todos/harald/urgent").is_dir());
|
|
assert!(tmp.join("todos/agent/queued").is_dir());
|
|
assert!(tmp.join(".vault/logs").is_dir());
|
|
let _ = std::fs::remove_dir_all(&tmp);
|
|
}
|
|
}
|