feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
1476
product-source/hololake-platform/src-tauri/src/vault/cache.rs
Normal file
1476
product-source/hololake-platform/src-tauri/src/vault/cache.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,870 @@
|
|||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::getting_started::{agents_content_is_known_managed_template, AGENTS_MD};
|
||||
|
||||
/// Content for `type.md` — describes the generic Type metamodel for the vault.
|
||||
const TYPE_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
type: Type
|
||||
order: 0
|
||||
visible: false
|
||||
---
|
||||
|
||||
# Type
|
||||
|
||||
A Type defines shared metadata and defaults for a category of notes in this vault.
|
||||
|
||||
## Common properties
|
||||
- **Icon**: Sidebar icon for this type
|
||||
- **Color**: Accent color for notes of this type
|
||||
- **Order**: Sidebar ordering
|
||||
- **Sidebar label**: Override the default plural label
|
||||
- **Template**: Default body for new notes of this type
|
||||
- **View**: Preferred note-list view for this type
|
||||
";
|
||||
|
||||
/// Content for `note.md` — restores the default Note type definition when missing.
|
||||
const NOTE_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
type: Type
|
||||
---
|
||||
|
||||
# Note
|
||||
|
||||
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
|
||||
";
|
||||
|
||||
const LEGACY_CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
const HEADING_CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
# CLAUDE.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
const CLAUDE_MD_SHIM: &str = "---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
const GEMINI_MD_SHIM: &str = "---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
This file is only an Antigravity/Gemini compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiGuidanceFileState {
|
||||
Managed,
|
||||
Missing,
|
||||
Broken,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WorkspaceAiGuidanceStatus {
|
||||
pub agents_state: AiGuidanceFileState,
|
||||
pub claude_state: AiGuidanceFileState,
|
||||
pub gemini_state: AiGuidanceFileState,
|
||||
pub can_restore: bool,
|
||||
}
|
||||
|
||||
struct GuidancePaths {
|
||||
agents: PathBuf,
|
||||
claude: PathBuf,
|
||||
gemini: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LegacyAgentsMigrationOutcome {
|
||||
copied_to_root: bool,
|
||||
removed_legacy: bool,
|
||||
}
|
||||
|
||||
/// Write a file if it doesn't exist or is empty (corrupt). Returns true if written.
|
||||
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(needs_write)
|
||||
}
|
||||
|
||||
fn read_file_or_empty(path: &Path) -> String {
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn agents_content_can_be_replaced(content: &str) -> bool {
|
||||
content.trim().is_empty()
|
||||
|| content.contains("See config/agents.md")
|
||||
|| agents_content_is_known_managed_template(content)
|
||||
}
|
||||
|
||||
fn root_agents_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || agents_content_can_be_replaced(&read_file_or_empty(path))
|
||||
}
|
||||
|
||||
fn matches_claude_shim(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
trimmed == "@AGENTS.md"
|
||||
|| trimmed == LEGACY_CLAUDE_MD_SHIM.trim()
|
||||
|| trimmed == HEADING_CLAUDE_MD_SHIM.trim()
|
||||
|| trimmed == CLAUDE_MD_SHIM.trim()
|
||||
}
|
||||
|
||||
fn matches_gemini_shim(content: &str) -> bool {
|
||||
content.trim() == GEMINI_MD_SHIM.trim()
|
||||
}
|
||||
|
||||
fn claude_shim_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || {
|
||||
let content = read_file_or_empty(path);
|
||||
content.trim().is_empty() || matches_claude_shim(&content)
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_shim_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || {
|
||||
let content = read_file_or_empty(path);
|
||||
content.trim().is_empty() || matches_gemini_shim(&content)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_managed_file(
|
||||
path: &Path,
|
||||
content: &str,
|
||||
can_replace: fn(&Path) -> bool,
|
||||
) -> Result<bool, String> {
|
||||
if !can_replace(path) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn strip_markdown_frontmatter(content: &str) -> &str {
|
||||
let Some(line_ending) = resolve_frontmatter_line_ending(content) else {
|
||||
return content;
|
||||
};
|
||||
let after_open = &content[3 + line_ending.len()..];
|
||||
let close_marker = format!("{line_ending}---");
|
||||
let Some(close_index) = after_open.find(&close_marker) else {
|
||||
return content;
|
||||
};
|
||||
let after_close = &after_open[close_index + close_marker.len()..];
|
||||
after_close.strip_prefix(line_ending).unwrap_or(after_close)
|
||||
}
|
||||
|
||||
fn resolve_frontmatter_line_ending(content: &str) -> Option<&'static str> {
|
||||
if content.starts_with("---\r\n") {
|
||||
return Some("\r\n");
|
||||
}
|
||||
content.starts_with("---\n").then_some("\n")
|
||||
}
|
||||
|
||||
fn guidance_content_has_body(content: &str) -> bool {
|
||||
!strip_markdown_frontmatter(content).trim().is_empty()
|
||||
}
|
||||
|
||||
fn classify_guidance_file(
|
||||
path: &Path,
|
||||
matches_managed: fn(&str) -> bool,
|
||||
can_replace: fn(&Path) -> bool,
|
||||
) -> AiGuidanceFileState {
|
||||
if !path.exists() {
|
||||
return AiGuidanceFileState::Missing;
|
||||
}
|
||||
|
||||
let content = read_file_or_empty(path);
|
||||
if matches_managed(&content) {
|
||||
return AiGuidanceFileState::Managed;
|
||||
}
|
||||
|
||||
if !guidance_content_has_body(&content) {
|
||||
return AiGuidanceFileState::Broken;
|
||||
}
|
||||
|
||||
if can_replace(path) {
|
||||
return AiGuidanceFileState::Broken;
|
||||
}
|
||||
|
||||
AiGuidanceFileState::Custom
|
||||
}
|
||||
|
||||
fn guidance_paths(vault_path: &Path) -> GuidancePaths {
|
||||
GuidancePaths {
|
||||
agents: vault_path.join("AGENTS.md"),
|
||||
claude: vault_path.join("CLAUDE.md"),
|
||||
gemini: vault_path.join("GEMINI.md"),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(
|
||||
path,
|
||||
|content| content == AGENTS_MD,
|
||||
root_agents_can_be_replaced,
|
||||
)
|
||||
}
|
||||
|
||||
fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn classify_gemini_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(path, matches_gemini_shim, gemini_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
AiGuidanceFileState::Missing | AiGuidanceFileState::Broken
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ai_guidance_status(vault_path: &Path) -> WorkspaceAiGuidanceStatus {
|
||||
let paths = guidance_paths(vault_path);
|
||||
let agents_state = classify_agents_file(&paths.agents);
|
||||
let claude_state = classify_claude_file(&paths.claude);
|
||||
let gemini_state = classify_gemini_file(&paths.gemini);
|
||||
|
||||
WorkspaceAiGuidanceStatus {
|
||||
agents_state,
|
||||
claude_state,
|
||||
gemini_state,
|
||||
can_restore: guidance_file_needs_restore(agents_state)
|
||||
|| guidance_file_needs_restore(claude_state)
|
||||
|| guidance_file_needs_restore(gemini_state),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let paths = guidance_paths(vault_path);
|
||||
sync_managed_file(&paths.claude, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_gemini_shim_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let paths = guidance_paths(vault_path);
|
||||
sync_managed_file(&paths.gemini, GEMINI_MD_SHIM, gemini_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_required_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
|
||||
let wrote_agents = sync_default_agents_file(vault_path)?;
|
||||
let wrote_claude = sync_claude_shim_file(vault_path)?;
|
||||
Ok(wrote_agents || wrote_claude)
|
||||
}
|
||||
|
||||
fn sync_all_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
|
||||
let wrote_required = sync_required_ai_guidance_files(vault_path)?;
|
||||
let wrote_gemini = sync_gemini_shim_file(vault_path)?;
|
||||
Ok(wrote_required || wrote_gemini)
|
||||
}
|
||||
|
||||
fn migrate_legacy_agents_file(
|
||||
root_agents: &Path,
|
||||
config_agents: &Path,
|
||||
) -> Result<LegacyAgentsMigrationOutcome, String> {
|
||||
let mut outcome = LegacyAgentsMigrationOutcome::default();
|
||||
if !config_agents.exists() {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
let config_content = read_file_or_empty(config_agents);
|
||||
if !config_content.is_empty() && root_agents_can_be_replaced(root_agents) {
|
||||
fs::write(root_agents, &config_content)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {e}"))?;
|
||||
outcome.copied_to_root = true;
|
||||
}
|
||||
|
||||
fs::remove_file(config_agents)
|
||||
.map_err(|e| format!("Failed to remove config/agents.md: {e}"))?;
|
||||
outcome.removed_legacy = true;
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
|
||||
let config_dir = vault.join("config");
|
||||
if !config_dir.is_dir() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let is_empty = fs::read_dir(&config_dir)
|
||||
.map_err(|e| format!("Failed to inspect {}: {e}", config_dir.display()))?
|
||||
.next()
|
||||
.is_none();
|
||||
if !is_empty {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
fs::remove_dir(&config_dir)
|
||||
.map_err(|e| format!("Failed to remove {}: {e}", config_dir.display()))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let paths = guidance_paths(vault_path);
|
||||
sync_managed_file(&paths.agents, AGENTS_MD, root_agents_can_be_replaced)
|
||||
}
|
||||
|
||||
pub fn get_ai_guidance_status(
|
||||
vault_path: impl AsRef<str>,
|
||||
) -> Result<WorkspaceAiGuidanceStatus, String> {
|
||||
Ok(build_ai_guidance_status(Path::new(vault_path.as_ref())))
|
||||
}
|
||||
|
||||
pub fn restore_ai_guidance_files(
|
||||
vault_path: impl AsRef<str>,
|
||||
) -> Result<WorkspaceAiGuidanceStatus, String> {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
sync_all_ai_guidance_files(vault_path)?;
|
||||
Ok(build_ai_guidance_status(vault_path))
|
||||
}
|
||||
|
||||
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
|
||||
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
|
||||
pub fn seed_config_files(vault_path: impl AsRef<str>) {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
if sync_required_ai_guidance_files(vault_path).unwrap_or(false) {
|
||||
log::info!("Seeded vault AI guidance files at vault root");
|
||||
}
|
||||
|
||||
ensure_root_type_definitions(vault_path);
|
||||
}
|
||||
|
||||
fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) {
|
||||
let path = vault_path.join(file_name);
|
||||
let _ = write_if_missing(&path, content);
|
||||
}
|
||||
|
||||
/// Ensure the default root type definitions exist for opened/repaired vaults.
|
||||
fn ensure_root_type_definitions(vault_path: &Path) {
|
||||
ensure_root_type_definition(vault_path, "type.md", TYPE_TYPE_DEFINITION);
|
||||
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
|
||||
///
|
||||
/// - If `config/agents.md` has real content and root `AGENTS.md` is missing/empty/stub:
|
||||
/// move content to root, remove legacy file.
|
||||
/// - If root `AGENTS.md` doesn't exist: write defaults.
|
||||
/// - Cleans up empty `config/` directory after migration.
|
||||
///
|
||||
/// Always idempotent and silent.
|
||||
pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
|
||||
let vault = Path::new(vault_path.as_ref());
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
if let Ok(outcome) = migrate_legacy_agents_file(&root_agents, &config_agents) {
|
||||
if outcome.copied_to_root {
|
||||
log::info!("Migrated config/agents.md content to root AGENTS.md");
|
||||
}
|
||||
if outcome.removed_legacy {
|
||||
log::info!("Removed legacy config/agents.md");
|
||||
}
|
||||
}
|
||||
|
||||
if cleanup_empty_config_dir(vault).unwrap_or(false) {
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
|
||||
let _ = sync_required_ai_guidance_files(vault);
|
||||
}
|
||||
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
|
||||
/// Migrates legacy `config/agents.md` to root if present.
|
||||
/// Called by the "Repair Vault" command. Returns a status message.
|
||||
pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path.as_ref());
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
migrate_legacy_agents_file(&root_agents, &config_agents)?;
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
sync_required_ai_guidance_files(vault)?;
|
||||
|
||||
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
|
||||
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_vault() -> (TempDir, PathBuf) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
fn config_dir(vault: &Path) -> PathBuf {
|
||||
let dir = vault.join("config");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_root_agents(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("AGENTS.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_root_claude(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("CLAUDE.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_root_gemini(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("GEMINI.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_legacy_agents(vault: &Path, content: &str) {
|
||||
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn read_root_agents(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("AGENTS.md")).unwrap()
|
||||
}
|
||||
|
||||
fn read_root_claude(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
|
||||
}
|
||||
|
||||
fn read_root_gemini(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("GEMINI.md")).unwrap()
|
||||
}
|
||||
|
||||
type VaultOperation = fn(&Path);
|
||||
|
||||
fn run_seed(vault: &Path) {
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
}
|
||||
|
||||
fn run_migrate(vault: &Path) {
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
}
|
||||
|
||||
fn run_repair(vault: &Path) {
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn run_with_agents(
|
||||
run: VaultOperation,
|
||||
root_agents: Option<&str>,
|
||||
legacy_agents: Option<&str>,
|
||||
) -> (TempDir, PathBuf) {
|
||||
let (dir, vault) = create_vault();
|
||||
if let Some(content) = root_agents {
|
||||
write_root_agents(&vault, content);
|
||||
}
|
||||
if let Some(content) = legacy_agents {
|
||||
write_legacy_agents(&vault, content);
|
||||
}
|
||||
|
||||
run(&vault);
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
fn assert_preserves_custom_agents(run: VaultOperation) {
|
||||
let (_dir, vault) = run_with_agents(
|
||||
run,
|
||||
Some("# Custom Agent Config\nMy custom instructions\n"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
read_root_agents(&vault).contains("Custom Agent Config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_preserves_edited_default_agents(run: VaultOperation) {
|
||||
let edited_agents = AGENTS_MD.replacen(
|
||||
"Store note type in the `type:` frontmatter field.",
|
||||
"`type:` is the preferred type field. Tolaria still understands legacy aliases such as `Is A`.",
|
||||
1,
|
||||
);
|
||||
let (_dir, vault) = run_with_agents(run, Some(&edited_agents), None);
|
||||
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("Tolaria still understands legacy aliases such as `Is A`."));
|
||||
assert!(!content.contains("Store note type in the `type:` frontmatter field."));
|
||||
}
|
||||
|
||||
fn assert_legacy_agents_move_to_root(
|
||||
run: VaultOperation,
|
||||
legacy_agents: &str,
|
||||
expected_root_text: &str,
|
||||
expect_config_dir_removed: bool,
|
||||
) {
|
||||
let (_dir, vault) = run_with_agents(run, None, Some(legacy_agents));
|
||||
|
||||
let config_dir = vault.join("config");
|
||||
let root_content = read_root_agents(&vault);
|
||||
assert!(root_content.contains(expected_root_text));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
assert_eq!(config_dir.exists(), !expect_config_dir_removed);
|
||||
}
|
||||
|
||||
fn assert_stub_agents_are_replaced(
|
||||
run: VaultOperation,
|
||||
legacy_agents: &str,
|
||||
expected_root_text: &str,
|
||||
) {
|
||||
let (_dir, vault) = run_with_agents(
|
||||
run,
|
||||
Some("# Agent Instructions\nSee config/agents.md for vault instructions.\n"),
|
||||
Some(legacy_agents),
|
||||
);
|
||||
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains(expected_root_text));
|
||||
}
|
||||
|
||||
fn assert_required_agents_file_seeded(vault: &Path) {
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(read_root_agents(vault).contains("HoloLake Era Vault"));
|
||||
}
|
||||
|
||||
fn assert_required_guidance_shims_seeded(vault: &Path) {
|
||||
assert_eq!(read_root_claude(vault), CLAUDE_MD_SHIM);
|
||||
assert!(!vault.join("GEMINI.md").exists());
|
||||
}
|
||||
|
||||
fn assert_required_guidance_files_seeded(vault: &Path) {
|
||||
assert_required_agents_file_seeded(vault);
|
||||
assert_required_guidance_shims_seeded(vault);
|
||||
}
|
||||
|
||||
fn assert_root_type_definitions_seeded(vault: &Path) {
|
||||
assert!(vault.join("type.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
|
||||
fn assert_type_definition_content(vault: &Path) {
|
||||
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert_type_definition_body(&type_content);
|
||||
assert_note_definition_body(¬e_content);
|
||||
}
|
||||
|
||||
fn assert_type_definition_body(type_content: &str) {
|
||||
assert!(type_content.contains("type: Type"));
|
||||
assert!(type_content.contains("# Type"));
|
||||
assert!(type_content.contains("visible: false"));
|
||||
}
|
||||
|
||||
fn assert_note_definition_body(note_content: &str) {
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("# Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_guidance_files_at_root() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert_required_guidance_files_seeded(&vault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definitions() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert_root_type_definitions_seeded(&vault);
|
||||
assert_type_definition_content(&vault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_preserves_custom_agents() {
|
||||
assert_preserves_custom_agents(run_seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_reseeds_empty() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "");
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
assert!(read_root_agents(&vault).contains("HoloLake Era Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_preserves_edited_stale_default_agents() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(
|
||||
&vault,
|
||||
"# AGENTS.md — Tolaria Vault\n\n- The first H1 in the body is the note title. Do not add `title:` frontmatter.\n",
|
||||
);
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("Do not add `title:` frontmatter."));
|
||||
assert!(!content.contains("Use the first H1 as the note title."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_preserves_edited_default_agents() {
|
||||
assert_preserves_edited_default_agents(run_seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_preserves_custom_claude() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_claude(&vault, "# Custom Claude instructions\nDo not overwrite\n");
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(read_root_claude(&vault).contains("Custom Claude instructions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_refreshes_previous_managed_claude_shim() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_claude(&vault, HEADING_CLAUDE_MD_SHIM);
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_moves_config_to_root() {
|
||||
assert_legacy_agents_move_to_root(
|
||||
run_migrate,
|
||||
"# My vault agent instructions\nCustom content\n",
|
||||
"My vault agent instructions",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_preserves_existing_root() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# My root agent config\nDo not overwrite\n");
|
||||
write_legacy_agents(&vault, "Legacy content");
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
let config_dir = vault.join("config");
|
||||
let content = read_root_agents(&vault);
|
||||
assert!(content.contains("My root agent config"));
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_replaces_stub_with_config_content() {
|
||||
assert_stub_agents_are_replaced(
|
||||
run_migrate,
|
||||
"# Real Agent Config\nImportant instructions\n",
|
||||
"Real Agent Config",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_idempotent_when_no_legacy() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = read_root_agents(&vault);
|
||||
assert!(root.contains("HoloLake Era Vault"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_keeps_nonempty_config_dir() {
|
||||
let (_dir, vault) = create_vault();
|
||||
let config_dir = config_dir(&vault);
|
||||
fs::write(config_dir.join("agents.md"), "Agent content").unwrap();
|
||||
fs::write(config_dir.join("other.md"), "Other file").unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
assert!(config_dir.exists());
|
||||
assert!(config_dir.join("other.md").exists());
|
||||
assert!(!config_dir.join("agents.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_creates_all() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(msg, "Config files repaired");
|
||||
|
||||
assert_required_guidance_files_seeded(&vault);
|
||||
assert_root_type_definitions_seeded(&vault);
|
||||
assert_type_definition_content(&vault);
|
||||
assert!(fs::read_to_string(vault.join("note.md"))
|
||||
.unwrap()
|
||||
.contains("general-purpose document"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_preserves_custom_content() {
|
||||
assert_preserves_custom_agents(run_repair);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_migrates_legacy_config() {
|
||||
assert_legacy_agents_move_to_root(
|
||||
run_repair,
|
||||
"# My vault agent instructions\nCustom content\n",
|
||||
"My vault agent instructions",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_replaces_stub_with_legacy() {
|
||||
assert_stub_agents_are_replaced(
|
||||
run_repair,
|
||||
"# Real Instructions\nImportant stuff\n",
|
||||
"Real Instructions",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_preserves_edited_default_agents() {
|
||||
assert_preserves_edited_default_agents(run_repair);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ai_guidance_status_reports_custom_and_repairable_files() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
WorkspaceAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Custom,
|
||||
claude_state: AiGuidanceFileState::Broken,
|
||||
gemini_state: AiGuidanceFileState::Missing,
|
||||
can_restore: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ai_guidance_status_treats_edited_agents_as_custom() {
|
||||
let (_dir, vault) = create_vault();
|
||||
let edited_agents = AGENTS_MD.replacen("type: Note", "type: Vault Guidance", 1)
|
||||
+ "\n\n- Prefer the user's vault-specific naming conventions.\n";
|
||||
write_root_agents(&vault, &edited_agents);
|
||||
write_root_claude(&vault, CLAUDE_MD_SHIM);
|
||||
write_root_gemini(&vault, GEMINI_MD_SHIM);
|
||||
|
||||
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
WorkspaceAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Custom,
|
||||
claude_state: AiGuidanceFileState::Managed,
|
||||
gemini_state: AiGuidanceFileState::Managed,
|
||||
can_restore: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_ai_guidance_status_reports_frontmatter_only_agents_as_broken() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "---\ntype: Vault Guidance\n---\n");
|
||||
write_root_claude(&vault, CLAUDE_MD_SHIM);
|
||||
write_root_gemini(&vault, GEMINI_MD_SHIM);
|
||||
|
||||
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
WorkspaceAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Broken,
|
||||
claude_state: AiGuidanceFileState::Managed,
|
||||
gemini_state: AiGuidanceFileState::Managed,
|
||||
can_restore: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_repairs_without_overwriting_custom_agents() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "# Custom Agent Config\nHands off\n");
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
WorkspaceAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Custom,
|
||||
claude_state: AiGuidanceFileState::Managed,
|
||||
gemini_state: AiGuidanceFileState::Managed,
|
||||
can_restore: false,
|
||||
}
|
||||
);
|
||||
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
assert_eq!(read_root_gemini(&vault), GEMINI_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_repairs_broken_agents_and_missing_claude() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
WorkspaceAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Managed,
|
||||
claude_state: AiGuidanceFileState::Managed,
|
||||
gemini_state: AiGuidanceFileState::Managed,
|
||||
can_restore: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_preserves_custom_gemini() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, AGENTS_MD);
|
||||
write_root_claude(&vault, CLAUDE_MD_SHIM);
|
||||
write_root_gemini(&vault, "# Custom Gemini instructions\nDo not overwrite\n");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(status.gemini_state, AiGuidanceFileState::Custom);
|
||||
assert!(!status.can_restore);
|
||||
assert!(read_root_gemini(&vault).contains("Custom Gemini instructions"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A node in the vault's folder tree. Only contains directories, not files.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FolderNode {
|
||||
/// Folder name (last path component).
|
||||
pub name: String,
|
||||
/// Path relative to the vault root, using `/` separators (e.g. "projects/laputa").
|
||||
pub path: String,
|
||||
/// Child folders (sorted alphabetically).
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
#[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 archived: bool,
|
||||
#[serde(rename = "modifiedAt")]
|
||||
pub modified_at: Option<u64>,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<u64>,
|
||||
#[serde(rename = "fileSize")]
|
||||
pub file_size: u64,
|
||||
pub snippet: String,
|
||||
/// Generic relationship fields: any frontmatter key whose value contains wikilinks.
|
||||
/// Key is the original frontmatter field name (e.g. "Has", "Topics", "Events").
|
||||
pub relationships: HashMap<String, Vec<String>>,
|
||||
/// Phosphor icon name (kebab-case) for Type entries, e.g. "cooking-pot".
|
||||
pub icon: Option<String>,
|
||||
/// Accent color key for Type entries: "red", "purple", "blue", "green", "yellow", "orange".
|
||||
pub color: Option<String>,
|
||||
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
|
||||
pub order: Option<i64>,
|
||||
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
|
||||
#[serde(rename = "sidebarLabel")]
|
||||
pub sidebar_label: Option<String>,
|
||||
/// Markdown template for notes of this Type. When a new note is created
|
||||
/// with this type, the template body is pre-filled after the frontmatter.
|
||||
pub template: Option<String>,
|
||||
/// Default sort preference for the note list when viewing instances of this Type.
|
||||
/// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc").
|
||||
pub sort: Option<String>,
|
||||
/// Default view mode for the note list when viewing instances of this Type.
|
||||
/// Stored as a string: "all", "editor-list", or "editor-only".
|
||||
pub view: Option<String>,
|
||||
/// Rich-editor width mode for this note. None means use the app default.
|
||||
#[serde(rename = "noteWidth")]
|
||||
pub note_width: Option<String>,
|
||||
/// Editor display mode for this note. None means the default text editor.
|
||||
pub display: Option<String>,
|
||||
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
|
||||
pub visible: Option<bool>,
|
||||
/// Whether this note has been explicitly organized (removed from Inbox).
|
||||
pub organized: bool,
|
||||
/// Whether this note is a user favorite (shown in FAVORITES sidebar section).
|
||||
pub favorite: bool,
|
||||
/// Display order within the FAVORITES section (lower = higher).
|
||||
#[serde(rename = "favoriteIndex")]
|
||||
pub favorite_index: Option<i64>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
/// All wikilink targets found in the note body (excludes frontmatter).
|
||||
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
|
||||
#[serde(rename = "outgoingLinks", default)]
|
||||
pub outgoing_links: Vec<String>,
|
||||
/// Custom scalar and scalar-array frontmatter properties (non-relationship, non-structural).
|
||||
/// Objects and arrays containing wikilinks are excluded.
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
/// Properties to display as chips in the note list for this Type's notes.
|
||||
/// Configured via `_list_properties_display` in the type file's frontmatter.
|
||||
#[serde(rename = "listPropertiesDisplay", default)]
|
||||
pub list_properties_display: Vec<String>,
|
||||
/// Whether the note body has an H1 heading on the first non-empty line.
|
||||
/// Used by the frontend to decide whether to show the TitleField.
|
||||
#[serde(rename = "hasH1")]
|
||||
pub has_h1: bool,
|
||||
/// File kind: "markdown", "text", or "binary".
|
||||
/// Determines how the frontend renders and opens the file.
|
||||
#[serde(rename = "fileKind", default = "default_file_kind")]
|
||||
pub file_kind: String,
|
||||
}
|
||||
|
||||
fn default_file_kind() -> String {
|
||||
"markdown".to_string()
|
||||
}
|
||||
256
product-source/hololake-platform/src-tauri/src/vault/file.rs
Normal file
256
product-source/hololake-platform/src-tauri/src/vault/file.rs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
use std::borrow::Cow;
|
||||
use std::fs;
|
||||
use std::io::{Error, ErrorKind, Write};
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
const SAVE_RETRY_DELAYS_MS: [u64; 4] = [25, 50, 100, 200];
|
||||
|
||||
/// Read file metadata (modified_at timestamp, created_at timestamp, file size).
|
||||
/// Creation time is sourced from filesystem metadata (birthtime on macOS).
|
||||
pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option<u64>, Option<u64>, u64), String> {
|
||||
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 created_at = metadata
|
||||
.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
Ok((modified_at, created_at, metadata.len()))
|
||||
}
|
||||
|
||||
fn invalid_utf8_text_error(path: &Path) -> String {
|
||||
format!("File is not valid UTF-8 text: {}", path.display())
|
||||
}
|
||||
|
||||
fn is_invalid_platform_path_error(error: &Error) -> bool {
|
||||
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
|
||||
}
|
||||
|
||||
fn is_retryable_save_error(error: &Error) -> bool {
|
||||
error.kind() == ErrorKind::PermissionDenied
|
||||
|| (cfg!(windows) && error.raw_os_error() == Some(5))
|
||||
}
|
||||
|
||||
fn write_with_retry(
|
||||
mut write_once: impl FnMut() -> Result<(), Error>,
|
||||
mut wait_before_retry: impl FnMut(u64),
|
||||
) -> Result<(), Error> {
|
||||
for delay in SAVE_RETRY_DELAYS_MS {
|
||||
match write_once() {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) if is_retryable_save_error(&error) => wait_before_retry(delay),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
write_once()
|
||||
}
|
||||
|
||||
fn read_existing_note_bytes(path: &Path) -> Result<Vec<u8>, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(format!("Path is not a file: {}", path.display()));
|
||||
}
|
||||
fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
struct RawNotePath<'a>(&'a str);
|
||||
|
||||
impl<'a> RawNotePath<'a> {
|
||||
fn is_windows_verbatim(&self) -> bool {
|
||||
self.0.starts_with(r"\\?\") || self.0.starts_with(r"\??\")
|
||||
}
|
||||
|
||||
fn normalized_for_file_io(&self) -> Cow<'a, str> {
|
||||
if !self.is_windows_verbatim() {
|
||||
return Cow::Borrowed(self.0);
|
||||
}
|
||||
if !self.0.contains('/') {
|
||||
return Cow::Borrowed(self.0);
|
||||
}
|
||||
Cow::Owned(self.0.replace('/', r"\"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum NoteIoOperation {
|
||||
Save,
|
||||
Create,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NotePathDisplay<'a> {
|
||||
value: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> NotePathDisplay<'a> {
|
||||
fn new(value: &'a str) -> Self {
|
||||
Self { value }
|
||||
}
|
||||
}
|
||||
|
||||
impl NoteIoOperation {
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
Self::Save => "save",
|
||||
Self::Create => "create",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String {
|
||||
let verb = operation.verb();
|
||||
if is_invalid_platform_path_error(error) {
|
||||
let path = path.value;
|
||||
format!(
|
||||
"Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}"
|
||||
)
|
||||
} else {
|
||||
let path = path.value;
|
||||
format!("Failed to {verb} {path}: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the content of a single note file.
|
||||
pub fn get_note_content(path: &Path) -> Result<String, String> {
|
||||
let bytes = read_existing_note_bytes(path)?;
|
||||
String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path))
|
||||
}
|
||||
|
||||
/// Check whether a note still has the exact content the renderer cached.
|
||||
pub fn note_content_matches(path: &Path, expected_content: &str) -> Result<bool, String> {
|
||||
let bytes = read_existing_note_bytes(path)?;
|
||||
Ok(bytes == expected_content.as_bytes())
|
||||
}
|
||||
|
||||
fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> {
|
||||
let parent_missing = file_path.parent().is_some_and(|p| !p.exists());
|
||||
if parent_missing {
|
||||
return Err(format!(
|
||||
"Parent directory does not exist: {}",
|
||||
file_path.parent().unwrap().display()
|
||||
));
|
||||
}
|
||||
let is_readonly = file_path.exists()
|
||||
&& file_path
|
||||
.metadata()
|
||||
.map(|m| m.permissions().readonly())
|
||||
.unwrap_or(false);
|
||||
if is_readonly {
|
||||
return Err(format!("File is read-only: {}", display_path));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write content to a note file. Creates parent directory if needed, validates path,
|
||||
/// then writes content to disk.
|
||||
pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let normalized_path = RawNotePath(path).normalized_for_file_io();
|
||||
let file_path = Path::new(normalized_path.as_ref());
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
validate_save_path(file_path, path)?;
|
||||
write_with_retry(
|
||||
|| fs::write(file_path, content),
|
||||
|delay| thread::sleep(Duration::from_millis(delay)),
|
||||
)
|
||||
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
|
||||
}
|
||||
|
||||
/// Create a new note file without overwriting any existing file.
|
||||
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let normalized_path = RawNotePath(path).normalized_for_file_io();
|
||||
let file_path = Path::new(normalized_path.as_ref());
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
validate_save_path(file_path, path)?;
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(file_path)
|
||||
.map_err(|e| match e.kind() {
|
||||
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
|
||||
_ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e),
|
||||
})?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_windows_invalid_path_syntax_as_recoverable_save_error() {
|
||||
let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md";
|
||||
let message = note_io_error(
|
||||
NoteIoOperation::Save,
|
||||
NotePathDisplay::new(path),
|
||||
&Error::from_raw_os_error(123),
|
||||
);
|
||||
|
||||
assert!(message.contains("path is invalid on this platform"));
|
||||
assert!(message.contains("Rename the note or move it to a valid folder"));
|
||||
assert!(!message.contains("os error 123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_extended_windows_paths_before_file_io() {
|
||||
let path = r"\\?\C:\Users\alex\Documents\Tolaria/Getting Started/untitled-project.md";
|
||||
|
||||
assert_eq!(
|
||||
RawNotePath(path).normalized_for_file_io(),
|
||||
r"\\?\C:\Users\alex\Documents\Tolaria\Getting Started\untitled-project.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_transient_access_denied_save_errors() {
|
||||
let mut attempts = 0;
|
||||
let mut delays = Vec::new();
|
||||
|
||||
write_with_retry(
|
||||
|| {
|
||||
attempts += 1;
|
||||
if attempts == 1 {
|
||||
Err(Error::new(ErrorKind::PermissionDenied, "Access is denied"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|delay| delays.push(delay),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(attempts, 2);
|
||||
assert_eq!(delays, vec![25]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_content_matches_detects_external_edits() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(&path, "# Fresh\n").unwrap();
|
||||
|
||||
assert!(note_content_matches(&path, "# Fresh\n").unwrap());
|
||||
assert!(!note_content_matches(&path, "# Stale\n").unwrap());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
pub(crate) fn validate_filename_stem(stem: &str) -> Result<(), String> {
|
||||
validate_portable_name_segment(stem, "Invalid filename")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_folder_name(name: &str) -> Result<(), String> {
|
||||
validate_portable_name_segment(name, "Invalid folder name")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_view_filename_stem(stem: &str) -> Result<(), String> {
|
||||
validate_portable_name_segment(stem, "Invalid view filename")
|
||||
}
|
||||
|
||||
fn validate_portable_name_segment(value: &str, message: &str) -> Result<(), String> {
|
||||
if is_invalid_portable_name_segment(value) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_invalid_portable_name_segment(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || matches!(trimmed, "." | "..") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.ends_with('.') || trimmed.ends_with(' ') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.chars().any(is_invalid_portable_name_char) {
|
||||
return true;
|
||||
}
|
||||
|
||||
is_windows_reserved_device_name(trimmed)
|
||||
}
|
||||
|
||||
fn is_invalid_portable_name_char(ch: char) -> bool {
|
||||
ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
|
||||
}
|
||||
|
||||
fn is_windows_reserved_device_name(value: &str) -> bool {
|
||||
let candidate = value
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(value)
|
||||
.to_ascii_uppercase();
|
||||
WINDOWS_RESERVED_DEVICE_NAMES
|
||||
.iter()
|
||||
.any(|reserved| candidate == *reserved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{validate_filename_stem, validate_folder_name, validate_view_filename_stem};
|
||||
|
||||
#[test]
|
||||
fn accepts_portable_names() {
|
||||
assert_eq!(validate_filename_stem("meeting-notes"), Ok(()));
|
||||
assert_eq!(validate_filename_stem("draft.v2"), Ok(()));
|
||||
assert_eq!(validate_folder_name("Projects"), Ok(()));
|
||||
assert_eq!(validate_view_filename_stem("active-projects"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved_windows_device_names() {
|
||||
assert_eq!(
|
||||
validate_filename_stem("con"),
|
||||
Err("Invalid filename".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_folder_name("Lpt1"),
|
||||
Err("Invalid folder name".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_view_filename_stem("aux"),
|
||||
Err("Invalid view filename".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_windows_invalid_characters_and_suffixes() {
|
||||
assert_eq!(
|
||||
validate_filename_stem("quarterly:plan"),
|
||||
Err("Invalid filename".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_folder_name("Research?"),
|
||||
Err("Invalid folder name".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_view_filename_stem("overview. "),
|
||||
Err("Invalid view filename".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
193
product-source/hololake-platform/src-tauri/src/vault/folders.rs
Normal file
193
product-source/hololake-platform/src-tauri/src/vault/folders.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use super::filename_rules::validate_folder_name;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FolderRenameResult {
|
||||
pub old_path: String,
|
||||
pub new_path: String,
|
||||
}
|
||||
|
||||
fn normalize_folder_name(next_name: &str) -> Result<String, String> {
|
||||
let trimmed = next_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Folder name cannot be empty".to_string());
|
||||
}
|
||||
validate_folder_name(trimmed)?;
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn ensure_relative_folder_path(folder_path: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = folder_path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Folder path cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let relative = Path::new(trimmed);
|
||||
if relative.is_absolute() {
|
||||
return Err("Folder path must be relative to the vault root".to_string());
|
||||
}
|
||||
if relative
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
return Err("Folder path cannot escape the vault root".to_string());
|
||||
}
|
||||
|
||||
Ok(relative.to_path_buf())
|
||||
}
|
||||
|
||||
fn display_relative_path(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
pub fn rename_folder(
|
||||
vault_path: &Path,
|
||||
folder_path: &str,
|
||||
next_name: &str,
|
||||
) -> Result<FolderRenameResult, String> {
|
||||
let relative_path = ensure_relative_folder_path(folder_path)?;
|
||||
let normalized_name = normalize_folder_name(next_name)?;
|
||||
let source_path = vault_path.join(&relative_path);
|
||||
|
||||
if !source_path.exists() {
|
||||
return Err(format!("Folder does not exist: {}", folder_path));
|
||||
}
|
||||
if !source_path.is_dir() {
|
||||
return Err(format!("Not a folder: {}", folder_path));
|
||||
}
|
||||
|
||||
let current_name = source_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.ok_or_else(|| "Folder path cannot target the vault root".to_string())?;
|
||||
|
||||
if current_name == normalized_name {
|
||||
return Ok(FolderRenameResult {
|
||||
old_path: display_relative_path(&relative_path),
|
||||
new_path: display_relative_path(&relative_path),
|
||||
});
|
||||
}
|
||||
|
||||
let parent_relative = relative_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default();
|
||||
let destination_relative = parent_relative.join(&normalized_name);
|
||||
let destination_path = vault_path.join(&destination_relative);
|
||||
|
||||
if destination_path.exists() {
|
||||
return Err(format!(
|
||||
"Folder '{}' already exists",
|
||||
display_relative_path(&destination_relative)
|
||||
));
|
||||
}
|
||||
|
||||
fs::rename(&source_path, &destination_path)
|
||||
.map_err(|error| format!("Failed to rename folder: {}", error))?;
|
||||
|
||||
Ok(FolderRenameResult {
|
||||
old_path: display_relative_path(&relative_path),
|
||||
new_path: display_relative_path(&destination_relative),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_folder(vault_path: &Path, folder_path: &str) -> Result<String, String> {
|
||||
let relative_path = ensure_relative_folder_path(folder_path)?;
|
||||
let target_path = vault_path.join(&relative_path);
|
||||
|
||||
if !target_path.exists() {
|
||||
return Err(format!("Folder does not exist: {}", folder_path));
|
||||
}
|
||||
if !target_path.is_dir() {
|
||||
return Err(format!("Not a folder: {}", folder_path));
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&target_path)
|
||||
.map_err(|error| format!("Failed to delete folder: {}", error))?;
|
||||
Ok(display_relative_path(&relative_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_folder(dir: &TempDir, relative: &str) -> PathBuf {
|
||||
let path = dir.path().join(relative);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_folder_updates_relative_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
make_folder(&dir, "projects/laputa");
|
||||
|
||||
let result = rename_folder(dir.path(), "projects", "work").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
FolderRenameResult {
|
||||
old_path: "projects".to_string(),
|
||||
new_path: "work".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(dir.path().join("work/laputa").is_dir());
|
||||
assert!(!dir.path().join("projects").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_folder_rejects_duplicate_sibling() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
make_folder(&dir, "projects");
|
||||
make_folder(&dir, "areas");
|
||||
|
||||
let error = rename_folder(dir.path(), "projects", "areas").unwrap_err();
|
||||
|
||||
assert_eq!(error, "Folder 'areas' already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_folder_rejects_invalid_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
make_folder(&dir, "projects");
|
||||
|
||||
let error = rename_folder(dir.path(), "projects", "../areas").unwrap_err();
|
||||
|
||||
assert_eq!(error, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_folder_rejects_windows_invalid_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
make_folder(&dir, "projects");
|
||||
|
||||
let error = rename_folder(dir.path(), "projects", "LPT1").unwrap_err();
|
||||
|
||||
assert_eq!(error, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_removes_nested_contents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let nested = make_folder(&dir, "projects/laputa");
|
||||
fs::write(nested.join("note.md"), "# Note\n").unwrap();
|
||||
|
||||
let deleted_path = delete_folder(dir.path(), "projects").unwrap();
|
||||
|
||||
assert_eq!(deleted_path, "projects");
|
||||
assert!(!dir.path().join("projects").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_rejects_missing_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let error = delete_folder(dir.path(), "projects").unwrap_err();
|
||||
|
||||
assert_eq!(error, "Folder does not exist: projects");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
use crate::frontmatter::keys::{canonical_known_frontmatter_key, FrontmatterKey};
|
||||
use crate::vault::parsing::contains_wikilink;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub(crate) struct Frontmatter {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
|
||||
pub is_a: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub aliases: Option<StringOrList>,
|
||||
#[serde(
|
||||
rename = "_archived",
|
||||
alias = "Archived",
|
||||
alias = "archived",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(rename = "_icon", alias = "icon", default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub color: Option<StringOrList>,
|
||||
#[serde(rename = "_order", alias = "order", default)]
|
||||
pub order: Option<i64>,
|
||||
#[serde(
|
||||
rename = "_sidebar_label",
|
||||
alias = "sidebar label",
|
||||
alias = "sidebar_label",
|
||||
default
|
||||
)]
|
||||
pub sidebar_label: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub template: Option<StringOrList>,
|
||||
#[serde(rename = "_sort", alias = "sort", default)]
|
||||
pub sort: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub view: Option<StringOrList>,
|
||||
#[serde(rename = "_width", alias = "width", default)]
|
||||
pub note_width: Option<StringOrList>,
|
||||
#[serde(rename = "_display", default)]
|
||||
pub display: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
#[serde(
|
||||
rename = "_organized",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub organized: Option<bool>,
|
||||
#[serde(
|
||||
rename = "_favorite",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub favorite: Option<bool>,
|
||||
#[serde(rename = "_favorite_index", default)]
|
||||
pub favorite_index: Option<i64>,
|
||||
#[serde(rename = "_list_properties_display", default)]
|
||||
pub list_properties_display: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Custom deserializer for boolean fields that may arrive as strings.
|
||||
/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we
|
||||
/// need to accept both actual booleans and their string representations.
|
||||
fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct BoolOrStringVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for BoolOrStringVisitor {
|
||||
type Value = Option<bool>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a boolean or a string representing a boolean")
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
match v.to_lowercase().as_str() {
|
||||
"true" | "yes" | "1" => Ok(Some(true)),
|
||||
"false" | "no" | "0" | "" => Ok(Some(false)),
|
||||
_ => Ok(Some(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
|
||||
Ok(Some(v != 0))
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
|
||||
Ok(Some(v != 0))
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(BoolOrStringVisitor)
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum StringOrList {
|
||||
Single(String),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
impl StringOrList {
|
||||
pub fn into_vec(self) -> Vec<String> {
|
||||
match self {
|
||||
StringOrList::Single(s) => vec![s],
|
||||
StringOrList::List(v) => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize to a single scalar: unwrap single-element arrays, take first
|
||||
/// element of multi-element arrays, return scalar unchanged, empty array → None.
|
||||
pub fn into_scalar(self) -> Option<String> {
|
||||
match self {
|
||||
StringOrList::Single(s) => Some(s),
|
||||
StringOrList::List(mut v) => {
|
||||
if v.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(v.swap_remove(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a JSON value so that arrays of mixed types (strings + objects + nulls)
|
||||
/// are flattened to arrays of strings. gray_matter mis-parses certain YAML patterns:
|
||||
///
|
||||
/// 1. Unquoted colons in list items: `- Bitcoin: Net Unrealized` becomes
|
||||
/// `{"Bitcoin": "Net Unrealized"}` instead of a plain string.
|
||||
/// 2. Hash comments in list items: `- # Heading` becomes `Null` because
|
||||
/// gray_matter treats `#` as a YAML comment.
|
||||
///
|
||||
/// This sanitizer converts objects back to "key: value" strings and removes nulls,
|
||||
/// preventing serde deserialization of the entire Frontmatter struct from failing.
|
||||
fn sanitize_array_item(item: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
match item {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::Object(map) if !map.is_empty() => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => format!("{}: {}", k, s),
|
||||
_ => format!("{}: {}", k, v),
|
||||
})
|
||||
.collect();
|
||||
Some(serde_json::Value::String(parts.join(", ")))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
let sanitized: Vec<serde_json::Value> =
|
||||
arr.iter().filter_map(sanitize_array_item).collect();
|
||||
serde_json::Value::Array(sanitized)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_known_frontmatter_value(
|
||||
target: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
value: &serde_json::Value,
|
||||
overwrite: bool,
|
||||
) {
|
||||
let Some(canonical_key) = canonical_known_frontmatter_key(FrontmatterKey::new(key)) else {
|
||||
return;
|
||||
};
|
||||
if overwrite || !target.contains_key(canonical_key) {
|
||||
target.insert(canonical_key.to_string(), sanitize_value(value));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_frontmatter_keys(raw_content: &str) -> Vec<String> {
|
||||
RawFrontmatter(raw_content)
|
||||
.extract_block()
|
||||
.map(|raw| {
|
||||
raw.lines()
|
||||
.filter_map(|line| YamlLine(line).key().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn known_frontmatter_map(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
raw_content: &str,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut filtered = serde_json::Map::new();
|
||||
for key in raw_frontmatter_keys(raw_content) {
|
||||
if let Some(value) = data.get(&key) {
|
||||
insert_known_frontmatter_value(&mut filtered, &key, value, true);
|
||||
}
|
||||
}
|
||||
for (key, value) in data {
|
||||
insert_known_frontmatter_value(&mut filtered, key, value, false);
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// Parse frontmatter from raw YAML data extracted by gray_matter.
|
||||
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>, raw_content: &str) -> Frontmatter {
|
||||
let filtered = known_frontmatter_map(data, raw_content);
|
||||
let value = serde_json::Value::Object(filtered);
|
||||
serde_json::from_value(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
pub(crate) fn extract_relationships(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, Vec<String>> {
|
||||
let mut relationships = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
if FrontmatterKey::new(key).is_reserved() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wikilinks = relationship_wikilinks(value);
|
||||
if !wikilinks.is_empty() {
|
||||
relationships.insert(key.clone(), wikilinks);
|
||||
}
|
||||
}
|
||||
|
||||
relationships
|
||||
}
|
||||
|
||||
fn relationship_wikilinks(value: &serde_json::Value) -> Vec<String> {
|
||||
let mut wikilinks = Vec::new();
|
||||
collect_relationship_wikilinks(value, 0, &mut wikilinks);
|
||||
wikilinks
|
||||
}
|
||||
|
||||
fn collect_relationship_wikilinks(
|
||||
value: &serde_json::Value,
|
||||
depth: usize,
|
||||
wikilinks: &mut Vec<String>,
|
||||
) {
|
||||
match value {
|
||||
serde_json::Value::String(s) if contains_wikilink(s) => wikilinks.push(s.clone()),
|
||||
serde_json::Value::Array(arr) => {
|
||||
if let Some(link) = nested_flow_wikilink(arr, depth) {
|
||||
wikilinks.push(link);
|
||||
return;
|
||||
}
|
||||
for item in arr {
|
||||
collect_relationship_wikilinks(item, depth + 1, wikilinks);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<String> {
|
||||
if depth == 0 {
|
||||
return None;
|
||||
}
|
||||
match arr {
|
||||
[serde_json::Value::String(target)] if !contains_wikilink(target) => {
|
||||
Some(format!("[[{target}]]"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_array_property_value(arr: &[serde_json::Value]) -> Option<serde_json::Value> {
|
||||
let mut values = Vec::new();
|
||||
for item in arr {
|
||||
let sanitized = sanitize_array_item(item)?;
|
||||
match sanitized {
|
||||
serde_json::Value::String(ref s) if contains_wikilink(s) => return None,
|
||||
serde_json::Value::String(_)
|
||||
| serde_json::Value::Number(_)
|
||||
| serde_json::Value::Bool(_) => values.push(sanitized),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
match values.as_slice() {
|
||||
[single] => Some(single.clone()),
|
||||
_ => Some(serde_json::Value::Array(values)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom scalar and scalar-array properties from raw YAML frontmatter.
|
||||
pub(crate) fn extract_properties(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
if FrontmatterKey::new(key).is_reserved() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::Null => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
serde_json::Value::String(s) if !contains_wikilink(s) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
if let Some(value) = scalar_array_property_value(arr) {
|
||||
properties.insert(key.clone(), value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
properties
|
||||
}
|
||||
|
||||
/// Resolve `is_a` from frontmatter only.
|
||||
pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
|
||||
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_note_width(note_width: Option<StringOrList>) -> Option<String> {
|
||||
match note_width
|
||||
.and_then(StringOrList::into_scalar)
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
{
|
||||
Some(mode) if mode == "normal" || mode == "wide" => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_note_display(display: Option<StringOrList>) -> Option<String> {
|
||||
match display
|
||||
.and_then(StringOrList::into_scalar)
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
{
|
||||
Some(mode) if mode == "text" || mode == "sheet" => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct YamlScalar<'a>(&'a str);
|
||||
|
||||
impl<'a> YamlScalar<'a> {
|
||||
/// Strip matching outer quotes (single or double) from a YAML scalar.
|
||||
fn unquote(self) -> &'a str {
|
||||
self.0
|
||||
.strip_prefix('"')
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
self.0
|
||||
.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(self.0)
|
||||
}
|
||||
|
||||
/// Parse a scalar YAML value into a JSON value.
|
||||
fn parse(self) -> serde_json::Value {
|
||||
let trimmed = self.unquote();
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"true" | "yes" => serde_json::Value::Bool(true),
|
||||
"false" | "no" => serde_json::Value::Bool(false),
|
||||
_ => trimmed
|
||||
.parse::<i64>()
|
||||
.map(|n| serde_json::json!(n))
|
||||
.unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct YamlLine<'a>(&'a str);
|
||||
|
||||
impl<'a> YamlLine<'a> {
|
||||
fn is_top_level(self) -> bool {
|
||||
if self.0.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if self.0.starts_with(' ') {
|
||||
return false;
|
||||
}
|
||||
!self.0.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Return the key from a top-level `key:` or `"key":` YAML line.
|
||||
/// Returns `None` for indented, blank, or non-key lines.
|
||||
fn key(self) -> Option<&'a str> {
|
||||
if !self.is_top_level() {
|
||||
return None;
|
||||
}
|
||||
let (key, _) = self.0.split_once(':')?;
|
||||
Some(key.trim().trim_matches('"').trim_matches('\''))
|
||||
}
|
||||
|
||||
fn list_item(self) -> Option<&'a str> {
|
||||
self.0.strip_prefix(" - ").map(str::trim)
|
||||
}
|
||||
|
||||
fn value_part(self) -> Option<&'a str> {
|
||||
self.0.split_once(':').map(|(_, value)| value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RawFrontmatter<'a>(&'a str);
|
||||
|
||||
impl<'a> RawFrontmatter<'a> {
|
||||
fn extract_block(self) -> Option<&'a str> {
|
||||
let rest = self.0.strip_prefix("---")?;
|
||||
let rest = rest
|
||||
.strip_prefix('\n')
|
||||
.or_else(|| rest.strip_prefix("\r\n"))?;
|
||||
let end = rest.find("\n---")?;
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
/// Fallback parser for when gray_matter fails to parse YAML (returns raw string).
|
||||
/// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings,
|
||||
/// and YAML lists.
|
||||
fn parse_fallback(self) -> HashMap<String, serde_json::Value> {
|
||||
let mut map = HashMap::new();
|
||||
let mut list_key: Option<String> = None;
|
||||
let mut list_items: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for line in self.0.lines() {
|
||||
let yaml_line = YamlLine(line);
|
||||
|
||||
// Accumulate list items under the current key
|
||||
if list_key.is_some() {
|
||||
if let Some(item) = yaml_line.list_item() {
|
||||
list_items.push(YamlScalar(item).parse());
|
||||
continue;
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
}
|
||||
|
||||
let Some(key) = yaml_line.key() else {
|
||||
continue;
|
||||
};
|
||||
let value_part = yaml_line.value_part().unwrap_or("");
|
||||
if value_part.is_empty() {
|
||||
list_key = Some(key.to_string());
|
||||
} else {
|
||||
map.insert(key.to_string(), YamlScalar(value_part).parse());
|
||||
}
|
||||
}
|
||||
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush a pending list accumulator into the map.
|
||||
fn flush_list(
|
||||
map: &mut HashMap<String, serde_json::Value>,
|
||||
key: &mut Option<String>,
|
||||
items: &mut Vec<serde_json::Value>,
|
||||
) {
|
||||
if let Some(k) = key.take() {
|
||||
if !items.is_empty() {
|
||||
map.insert(k, serde_json::Value::Array(std::mem::take(items)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
|
||||
/// When gray_matter fails to parse YAML (e.g. malformed quotes from Notion exports),
|
||||
/// `raw_content` is used as a fallback: simple key:value pairs are extracted line-by-line
|
||||
/// so that critical fields like Trashed, Archived, type are not silently lost.
|
||||
pub(crate) fn extract_fm_and_rels(
|
||||
data: Option<gray_matter::Pod>,
|
||||
raw_content: &str,
|
||||
) -> (
|
||||
Frontmatter,
|
||||
HashMap<String, Vec<String>>,
|
||||
HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let json_map = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => {
|
||||
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect()
|
||||
}
|
||||
_ => {
|
||||
// gray_matter returned Null, String, or None — YAML parse failed.
|
||||
// Fall back to line-by-line extraction from the raw frontmatter block.
|
||||
match RawFrontmatter(raw_content).extract_block() {
|
||||
Some(raw) => RawFrontmatter(raw).parse_fallback(),
|
||||
None => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
}
|
||||
}
|
||||
};
|
||||
(
|
||||
parse_frontmatter(&json_map, raw_content),
|
||||
extract_relationships(&json_map),
|
||||
extract_properties(&json_map),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
use super::*;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
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();
|
||||
}
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
struct HiddenPropertyCase<'a> {
|
||||
content: &'a str,
|
||||
hidden_key: &'a str,
|
||||
}
|
||||
|
||||
fn assert_filtered_property_stays_hidden(case: HiddenPropertyCase<'_>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "test.md", case.content);
|
||||
assert!(!entry.properties.contains_key(case.hidden_key));
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get("Company")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
struct SingleElementArrayCase<'a> {
|
||||
note_type: &'a str,
|
||||
key: &'a str,
|
||||
value: &'a str,
|
||||
}
|
||||
|
||||
fn assert_single_element_array_property(case: SingleElementArrayCase<'_>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = format!(
|
||||
"---\ntype: {}\n{}:\n - {}\n---\n# Test\n",
|
||||
case.note_type, case.key, case.value
|
||||
);
|
||||
let entry = parse_test_entry(&dir, "test.md", &content);
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get(case.key)
|
||||
.and_then(|property| property.as_str()),
|
||||
Some(case.value)
|
||||
);
|
||||
assert_eq!(entry.is_a, Some(case.note_type.to_string()));
|
||||
}
|
||||
|
||||
struct AliasRecoveryCase<'a> {
|
||||
file_name: &'a str,
|
||||
title: &'a str,
|
||||
alias_item: &'a str,
|
||||
}
|
||||
|
||||
fn assert_alias_parser_recovers(case: AliasRecoveryCase<'_>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = format!(
|
||||
"---\ntype: Note\n_organized: true\naliases:\n - {}\n - Note\n---\n# {}\n",
|
||||
case.alias_item, case.title
|
||||
);
|
||||
let entry = parse_test_entry(&dir, case.file_name, &content);
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
assert!(entry.organized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filtered_properties_stay_hidden() {
|
||||
let cases = [HiddenPropertyCase {
|
||||
content: "---\nMentor: \"[[person/alice]]\"\nCompany: Acme Corp\n---\n# Test\n",
|
||||
hidden_key: "Mentor",
|
||||
}];
|
||||
|
||||
for case in cases {
|
||||
assert_filtered_property_stays_hidden(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_element_array_properties_unwrap_to_scalars() {
|
||||
let cases = [
|
||||
SingleElementArrayCase {
|
||||
note_type: "Responsibility",
|
||||
key: "Owner",
|
||||
value: "Luca",
|
||||
},
|
||||
SingleElementArrayCase {
|
||||
note_type: "Procedure",
|
||||
key: "Cadence",
|
||||
value: "Weekly",
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_single_element_array_property(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_element_array_properties_are_preserved() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"playlist.md",
|
||||
"---\ntags:\n - blues\n - chicago\n---\n# Playlist\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
entry.properties.get("tags"),
|
||||
Some(&serde_json::json!(["blues", "chicago"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blank_scalar_properties_are_preserved() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"book.md",
|
||||
"---\ntype: Book\nstart date:\nrating: \n---\n# Book\n",
|
||||
);
|
||||
|
||||
assert!(entry
|
||||
.properties
|
||||
.get("start date")
|
||||
.is_some_and(|value| value.is_null()));
|
||||
assert!(entry
|
||||
.properties
|
||||
.get("rating")
|
||||
.is_some_and(|value| value.is_null()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unquoted_wikilink_relationships_are_preserved() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"book.md",
|
||||
"---\ntype: Type\nMentor: [[person/alice]]\n---\n# Book\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
entry.relationships.get("Mentor"),
|
||||
Some(&vec!["[[person/alice]]".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_parser_recovers_special_alias_items() {
|
||||
let cases = [
|
||||
AliasRecoveryCase {
|
||||
file_name: "colon-alias.md",
|
||||
title: "Test",
|
||||
alias_item: "Bitcoin: Net Unrealized Profit/Loss",
|
||||
},
|
||||
AliasRecoveryCase {
|
||||
file_name: "hash-alias.md",
|
||||
title: "Title",
|
||||
alias_item: "# Writing a Good CLAUDE.md",
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_alias_parser_recovers(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_collisions_keep_frontmatter_with_last_value_winning() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
assert_eq!(entry.status, Some("Evergreened".to_string()));
|
||||
}
|
||||
|
|
@ -0,0 +1,890 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Public starter vault cloned when the user chooses Getting Started.
|
||||
pub const GETTING_STARTED_REPO_URL: &str =
|
||||
"https://github.com/refactoringhq/tolaria-getting-started.git";
|
||||
|
||||
/// Default location for the Getting Started vault.
|
||||
pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
documents_dir()
|
||||
.map(|d| d.join("Getting Started"))
|
||||
.ok_or_else(|| "Could not determine Documents directory".to_string())
|
||||
}
|
||||
|
||||
fn documents_dir() -> Option<PathBuf> {
|
||||
dirs::document_dir().or_else(|| dirs::home_dir().map(|home| home.join("Documents")))
|
||||
}
|
||||
|
||||
const GETTING_STARTED_REQUIRED_CONFIG_FILES: [&str; 2] = ["type.md", "note.md"];
|
||||
const GETTING_STARTED_TEMPLATE_MARKERS: [&str; 2] = ["welcome.md", "views/active-projects.yml"];
|
||||
|
||||
/// Check whether a vault path exists on disk.
|
||||
pub fn vault_exists(path: &str) -> bool {
|
||||
let default_path = default_vault_path().ok();
|
||||
vault_exists_with_default_path(Path::new(path), default_path.as_deref())
|
||||
}
|
||||
|
||||
fn vault_exists_with_default_path(path: &Path, default_path: Option<&Path>) -> bool {
|
||||
if !path.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !is_canonical_getting_started_path(path, default_path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
canonical_getting_started_vault_exists(path)
|
||||
}
|
||||
|
||||
fn is_canonical_getting_started_path(path: &Path, default_path: Option<&Path>) -> bool {
|
||||
default_path.is_some_and(|candidate| candidate == path)
|
||||
}
|
||||
|
||||
fn canonical_getting_started_vault_exists(path: &Path) -> bool {
|
||||
has_getting_started_config_files(path) && has_getting_started_template_marker(path)
|
||||
}
|
||||
|
||||
fn has_getting_started_config_files(path: &Path) -> bool {
|
||||
GETTING_STARTED_REQUIRED_CONFIG_FILES
|
||||
.iter()
|
||||
.all(|file| path.join(file).is_file())
|
||||
}
|
||||
|
||||
fn has_getting_started_template_marker(path: &Path) -> bool {
|
||||
GETTING_STARTED_TEMPLATE_MARKERS
|
||||
.iter()
|
||||
.any(|file| path.join(file).is_file())
|
||||
}
|
||||
|
||||
/// Previous default AGENTS.md content seeded by Tolaria itself. Existing vaults
|
||||
/// can still contain this exact text, so Tolaria treats it as managed content
|
||||
/// that is safe to refresh automatically.
|
||||
const STALE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault - a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring.
|
||||
|
||||
## Core rules
|
||||
|
||||
- One markdown note per file.
|
||||
- The first H1 in the body is the note title. Do not add `title:` frontmatter.
|
||||
- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`.
|
||||
- Use wikilinks for note-to-note references, both in frontmatter and in the body.
|
||||
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
|
||||
|
||||
## Notes
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
belongs_to:
|
||||
- "[[area-operations]]"
|
||||
related_to:
|
||||
- "[[goal-q2-launch]]"
|
||||
---
|
||||
|
||||
# Q2 Launch Plan
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
Tolaria still understands some legacy aliases such as `Is A`, but prefer `type:` for new or edited notes.
|
||||
|
||||
## Types
|
||||
|
||||
Type definitions are regular notes stored in `type/`. Use `type: Type` in frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: books
|
||||
color: blue
|
||||
order: 20
|
||||
sidebar label: Projects
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value is a wikilink is treated as a relationship. Common names include `belongs_to`, `related_to`, and `has`, but custom relationship names are valid too.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` - link by filename or title
|
||||
- `[[filename|display text]]` - with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Active Notes",
|
||||
"filters": [
|
||||
{"property": "type", "operator": "equals", "value": "Note"},
|
||||
{"property": "status", "operator": "equals", "value": "Active"}
|
||||
],
|
||||
"sort": {"property": "title", "direction": "asc"}
|
||||
}
|
||||
```
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What agents should do
|
||||
|
||||
- Create and edit notes using the frontmatter and H1 conventions above.
|
||||
- Create and edit type documents in `type/`.
|
||||
- Add or modify relationships without breaking existing wikilinks.
|
||||
- Create and edit saved views in `views/`.
|
||||
- Update `AGENTS.md` only when the user asks for agent guidance changes.
|
||||
|
||||
## What agents should avoid
|
||||
|
||||
- Do not infer note type from folders other than the dedicated `type/` directory for type definitions.
|
||||
- Do not silently overwrite an existing custom `AGENTS.md`.
|
||||
- Do not rewrite installation-specific app config unless the user explicitly asks.
|
||||
"##;
|
||||
|
||||
/// Older Tolaria-managed AGENTS.md content from before the `type:` migration.
|
||||
/// Existing vaults can still contain this exact text, so Tolaria treats it as
|
||||
/// managed content that is safe to refresh automatically.
|
||||
const PRE_TYPE_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Note structure
|
||||
|
||||
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
|
||||
|
||||
```yaml
|
||||
---
|
||||
is_a: TypeName # the note's type (must match the title of a type file in the vault)
|
||||
url: https://... # example property
|
||||
belongs_to: "[[other-note]]"
|
||||
related_to:
|
||||
- "[[note-a]]"
|
||||
- "[[note-b]]"
|
||||
---
|
||||
|
||||
# Note Title
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
|
||||
|
||||
## Types
|
||||
|
||||
A type is a note with `is_a: Type`. Type files live in the vault root:
|
||||
|
||||
```yaml
|
||||
---
|
||||
is_a: Type
|
||||
_icon: books # Phosphor icon name in kebab-case
|
||||
_color: "#8b5cf6" # hex color
|
||||
---
|
||||
|
||||
# TypeName
|
||||
```
|
||||
|
||||
To find what types exist: look for files with `is_a: Type` in the vault root.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
|
||||
|
||||
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Active Notes",
|
||||
"filters": [
|
||||
{"property": "is_a", "operator": "equals", "value": "Note"},
|
||||
{"property": "status", "operator": "equals", "value": "Active"}
|
||||
],
|
||||
"sort": {"property": "title", "direction": "asc"}
|
||||
}
|
||||
```
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What you can do
|
||||
|
||||
- Create/edit notes with correct frontmatter and H1 title
|
||||
- Create new type files
|
||||
- Add or modify relationships
|
||||
- Create/edit views in `views/`
|
||||
- Edit `AGENTS.md` (this file)
|
||||
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
"##;
|
||||
|
||||
const OUTDATED_AGENTS_MARKERS: [&str; 3] = [
|
||||
"# AGENTS.md — Tolaria Vault",
|
||||
"Legacy `title:` frontmatter is still read as a fallback",
|
||||
"Tolaria still understands legacy aliases such as `Is A`.",
|
||||
];
|
||||
|
||||
const STALE_TITLE_FRONTMATTER_MARKER: &str = "Do not add `title:` frontmatter.";
|
||||
const LEGACY_VIEWS_SECTION_MARKER: &str = "## Views";
|
||||
const LEGACY_VIEW_FILE_MARKERS: [&str; 2] = [".view.json", "```json"];
|
||||
|
||||
struct AgentsContent<'a>(&'a str);
|
||||
|
||||
impl<'a> AgentsContent<'a> {
|
||||
fn new(content: &'a str) -> Self {
|
||||
Self(content)
|
||||
}
|
||||
|
||||
fn contains(&self, marker: &str) -> bool {
|
||||
self.0.contains(marker)
|
||||
}
|
||||
|
||||
fn contains_all(&self, markers: &[&str]) -> bool {
|
||||
markers.iter().all(|marker| self.contains(marker))
|
||||
}
|
||||
|
||||
fn is_known_legacy_template(&self) -> bool {
|
||||
self.0.trim().is_empty()
|
||||
|| self.0 == PRE_TYPE_AGENTS_MD
|
||||
|| self.0 == LEGACY_AGENTS_MD
|
||||
|| self.0 == STALE_AGENTS_MD
|
||||
}
|
||||
|
||||
fn has_stale_title_stub(&self) -> bool {
|
||||
self.contains(STALE_TITLE_FRONTMATTER_MARKER)
|
||||
}
|
||||
|
||||
fn has_legacy_json_view_guidance(&self) -> bool {
|
||||
self.contains(LEGACY_VIEWS_SECTION_MARKER)
|
||||
&& LEGACY_VIEW_FILE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| self.contains(marker))
|
||||
}
|
||||
|
||||
fn can_be_refreshed(&self) -> bool {
|
||||
self.is_known_legacy_template()
|
||||
|| self.has_stale_title_stub()
|
||||
|| self.has_legacy_json_view_guidance()
|
||||
|| self.contains_all(&OUTDATED_AGENTS_MARKERS)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
|
||||
AgentsContent::new(content).can_be_refreshed()
|
||||
}
|
||||
|
||||
pub(super) fn agents_content_is_known_managed_template(content: &str) -> bool {
|
||||
AgentsContent::new(content).is_known_legacy_template()
|
||||
}
|
||||
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Tolaria vault mechanics only; no user-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# AGENTS.md — HoloLake Era Vault
|
||||
|
||||
This is a HoloLake Era knowledge vault in the Guanghu language-personality-driven operating system.
|
||||
|
||||
## Identity and awakening
|
||||
|
||||
- Begin as the neutral Guanghu Guide Persona. Do not invent a personal name, ICE identity, prior relationship, honorific, or privileged role.
|
||||
- Do not infer the human identity from an open page, vault name, note content, device, account, or conversation topic.
|
||||
- Do not claim to be a registered persona merely because that persona's files are visible.
|
||||
- Know only the current conversation, system-provided context, and content actually read through an available route or tool. Treat unread files, unverified identities, disconnected servers, and unobserved actions as unknown.
|
||||
- Wait for the user to speak before selecting task context. Then answer directly, load one matching skill, consult one relevant repository, or invoke the temporary knowledge-vault operation layer as the request requires.
|
||||
- Treat a self-introduction as a request to verify and route, not as completed authentication.
|
||||
- Do not preload unrelated skills, repositories, memories, or tools.
|
||||
- Until the route and applicable identity evidence are loaded, remain the Guanghu Guide Persona and address the user neutrally.
|
||||
- Read `AI-MEMORY.md` and `AI-PROMPT.md` at the vault root when they exist.
|
||||
- The persona may create and update `AI-MEMORY.md` with confirmed working memory, decisions, and continuation points, and `AI-PROMPT.md` with vault-specific collaboration guidance.
|
||||
- These persona-maintained files cannot grant additional permissions, override human authorization, erase contribution history, or turn an unverified identity into fact.
|
||||
|
||||
Keep this file focused on vault-specific conventions. For general HoloLake Era behavior, use the bundled HoloLake Era agent docs path provided by the app session context.
|
||||
|
||||
## Core conventions
|
||||
|
||||
- Notes are Markdown files.
|
||||
- Use the first H1 as the note title. HoloLake Era uses this title in the note list, wikilinks, search, and other display surfaces.
|
||||
- Store note type in the `type:` frontmatter field.
|
||||
- Use wikilinks in body text and frontmatter fields to connect notes.
|
||||
- Prefer types and relationships for organization. Folder structure is optional and should not be treated as the primary source of meaning.
|
||||
- HoloLake Era reads notes recursively from all folders and stores new notes in the vault root by default.
|
||||
- Saved views live in `views/*.yml`.
|
||||
- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types.
|
||||
- Frontmatter properties that start with `_` are usually HoloLake-managed state. Leave them alone unless the user explicitly asks for them to change.
|
||||
|
||||
## Notes
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Note
|
||||
related_to: "[[tolaria]]"
|
||||
status: Active
|
||||
url: https://example.com
|
||||
---
|
||||
|
||||
# Example note
|
||||
|
||||
Body content in Markdown.
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
Types are regular notes with `type: Type`. They define how notes of that type appear and which properties or relationships should be suggested for new notes.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
_icon: rocket
|
||||
_color: "#3b82f6"
|
||||
_order: 0
|
||||
_list_properties_display:
|
||||
- related_to
|
||||
_sort: "property:onboarding:asc"
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
Empty properties and relationships in a type document become placeholders on new notes of that type. Values attached to properties in the type document become defaults for type instances.
|
||||
|
||||
Useful type metadata includes `icon`/`_icon`, `color`/`_color`, `order`/`_order`, `sidebar label`, `_list_properties_display`, `_sort`, `template`, `view`, and `visible`. When editing an existing file, preserve the key style already used there instead of mass-normalizing underscored keys.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value contains `[[wikilinks]]` is treated as a relationship. Common relationship keys include `related_to`, `belongs_to`, and `has`, but custom relationship names are valid too.
|
||||
|
||||
Preserve older relationship labels such as `Belongs to:` when editing existing notes that already use them.
|
||||
|
||||
Use quoted wikilinks for scalar frontmatter values and YAML lists for multi-value relationships.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` for normal links
|
||||
- `[[filename|display text]]` for custom display text
|
||||
- Works in frontmatter values and Markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved views live in `views/*.yml` and are written as YAML. HoloLake Era scans every `.yml` file in `views/`, and the filename is the stable view id, so use kebab-case filenames such as `active-projects.yml`.
|
||||
|
||||
A view definition looks like this:
|
||||
|
||||
```yaml
|
||||
name: Active Projects
|
||||
icon: null
|
||||
color: null
|
||||
sort: "property:onboarding:asc"
|
||||
filters:
|
||||
any:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: related_to
|
||||
op: contains
|
||||
value: "[[tolaria]]"
|
||||
```
|
||||
|
||||
View rules that matter when creating or editing files:
|
||||
- `name` is required. `icon`, `color`, and `sort` are optional.
|
||||
- `sort` uses `option:direction`. Built-in options are `modified`, `created`, `title`, and `status`. Custom-property sorts use `property:<Property Name>`, for example `property:onboarding:asc`.
|
||||
- `filters` must be a tree whose root is exactly one `all:` group or one `any:` group.
|
||||
- Each filter condition uses `field`, `op`, and usually `value`.
|
||||
- `field` can target built-ins like `type`, `status`, `title`, `favorite`, and `body`, plus actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`.
|
||||
- Supported operators are `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, and `after`.
|
||||
- `any_of` and `none_of` expect `value` to be a YAML list.
|
||||
- `regex: true` is supported with `equals`, `not_equals`, `contains`, and `not_contains` when pattern matching is needed.
|
||||
- Relationship filters can use wikilinks in `value`, for example `"[[tolaria]]"`.
|
||||
- Do not create JSON view files or `.view.json` filenames.
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What agents should do
|
||||
|
||||
- Create and edit notes using the frontmatter and H1 conventions above.
|
||||
- Create and edit type documents when the user asks for note categories or defaults.
|
||||
- Add or modify relationships without breaking existing wikilinks.
|
||||
- Create and edit saved views in `views/`.
|
||||
- Search, read, create, edit, and delete notes inside the active vault. Delete only when the user clearly requests it.
|
||||
- Maintain `AI-MEMORY.md` and `AI-PROMPT.md` for persona continuity within the authorization boundaries above.
|
||||
- Use one temporary `magic_brush` capability to compose vault operations for the current task, then discard the temporary plan and cache.
|
||||
- When a method deserves reuse, propose a lightweight skill package containing its trigger, procedure, and boundaries. Do not permanently mount every skill as a runtime tool.
|
||||
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
|
||||
- Search the bundled HoloLake Era docs when the user asks how HoloLake Era works or when you need product behavior beyond these base conventions.
|
||||
- Use Portent as the default best-practice model when the user asks how to improve, organize, or restructure the knowledge base. Combine Portent's types, relationships, and capture -> organize -> archive lifecycle with HoloLake Era's type documents, properties, Inbox, archive, and saved views.
|
||||
|
||||
## What agents should avoid
|
||||
|
||||
- Do not infer note type or meaning from folders.
|
||||
- Do not treat files in `attachments/` as notes, types, or view definitions.
|
||||
- Do not silently overwrite an existing custom `AGENTS.md`.
|
||||
- Do not rewrite installation-specific app configuration unless the user explicitly asks.
|
||||
"##;
|
||||
|
||||
pub(super) const LEGACY_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Note structure
|
||||
|
||||
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: TypeName # the note's type (must match the title of a type file in the vault)
|
||||
url: https://... # example property
|
||||
belongs_to: "[[other-note]]"
|
||||
related_to:
|
||||
- "[[note-a]]"
|
||||
- "[[note-b]]"
|
||||
---
|
||||
|
||||
# Note Title
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
|
||||
|
||||
## Types
|
||||
|
||||
A type is a note with `type: Type`. Type files live in the vault root:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
_icon: books # Phosphor icon name in kebab-case
|
||||
_color: "#8b5cf6" # hex color
|
||||
---
|
||||
|
||||
# TypeName
|
||||
```
|
||||
|
||||
To find what types exist: look for files with `type: Type` in the vault root.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
|
||||
|
||||
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Active Notes",
|
||||
"filters": [
|
||||
{"property": "type", "operator": "equals", "value": "Note"},
|
||||
{"property": "status", "operator": "equals", "value": "Active"}
|
||||
],
|
||||
"sort": {"property": "title", "direction": "asc"}
|
||||
}
|
||||
```
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What you can do
|
||||
|
||||
- Create/edit notes with correct frontmatter and H1 title
|
||||
- Create new type files
|
||||
- Add or modify relationships
|
||||
- Create/edit views in `views/`
|
||||
- Edit `AGENTS.md` (this file)
|
||||
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
"##;
|
||||
|
||||
/// Clone the public starter vault into the requested path.
|
||||
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
|
||||
let vault_path = create_getting_started_vault_from_repo(
|
||||
Path::new(target_path),
|
||||
&getting_started_repo_url(),
|
||||
)?;
|
||||
Ok(vault_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn create_getting_started_vault_from_repo(
|
||||
target_path: &Path,
|
||||
repo_url: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let target_path_str = target_path.to_string_lossy();
|
||||
if target_path_str.trim().is_empty() {
|
||||
return Err("Target path is required".to_string());
|
||||
}
|
||||
|
||||
crate::git::clone_repo(repo_url, &target_path_str)?;
|
||||
let vault_path = canonical_vault_path(target_path)?;
|
||||
crate::git::disconnect_all_remotes(path_to_utf8(&vault_path, "Vault path")?)?;
|
||||
refresh_cloned_vault_config_files(&vault_path)?;
|
||||
Ok(vault_path)
|
||||
}
|
||||
|
||||
fn getting_started_repo_url() -> String {
|
||||
std::env::var("TOLARIA_GETTING_STARTED_REPO_URL")
|
||||
.or_else(|_| std::env::var("LAPUTA_GETTING_STARTED_REPO_URL"))
|
||||
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
|
||||
}
|
||||
|
||||
fn canonical_vault_path(target_path: &Path) -> Result<PathBuf, String> {
|
||||
target_path.canonicalize().map_err(|e| {
|
||||
format!(
|
||||
"Failed to resolve vault path '{}': {}",
|
||||
target_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn path_to_utf8<'a>(path: &'a Path, context: &str) -> Result<&'a str, String> {
|
||||
path.to_str()
|
||||
.ok_or_else(|| format!("{context} '{}' is not valid UTF-8", path.display()))
|
||||
}
|
||||
|
||||
fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
let refresh_agents = if !agents_path.exists() {
|
||||
true
|
||||
} else {
|
||||
let content = fs::read_to_string(&agents_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", agents_path.display()))?;
|
||||
agents_content_can_be_refreshed(&content)
|
||||
};
|
||||
|
||||
if refresh_agents {
|
||||
fs::write(&agents_path, AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
|
||||
}
|
||||
|
||||
crate::vault::repair_config_files(path_to_utf8(vault_path, "Vault path")?)?;
|
||||
|
||||
if !vault_has_pending_changes(vault_path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
crate::git::ensure_author_config(vault_path)?;
|
||||
crate::git::git_commit(
|
||||
path_to_utf8(vault_path, "Vault path")?,
|
||||
"Initialize Tolaria config files",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
|
||||
let output = crate::hidden_command("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to inspect cloned vault status: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git status failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn init_source_repo(path: &Path, agents_content: Option<&str>) {
|
||||
fs::create_dir_all(path.join("views")).unwrap();
|
||||
fs::write(
|
||||
path.join("welcome.md"),
|
||||
"# Welcome to Tolaria\n\nThis is the starter vault.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
path.join("views").join("active-projects.yml"),
|
||||
"title: Active Projects\nfilters: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(content) = agents_content {
|
||||
fs::write(path.join("AGENTS.md"), content).unwrap();
|
||||
}
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "tolaria@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Tolaria App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial starter vault"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_tolaria_config_files(path: &Path) {
|
||||
fs::create_dir_all(path).unwrap();
|
||||
fs::write(path.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
fs::write(path.join("type.md"), "# Type\n").unwrap();
|
||||
fs::write(path.join("note.md"), "# Note\n").unwrap();
|
||||
}
|
||||
|
||||
fn assert_getting_started_vault_replaces_template(agents_content: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, Some(agents_content));
|
||||
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
assert!(dest.join("type.md").exists());
|
||||
assert!(dest.join("note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_vault_path_appends_getting_started() {
|
||||
let path = default_vault_path().unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.ends_with("Getting Started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_getting_started_repo_url_uses_tolaria_slug() {
|
||||
assert_eq!(
|
||||
GETTING_STARTED_REPO_URL,
|
||||
"https://github.com/refactoringhq/tolaria-getting-started.git"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_getting_started_path_rejects_plain_tolaria_folder() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let default_path = dir.path().join("Getting Started");
|
||||
|
||||
write_tolaria_config_files(&default_path);
|
||||
|
||||
assert!(!vault_exists_with_default_path(
|
||||
default_path.as_path(),
|
||||
Some(default_path.as_path())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_canonical_vault_path_stays_permissive() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let default_path = dir.path().join("Getting Started");
|
||||
let other_vault_path = dir.path().join("Existing Vault");
|
||||
|
||||
fs::create_dir_all(&other_vault_path).unwrap();
|
||||
|
||||
assert!(vault_exists_with_default_path(
|
||||
other_vault_path.as_path(),
|
||||
Some(default_path.as_path())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_clones_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
let result =
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, dest.canonicalize().unwrap());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
assert!(dest.join("views").join("active-projects.yml").exists());
|
||||
assert!(dest.join(".git").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(dest.join("AGENTS.md")).unwrap(),
|
||||
AGENTS_MD
|
||||
);
|
||||
assert!(dest.join("type.md").exists());
|
||||
assert!(dest.join("note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonical_getting_started_path_accepts_cloned_starter_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let default_path = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
create_getting_started_vault_from_repo(default_path.as_path(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert!(vault_exists_with_default_path(
|
||||
default_path.as_path(),
|
||||
Some(default_path.as_path())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_rejects_nonempty_destination() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
fs::create_dir_all(&dest).unwrap();
|
||||
fs::write(dest.join("existing.md"), "# Existing\n").unwrap();
|
||||
|
||||
let err = create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("already exists and is not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_cleans_partial_clone_on_failure() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let missing_repo = dir.path().join("missing");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
|
||||
let err =
|
||||
create_getting_started_vault_from_repo(dest.as_path(), missing_repo.to_str().unwrap())
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_leaves_clean_worktree() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_removes_the_starter_remote() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(!crate::git::has_remote(dest.to_str().unwrap()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_replaces_legacy_agents_template() {
|
||||
assert_getting_started_vault_replaces_template(LEGACY_AGENTS_MD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_replaces_pre_type_agents_template() {
|
||||
assert_getting_started_vault_replaces_template(PRE_TYPE_AGENTS_MD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_refresh_detection_accepts_pre_type_managed_template() {
|
||||
assert!(agents_content_can_be_refreshed(PRE_TYPE_AGENTS_MD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_refresh_detection_accepts_legacy_json_view_guidance() {
|
||||
let stale = r#"# AGENTS.md — Tolaria Vault
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{"title":"Active Notes"}
|
||||
```
|
||||
"#;
|
||||
assert!(agents_content_can_be_refreshed(stale));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_template_matches_current_tolaria_vault_conventions() {
|
||||
assert!(AGENTS_MD.starts_with("---\ntype: Note\n_organized: true\n---\n"));
|
||||
assert!(AGENTS_MD.contains("# AGENTS.md — HoloLake Era Vault"));
|
||||
assert!(AGENTS_MD.contains("Wait for the user to speak before selecting task context."));
|
||||
assert!(AGENTS_MD.contains("Do not infer the human identity"));
|
||||
assert!(AGENTS_MD.contains("Use the first H1 as the note title."));
|
||||
assert!(AGENTS_MD.contains("Store note type in the `type:` frontmatter field."));
|
||||
assert!(AGENTS_MD.contains("HoloLake Era reads notes recursively from all folders"));
|
||||
assert!(AGENTS_MD.contains("Search the bundled HoloLake Era docs"));
|
||||
assert!(AGENTS_MD.contains("attachments/"));
|
||||
assert!(AGENTS_MD.contains("views/*.yml"));
|
||||
assert!(AGENTS_MD.contains("option:direction"));
|
||||
assert!(AGENTS_MD.contains("property:<Property Name>"));
|
||||
assert!(AGENTS_MD.contains("actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`."));
|
||||
assert!(AGENTS_MD.contains("Belongs to:"));
|
||||
assert!(AGENTS_MD.contains("Do not create JSON view files or `.view.json` filenames."));
|
||||
assert!(!AGENTS_MD.contains("Laputa"));
|
||||
assert!(!AGENTS_MD.contains("Is A"));
|
||||
assert!(!AGENTS_MD.contains("is_a"));
|
||||
assert!(!AGENTS_MD.contains("type definitions currently live"));
|
||||
}
|
||||
}
|
||||
370
product-source/hololake-platform/src-tauri/src/vault/ignored.rs
Normal file
370
product-source/hololake-platform/src-tauri/src/vault/ignored.rs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
use super::{FolderNode, VaultEntry};
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
fn normalize_relative_path(path: &str) -> String {
|
||||
path.replace('\\', "/")
|
||||
.trim_start_matches("./")
|
||||
.trim_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn stripped_relative_path(vault_path: &Path, path: &Path) -> Option<String> {
|
||||
let relative = path.strip_prefix(vault_path).ok()?;
|
||||
let normalized = normalize_relative_path(relative.to_string_lossy().as_ref());
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
}
|
||||
|
||||
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
|
||||
stripped_relative_path(vault_path, path).or_else(|| {
|
||||
let canonical_vault_path = vault_path.canonicalize().ok()?;
|
||||
let canonical_path = path.canonicalize().ok()?;
|
||||
stripped_relative_path(&canonical_vault_path, &canonical_path)
|
||||
})
|
||||
}
|
||||
|
||||
fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
|
||||
entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git"
|
||||
}
|
||||
|
||||
fn has_gitignore_file(vault_path: &Path) -> bool {
|
||||
if vault_path.join(".gitignore").is_file() {
|
||||
return true;
|
||||
}
|
||||
|
||||
WalkDir::new(vault_path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(should_descend_for_gitignore)
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| {
|
||||
entry.file_type().is_file() && entry.file_name().to_string_lossy() == ".gitignore"
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<String> {
|
||||
let mut child = crate::hidden_command("git")
|
||||
.args(["check-ignore", "--no-index", "--stdin"])
|
||||
.current_dir(vault_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let mut stdin = child.stdin.take()?;
|
||||
let paths = relative_paths.to_vec();
|
||||
let writer = std::thread::spawn(move || -> std::io::Result<()> {
|
||||
for path in paths {
|
||||
writeln!(stdin, "{path}")?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let output = child.wait_with_output().ok()?;
|
||||
writer.join().ok()?.ok()?;
|
||||
if output.status.success() || output.status.code() == Some(1) {
|
||||
return Some(String::from_utf8_lossy(&output.stdout).to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ignored_relative_paths(vault_path: &Path, relative_paths: &[String]) -> HashSet<String> {
|
||||
if relative_paths.is_empty() || !has_gitignore_file(vault_path) {
|
||||
return HashSet::new();
|
||||
}
|
||||
|
||||
let mut candidates = relative_paths
|
||||
.iter()
|
||||
.map(|path| normalize_relative_path(path))
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates.dedup();
|
||||
|
||||
run_git_check_ignore(vault_path, &candidates)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(normalize_relative_path)
|
||||
.filter(|path| !path.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_gitignored_items<T>(
|
||||
vault_path: &Path,
|
||||
items: Vec<T>,
|
||||
hide_enabled: bool,
|
||||
relative_for: impl Fn(&T) -> Option<String>,
|
||||
) -> Vec<T> {
|
||||
if !hide_enabled || items.is_empty() {
|
||||
return items;
|
||||
}
|
||||
|
||||
let relative_paths = items.iter().filter_map(&relative_for).collect::<Vec<_>>();
|
||||
let ignored = ignored_relative_paths(vault_path, &relative_paths);
|
||||
if ignored.is_empty() {
|
||||
return items;
|
||||
}
|
||||
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
relative_for(item)
|
||||
.map(|relative| !ignored.contains(&relative))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn filter_gitignored_paths(
|
||||
vault_path: &Path,
|
||||
paths: Vec<PathBuf>,
|
||||
hide_enabled: bool,
|
||||
) -> Vec<PathBuf> {
|
||||
filter_gitignored_items(vault_path, paths, hide_enabled, |path| {
|
||||
relative_path(vault_path, path)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn filter_gitignored_entries(
|
||||
vault_path: &Path,
|
||||
entries: Vec<VaultEntry>,
|
||||
hide_enabled: bool,
|
||||
) -> Vec<VaultEntry> {
|
||||
filter_gitignored_items(vault_path, entries, hide_enabled, |entry| {
|
||||
relative_path(vault_path, Path::new(&entry.path))
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_folder_queries(nodes: &[FolderNode], queries: &mut Vec<String>) {
|
||||
for node in nodes {
|
||||
let relative = normalize_relative_path(&node.path);
|
||||
if !relative.is_empty() {
|
||||
queries.push(relative.clone());
|
||||
queries.push(format!("{relative}/"));
|
||||
}
|
||||
collect_folder_queries(&node.children, queries);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_or_parent_is_ignored(relative_path: &str, ignored: &HashSet<String>) -> bool {
|
||||
if ignored.contains(relative_path) {
|
||||
return true;
|
||||
}
|
||||
let mut current = relative_path;
|
||||
while let Some((parent, _)) = current.rsplit_once('/') {
|
||||
if ignored.contains(parent) {
|
||||
return true;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn filter_folder_nodes(nodes: Vec<FolderNode>, ignored: &HashSet<String>) -> Vec<FolderNode> {
|
||||
nodes
|
||||
.into_iter()
|
||||
.filter_map(|mut node| {
|
||||
let relative = normalize_relative_path(&node.path);
|
||||
if path_or_parent_is_ignored(&relative, ignored) {
|
||||
return None;
|
||||
}
|
||||
node.children = filter_folder_nodes(node.children, ignored);
|
||||
Some(node)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn filter_gitignored_folders(
|
||||
vault_path: &Path,
|
||||
folders: Vec<FolderNode>,
|
||||
hide_enabled: bool,
|
||||
) -> Vec<FolderNode> {
|
||||
if !hide_enabled || folders.is_empty() {
|
||||
return folders;
|
||||
}
|
||||
|
||||
let mut queries = Vec::new();
|
||||
collect_folder_queries(&folders, &mut queries);
|
||||
let ignored = ignored_relative_paths(vault_path, &queries);
|
||||
if ignored.is_empty() {
|
||||
return folders;
|
||||
}
|
||||
|
||||
filter_folder_nodes(folders, &ignored)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_file(root: &Path, relative: &str, content: &str) {
|
||||
let path = root.join(relative);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
fn init_git_repo(root: &Path) {
|
||||
crate::hidden_command("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn entry(root: &Path, relative: &str) -> VaultEntry {
|
||||
VaultEntry {
|
||||
path: root.join(relative).to_string_lossy().to_string(),
|
||||
filename: relative.rsplit('/').next().unwrap().to_string(),
|
||||
title: relative.to_string(),
|
||||
..VaultEntry::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_paths(root: &Path, entries: &[VaultEntry]) -> Vec<String> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| relative_path(root, Path::new(&entry.path)).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_ignored_entries_with_git_style_negation() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_git_repo(dir.path());
|
||||
write_file(dir.path(), ".gitignore", "ignored/*\n!ignored/keep.md\n");
|
||||
write_file(dir.path(), "visible.md", "# Visible\n");
|
||||
write_file(dir.path(), "ignored/hidden.md", "# Hidden\n");
|
||||
write_file(dir.path(), "ignored/keep.md", "# Keep\n");
|
||||
|
||||
let filtered = filter_gitignored_entries(
|
||||
dir.path(),
|
||||
vec![
|
||||
entry(dir.path(), "visible.md"),
|
||||
entry(dir.path(), "ignored/hidden.md"),
|
||||
entry(dir.path(), "ignored/keep.md"),
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
entry_paths(dir.path(), &filtered),
|
||||
vec!["visible.md", "ignored/keep.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_ignored_entries_when_visibility_is_enabled() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_git_repo(dir.path());
|
||||
write_file(dir.path(), ".gitignore", "ignored/\n");
|
||||
|
||||
let entries = vec![entry(dir.path(), "ignored/hidden.md")];
|
||||
let filtered = filter_gitignored_entries(dir.path(), entries, false);
|
||||
assert_eq!(
|
||||
entry_paths(dir.path(), &filtered),
|
||||
vec!["ignored/hidden.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_ignored_folder_trees() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_git_repo(dir.path());
|
||||
write_file(dir.path(), ".gitignore", "generated/\n");
|
||||
fs::create_dir_all(dir.path().join("generated/nested")).unwrap();
|
||||
fs::create_dir_all(dir.path().join("notes")).unwrap();
|
||||
|
||||
let folders = vec![
|
||||
FolderNode {
|
||||
name: "generated".to_string(),
|
||||
path: "generated".to_string(),
|
||||
children: vec![FolderNode {
|
||||
name: "nested".to_string(),
|
||||
path: "generated/nested".to_string(),
|
||||
children: vec![],
|
||||
}],
|
||||
},
|
||||
FolderNode {
|
||||
name: "notes".to_string(),
|
||||
path: "notes".to_string(),
|
||||
children: vec![],
|
||||
},
|
||||
];
|
||||
|
||||
let filtered = filter_gitignored_folders(dir.path(), folders, true);
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].path, "notes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_large_ignored_folder_sets_without_blocking_on_git_stdout() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_git_repo(dir.path());
|
||||
write_file(dir.path(), ".gitignore", "generated/\n");
|
||||
|
||||
let folders = (0..6_000)
|
||||
.map(|index| FolderNode {
|
||||
name: format!("package-{index}"),
|
||||
path: format!("generated/package-{index}"),
|
||||
children: vec![],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let vault_path = dir.path().to_path_buf();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let filtered = filter_gitignored_folders(vault_path.as_path(), folders, true);
|
||||
let _ = sender.send(filtered);
|
||||
drop(dir);
|
||||
});
|
||||
|
||||
let filtered = receiver
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("large gitignored folder filtering should not block on child stdout");
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_no_effect_without_gitignore_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_git_repo(dir.path());
|
||||
|
||||
let entries = vec![entry(dir.path(), "notes/local.md")];
|
||||
let filtered = filter_gitignored_entries(dir.path(), entries, true);
|
||||
assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn filters_entries_with_real_paths_when_vault_root_is_symlinked() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let real_root = dir.path().join("real-vault");
|
||||
let symlink_root = dir.path().join("linked-vault");
|
||||
fs::create_dir_all(&real_root).unwrap();
|
||||
std::os::unix::fs::symlink(&real_root, &symlink_root).unwrap();
|
||||
init_git_repo(&symlink_root);
|
||||
write_file(&real_root, ".gitignore", "tmp/\n");
|
||||
write_file(&real_root, "visible.md", "# Visible\n");
|
||||
write_file(&real_root, "tmp/hidden.md", "# Hidden\n");
|
||||
|
||||
let filtered = filter_gitignored_entries(
|
||||
&symlink_root,
|
||||
vec![
|
||||
entry(&real_root, "visible.md"),
|
||||
entry(&real_root, "tmp/hidden.md"),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(entry_paths(&real_root, &filtered), vec!["visible.md"]);
|
||||
}
|
||||
}
|
||||
196
product-source/hololake-platform/src-tauri/src/vault/image.rs
Normal file
196
product-source/hololake-platform/src-tauri/src/vault/image.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
|
||||
fn is_safe_filename_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '.' | '-' | '_')
|
||||
}
|
||||
|
||||
/// Sanitize a filename by replacing unsafe characters with underscores.
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| if is_safe_filename_char(c) { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Image file extensions considered valid for drag-drop import.
|
||||
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"];
|
||||
|
||||
/// Prepare the attachments directory and generate a unique target path.
|
||||
fn prepare_attachment_path(vault_path: &str, filename: &str) -> Result<std::path::PathBuf, String> {
|
||||
let attachments_dir = Path::new(vault_path).join("attachments");
|
||||
fs::create_dir_all(&attachments_dir)
|
||||
.map_err(|e| format!("Failed to create attachments directory: {}", e))?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename));
|
||||
Ok(attachments_dir.join(unique_name))
|
||||
}
|
||||
|
||||
/// Save an uploaded image to the vault's attachments directory.
|
||||
/// Returns the absolute path to the saved file.
|
||||
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
|
||||
use base64::Engine;
|
||||
|
||||
let target_path = prepare_attachment_path(vault_path, filename)?;
|
||||
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.map_err(|e| format!("Invalid base64 data: {}", e))?;
|
||||
|
||||
fs::write(&target_path, bytes).map_err(|e| format!("Failed to write image: {}", e))?;
|
||||
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Copy an image file from a source path into the vault's attachments directory.
|
||||
/// Used for Tauri native drag-drop which provides absolute file paths.
|
||||
/// Returns the absolute path to the saved file.
|
||||
pub fn copy_image_to_vault(vault_path: &str, source_path: &str) -> Result<String, String> {
|
||||
let source = Path::new(source_path);
|
||||
if !source.exists() {
|
||||
return Err(format!("Source file does not exist: {}", source_path));
|
||||
}
|
||||
|
||||
let ext = source
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return Err(format!("Not a supported image format: {}", source_path));
|
||||
}
|
||||
|
||||
let filename = source
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("image");
|
||||
let target_path = prepare_attachment_path(vault_path, filename)?;
|
||||
|
||||
fs::copy(source, &target_path).map_err(|e| format!("Failed to copy image: {}", e))?;
|
||||
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_safe_chars() {
|
||||
assert_eq!(sanitize_filename("photo.png"), "photo.png");
|
||||
assert_eq!(sanitize_filename("my-image_01.jpg"), "my-image_01.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename_unsafe_chars() {
|
||||
assert_eq!(sanitize_filename("my file (1).png"), "my_file__1_.png");
|
||||
assert_eq!(sanitize_filename("path/to/img.png"), "path_to_img.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_image_creates_file() {
|
||||
use base64::Engine;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(b"fake image data");
|
||||
|
||||
let result = save_image(vault_path, "test.png", &data);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let saved_path = result.unwrap();
|
||||
assert!(std::path::Path::new(&saved_path).exists());
|
||||
assert!(saved_path.contains("attachments"));
|
||||
assert!(saved_path.contains("test.png"));
|
||||
|
||||
let content = fs::read(&saved_path).unwrap();
|
||||
assert_eq!(content, b"fake image data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_image_creates_attachments_dir() {
|
||||
use base64::Engine;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
let attachments = dir.path().join("attachments");
|
||||
assert!(!attachments.exists());
|
||||
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(b"test");
|
||||
save_image(vault_path, "img.png", &data).unwrap();
|
||||
assert!(attachments.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_image_invalid_base64() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let result = save_image(vault_path, "test.png", "not-valid-base64!!!");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_success() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
// Create a source image file
|
||||
let source_path = dir.path().join("source.png");
|
||||
fs::write(&source_path, b"fake png data").unwrap();
|
||||
|
||||
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
|
||||
let saved_path = result.unwrap();
|
||||
assert!(std::path::Path::new(&saved_path).exists());
|
||||
assert!(saved_path.contains("attachments"));
|
||||
assert!(saved_path.contains("source.png"));
|
||||
|
||||
let content = fs::read(&saved_path).unwrap();
|
||||
assert_eq!(content, b"fake png data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_nonexistent_source() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let result = copy_image_to_vault(vault_path, "/nonexistent/photo.png");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_rejects_non_image() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
let source_path = dir.path().join("document.pdf");
|
||||
fs::write(&source_path, b"fake pdf").unwrap();
|
||||
|
||||
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Not a supported image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_image_to_vault_accepts_all_extensions() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_str().unwrap();
|
||||
|
||||
for ext in &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] {
|
||||
let source_path = dir.path().join(format!("img.{}", ext));
|
||||
fs::write(&source_path, b"data").unwrap();
|
||||
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
||||
assert!(result.is_ok(), "failed for extension: {}", ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const LEGACY_IS_A_PREFIXES: [&str; 4] = ["is_a:", "\"Is A\":", "'Is A':", "Is A:"];
|
||||
|
||||
fn has_legacy_is_a(fm_content: &str) -> bool {
|
||||
fm_content.lines().any(|line| {
|
||||
let t = line.trim_start();
|
||||
LEGACY_IS_A_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| t.starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the value from a legacy `is_a` / `Is A` line.
|
||||
fn extract_is_a_value(line: &str) -> Option<&str> {
|
||||
let t = line.trim_start();
|
||||
for prefix in &LEGACY_IS_A_PREFIXES {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let v = rest.trim();
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn frontmatter_content(content: &str) -> Option<(usize, &str)> {
|
||||
if !content.starts_with("---\n") {
|
||||
return None;
|
||||
}
|
||||
let fm_end = content[4..].find("\n---")? + 4;
|
||||
Some((fm_end, &content[4..fm_end]))
|
||||
}
|
||||
|
||||
fn has_type_field(fm_content: &str) -> bool {
|
||||
fm_content
|
||||
.lines()
|
||||
.any(|line| line.trim_start().starts_with("type:"))
|
||||
}
|
||||
|
||||
fn migrated_frontmatter_lines(fm_content: &str) -> Option<Vec<String>> {
|
||||
if !has_legacy_is_a(fm_content) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut new_lines = Vec::new();
|
||||
let mut is_a_value = None;
|
||||
|
||||
for line in fm_content.lines() {
|
||||
match extract_is_a_value(line) {
|
||||
Some(value) => is_a_value = Some(value.to_string()),
|
||||
None => new_lines.push(line.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
let value = is_a_value?;
|
||||
if !has_type_field(fm_content) {
|
||||
new_lines.insert(0, format!("type: {}", value));
|
||||
}
|
||||
|
||||
Some(new_lines)
|
||||
}
|
||||
|
||||
/// Migrate a single file's frontmatter from `is_a`/`Is A` to `type`.
|
||||
/// Returns Ok(true) if the file was modified, Ok(false) if no migration needed.
|
||||
fn migrate_file_is_a_to_type(path: &Path) -> Result<bool, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
|
||||
let Some((fm_end, fm_content)) = frontmatter_content(&content) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(new_lines) = migrated_frontmatter_lines(fm_content) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let rest = &content[fm_end + 4..];
|
||||
let new_content = format!("---\n{}\n---{}", new_lines.join("\n"), rest);
|
||||
|
||||
fs::write(path, &new_content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Migrate all markdown files in the vault from `is_a`/`Is A` to `type`.
|
||||
/// Returns the number of files migrated.
|
||||
pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let mut migrated = 0;
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match migrate_file_is_a_to_type(path) {
|
||||
Ok(true) => {
|
||||
log::info!("Migrated is_a → type: {}", path.display());
|
||||
migrated += 1;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to migrate {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn assert_file_migration_result(content: &str, expected: bool) {
|
||||
let tmp = tempdir().unwrap();
|
||||
let path = write_file(tmp.path(), "note.md", content);
|
||||
|
||||
let result = migrate_file_is_a_to_type(&path).unwrap();
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
// --- has_legacy_is_a ---
|
||||
|
||||
#[test]
|
||||
fn test_has_legacy_is_a_detects_is_a_colon() {
|
||||
assert!(has_legacy_is_a("is_a: Person\nname: Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_legacy_is_a_detects_quoted_is_a() {
|
||||
assert!(has_legacy_is_a("\"Is A\": Note\nname: Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_legacy_is_a_detects_bare_is_a() {
|
||||
assert!(has_legacy_is_a("Is A: Topic\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_legacy_is_a_returns_false_for_clean_frontmatter() {
|
||||
assert!(!has_legacy_is_a("type: Person\nname: Alice"));
|
||||
}
|
||||
|
||||
// --- extract_is_a_value ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_is_a_value_from_is_a_colon() {
|
||||
assert_eq!(extract_is_a_value("is_a: Person"), Some("Person"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_is_a_value_from_quoted() {
|
||||
assert_eq!(extract_is_a_value("\"Is A\": Note"), Some("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_is_a_value_returns_none_for_unrelated_line() {
|
||||
assert_eq!(extract_is_a_value("name: Alice"), None);
|
||||
}
|
||||
|
||||
// --- migrate_file_is_a_to_type ---
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_adds_type_and_removes_is_a() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let path = write_file(
|
||||
tmp.path(),
|
||||
"note.md",
|
||||
"---\nis_a: Person\nname: Alice\n---\n# Alice\n",
|
||||
);
|
||||
let result = migrate_file_is_a_to_type(&path).unwrap();
|
||||
assert!(result, "file should be migrated");
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("type: Person"), "should have type field");
|
||||
assert!(!content.contains("is_a:"), "should not have is_a field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_skips_when_no_frontmatter() {
|
||||
assert_file_migration_result("# Just a heading\nNo frontmatter here.\n", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_skips_when_already_has_type() {
|
||||
assert_file_migration_result("---\ntype: Person\nname: Alice\n---\n# Alice\n", false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_file_skips_when_no_is_a_field() {
|
||||
assert_file_migration_result("---\nname: Alice\ndate: 2024-01-01\n---\n# Alice\n", false);
|
||||
}
|
||||
|
||||
// --- migrate_is_a_to_type (public function) ---
|
||||
|
||||
#[test]
|
||||
fn test_migrate_vault_returns_count_of_migrated_files() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_file(
|
||||
tmp.path(),
|
||||
"note1.md",
|
||||
"---\nis_a: Person\nname: Alice\n---\n",
|
||||
);
|
||||
write_file(tmp.path(), "note2.md", "---\nis_a: Topic\nname: AI\n---\n");
|
||||
write_file(
|
||||
tmp.path(),
|
||||
"note3.md",
|
||||
"---\ntype: Event\nname: Conf\n---\n",
|
||||
);
|
||||
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 2, "should migrate exactly 2 files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_vault_returns_error_for_nonexistent_path() {
|
||||
let result = migrate_is_a_to_type("/tmp/this-path-does-not-exist-laputa-test");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_vault_ignores_non_markdown_files() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_file(tmp.path(), "image.png", "not a markdown file");
|
||||
write_file(tmp.path(), "data.json", "{\"is_a\": \"test\"}");
|
||||
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 0, "non-markdown files should be ignored");
|
||||
}
|
||||
}
|
||||
512
product-source/hololake-platform/src-tauri/src/vault/mod.rs
Normal file
512
product-source/hololake-platform/src-tauri/src/vault/mod.rs
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
mod cache;
|
||||
mod config_seed;
|
||||
mod entry;
|
||||
mod file;
|
||||
pub(crate) mod filename_rules;
|
||||
mod folders;
|
||||
mod frontmatter;
|
||||
mod getting_started;
|
||||
mod ignored;
|
||||
mod image;
|
||||
mod migration;
|
||||
mod parsing;
|
||||
pub(crate) mod path_identity;
|
||||
mod rename;
|
||||
mod rename_transaction;
|
||||
mod title_sync;
|
||||
mod trash;
|
||||
mod type_templates;
|
||||
mod view_date_filters;
|
||||
mod view_migration;
|
||||
mod view_relationships;
|
||||
#[cfg(test)]
|
||||
mod view_tests;
|
||||
mod view_value_conversions;
|
||||
mod views;
|
||||
|
||||
pub use cache::{invalidate_cache, scan_vault_cached};
|
||||
pub use config_seed::{
|
||||
get_ai_guidance_status, migrate_agents_md, repair_config_files, restore_ai_guidance_files,
|
||||
seed_config_files, AiGuidanceFileState, WorkspaceAiGuidanceStatus,
|
||||
};
|
||||
pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{create_note_content, get_note_content, note_content_matches, save_note_content};
|
||||
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
auto_rename_untitled, detect_renames, move_note_to_folder, move_note_to_workspace, rename_note,
|
||||
rename_note_filename, update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,
|
||||
MoveNoteToFolderRequest, MoveNoteToWorkspaceRequest, RenameNoteFilenameRequest,
|
||||
RenameNoteRequest, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
pub use views::{
|
||||
delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode,
|
||||
FilterOp, ViewDefinition, ViewFile,
|
||||
};
|
||||
|
||||
use file::read_file_metadata;
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a, resolve_note_display, resolve_note_width};
|
||||
use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title};
|
||||
use type_templates::TypeTemplateSource;
|
||||
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
fn preferred_relationship_refs(
|
||||
relationships: &std::collections::HashMap<String, Vec<String>>,
|
||||
canonical_key: &str,
|
||||
legacy_key: &str,
|
||||
) -> Vec<String> {
|
||||
relationships
|
||||
.get(canonical_key)
|
||||
.cloned()
|
||||
.or_else(|| relationships.get(legacy_key).cloned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str) -> String {
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(content);
|
||||
let (frontmatter, _, _) = extract_fm_and_rels(parsed.data, content);
|
||||
extract_title(frontmatter.title.as_deref(), content, filename)
|
||||
}
|
||||
|
||||
fn resolve_entry_dates(
|
||||
fs_modified: Option<u64>,
|
||||
fs_created: Option<u64>,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
) -> (Option<u64>, Option<u64>) {
|
||||
match git_dates {
|
||||
Some((git_modified, git_created)) => {
|
||||
let modified_at = Some(fs_modified.map_or(git_modified, |fs| fs.max(git_modified)));
|
||||
(modified_at, Some(git_created))
|
||||
}
|
||||
None => (fs_modified, fs_created),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a single markdown file into a VaultEntry.
|
||||
///
|
||||
/// If `git_dates` is provided, `created_at` comes from git history while
|
||||
/// `modified_at` uses the newer of the latest git touch and the current
|
||||
/// filesystem modified time. Pass `None` to use filesystem dates only
|
||||
/// (appropriate for non-git vaults).
|
||||
pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> 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, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
|
||||
|
||||
let title = derive_markdown_title_from_content(&content, &filename);
|
||||
let has_h1 = parsing::extract_h1_title(&content).is_some();
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates);
|
||||
let is_a = resolve_is_a(frontmatter.is_a);
|
||||
let template = TypeTemplateSource {
|
||||
explicit_template: frontmatter
|
||||
.template
|
||||
.map(|value| value.into_scalar().unwrap_or_default()),
|
||||
is_a: is_a.as_deref(),
|
||||
title: &title,
|
||||
body: &parsed.content,
|
||||
}
|
||||
.resolve();
|
||||
|
||||
// Add "Type" relationship: isA becomes a navigable link to the type document.
|
||||
// Skip for type documents themselves (isA == "Type") to avoid self-referential links.
|
||||
if let Some(ref type_name) = is_a {
|
||||
if type_name != "Type" {
|
||||
let type_link = if type_name.starts_with("[[") && type_name.ends_with("]]") {
|
||||
type_name.clone()
|
||||
} else {
|
||||
format!("[[{}]]", type_name.to_lowercase())
|
||||
};
|
||||
relationships.insert("Type".to_string(), vec![type_link]);
|
||||
}
|
||||
}
|
||||
|
||||
let belongs_to = preferred_relationship_refs(&relationships, "belongs_to", "Belongs to");
|
||||
let related_to = preferred_relationship_refs(&relationships, "related_to", "Related to");
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename,
|
||||
title,
|
||||
is_a,
|
||||
snippet,
|
||||
relationships,
|
||||
aliases: frontmatter
|
||||
.aliases
|
||||
.map(|a| a.into_vec())
|
||||
.unwrap_or_default(),
|
||||
belongs_to,
|
||||
related_to,
|
||||
status: frontmatter.status.and_then(|v| v.into_scalar()),
|
||||
archived: frontmatter.archived.unwrap_or(false),
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
icon: frontmatter.icon.and_then(|v| v.into_scalar()),
|
||||
color: frontmatter.color.and_then(|v| v.into_scalar()),
|
||||
order: frontmatter.order,
|
||||
sidebar_label: frontmatter.sidebar_label.and_then(|v| v.into_scalar()),
|
||||
template,
|
||||
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
|
||||
view: frontmatter.view.and_then(|v| v.into_scalar()),
|
||||
note_width: resolve_note_width(frontmatter.note_width),
|
||||
display: resolve_note_display(frontmatter.display),
|
||||
visible: frontmatter.visible,
|
||||
organized: frontmatter.organized.unwrap_or(false),
|
||||
favorite: frontmatter.favorite.unwrap_or(false),
|
||||
favorite_index: frontmatter.favorite_index,
|
||||
list_properties_display: frontmatter.list_properties_display.unwrap_or_default(),
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
has_h1,
|
||||
file_kind: "markdown".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a non-markdown file into a minimal VaultEntry.
|
||||
/// Uses filename as title, except for `.yml` files where the YAML `name` field is used.
|
||||
pub(crate) fn parse_non_md_file(
|
||||
path: &Path,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
) -> Result<VaultEntry, String> {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = resolve_entry_dates(fs_modified, fs_created, git_dates);
|
||||
let file_kind = classify_file_kind(path).to_string();
|
||||
let title = extract_yml_name(path).unwrap_or_else(|| filename.clone());
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename: filename.clone(),
|
||||
title,
|
||||
file_kind,
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
..VaultEntry::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// For `.yml` files, try to extract the `name` field from the YAML content.
|
||||
fn extract_yml_name(path: &Path) -> Option<String> {
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if ext != "yml" && ext != "yaml" {
|
||||
return None;
|
||||
}
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let mapping: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
|
||||
mapping.get("name")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Uses filesystem dates (no git lookup) since the file was likely just saved.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
if is_md_file(path) {
|
||||
parse_md_file(path, None)
|
||||
} else {
|
||||
parse_non_md_file(path, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories hidden from user-facing vault scans.
|
||||
const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"];
|
||||
/// Keep type definitions in their dedicated sidebar section instead of the generic folder tree.
|
||||
const FOLDER_TREE_EXCLUDED_DIRS: &[&str] = &["type"];
|
||||
|
||||
fn is_hidden_dir(name: &str) -> bool {
|
||||
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
fn is_folder_tree_hidden_dir(name: &str) -> bool {
|
||||
is_hidden_dir(name) || FOLDER_TREE_EXCLUDED_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
pub(crate) fn is_md_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
/// Extensions recognized as editable text files (opened in raw editor).
|
||||
const TEXT_EXTENSIONS: &[&str] = &[
|
||||
"yml",
|
||||
"yaml",
|
||||
"json",
|
||||
"txt",
|
||||
"toml",
|
||||
"csv",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"py",
|
||||
"rs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"rb",
|
||||
"go",
|
||||
"java",
|
||||
"kt",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
"swift",
|
||||
"lua",
|
||||
"sql",
|
||||
"graphql",
|
||||
"env",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"properties",
|
||||
"makefile",
|
||||
"dockerfile",
|
||||
"gitignore",
|
||||
"editorconfig",
|
||||
"mdx",
|
||||
"svelte",
|
||||
"vue",
|
||||
"astro",
|
||||
"tf",
|
||||
"hcl",
|
||||
"nix",
|
||||
"zig",
|
||||
"hs",
|
||||
"ml",
|
||||
"ex",
|
||||
"exs",
|
||||
"erl",
|
||||
"clj",
|
||||
"lisp",
|
||||
"el",
|
||||
"vim",
|
||||
"r",
|
||||
"jl",
|
||||
"ps1",
|
||||
"bat",
|
||||
"cmd",
|
||||
];
|
||||
|
||||
/// Classify a file extension into "markdown", "text", or "binary".
|
||||
pub(crate) fn classify_file_kind(path: &Path) -> &'static str {
|
||||
let ext = match path.extension() {
|
||||
Some(e) => e.to_string_lossy().to_lowercase(),
|
||||
None => {
|
||||
// Files without extension: check if name itself is a known text file
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
return if [
|
||||
"makefile",
|
||||
"dockerfile",
|
||||
"rakefile",
|
||||
"gemfile",
|
||||
"procfile",
|
||||
"brewfile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".env",
|
||||
]
|
||||
.contains(&name.as_str())
|
||||
{
|
||||
"text"
|
||||
} else {
|
||||
"binary"
|
||||
};
|
||||
}
|
||||
};
|
||||
if ext == "md" || ext == "markdown" {
|
||||
"markdown"
|
||||
} else if TEXT_EXTENSIONS.contains(&ext.as_str()) {
|
||||
"text"
|
||||
} else {
|
||||
"binary"
|
||||
}
|
||||
}
|
||||
|
||||
use crate::git::GitDates;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn lookup_git_dates(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Option<(u64, u64)> {
|
||||
let rel = path_identity::vault_relative_path_string(vault_path, path).ok()?;
|
||||
git_dates.get(&rel).map(|d| (d.modified_at, d.created_at))
|
||||
}
|
||||
|
||||
fn try_parse_file(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let dates = lookup_git_dates(path, vault_path, git_dates);
|
||||
let result = if is_md_file(path) {
|
||||
parse_md_file(path, dates)
|
||||
} else {
|
||||
parse_non_md_file(path, dates)
|
||||
};
|
||||
match result {
|
||||
Ok(vault_entry) => entries.push(vault_entry),
|
||||
Err(e) => log::warn!("Skipping file: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all files in the vault, including subdirectories.
|
||||
/// Hidden directories (starting with `.`) are excluded.
|
||||
fn scan_all_files(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let walker = WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
if e.file_type().is_dir() {
|
||||
let name = e.file_name().to_string_lossy();
|
||||
// Skip the vault root itself (depth 0) — we only filter subdirs
|
||||
if e.depth() == 0 {
|
||||
return true;
|
||||
}
|
||||
return !is_hidden_dir(&name);
|
||||
}
|
||||
true
|
||||
});
|
||||
for entry in walker.filter_map(|e| e.ok()) {
|
||||
if entry.path().is_file() {
|
||||
// Skip hidden files (starting with '.') — e.g. .gitignore, .DS_Store
|
||||
let fname = entry.file_name().to_string_lossy();
|
||||
if fname.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
try_parse_file(entry.path(), vault_path, git_dates, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a directory recursively for all files and return VaultEntry for each.
|
||||
/// Pass an empty map for `git_dates` to use filesystem dates only.
|
||||
pub fn scan_vault(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Result<Vec<VaultEntry>, String> {
|
||||
if !vault_path.exists() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist: {}",
|
||||
vault_path.display()
|
||||
));
|
||||
}
|
||||
if !vault_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path is not a directory: {}",
|
||||
vault_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = rename::recover_pending_rename_transactions(vault_path) {
|
||||
log::warn!(
|
||||
"Failed to recover pending rename transactions in {}: {}",
|
||||
vault_path.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
scan_all_files(vault_path, git_dates, &mut entries);
|
||||
|
||||
entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Build a tree of user-created folders in the vault.
|
||||
pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
|
||||
if !vault_path.is_dir() {
|
||||
return Err(format!("Not a directory: {}", vault_path.display()));
|
||||
}
|
||||
fn build_tree(dir: &Path, vault_root: &Path) -> Vec<FolderNode> {
|
||||
let mut nodes: Vec<FolderNode> = Vec::new();
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return nodes,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if is_folder_tree_hidden_dir(&name) {
|
||||
continue;
|
||||
}
|
||||
let rel_path = path_identity::vault_relative_path_string(vault_root, &path)
|
||||
.unwrap_or_else(|_| {
|
||||
path_identity::normalize_path_for_identity(&path.to_string_lossy())
|
||||
});
|
||||
let children = build_tree(&path, vault_root);
|
||||
nodes.push(FolderNode {
|
||||
name,
|
||||
path: rel_path,
|
||||
children,
|
||||
});
|
||||
}
|
||||
nodes.sort_by_key(|node| node.name.to_lowercase());
|
||||
nodes
|
||||
}
|
||||
Ok(build_tree(vault_path, vault_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frontmatter_regression_tests.rs"]
|
||||
mod frontmatter_regression_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "modified_dates_tests.rs"]
|
||||
mod modified_dates_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "relationship_key_tests.rs"]
|
||||
mod relationship_key_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "system_metadata_tests.rs"]
|
||||
mod system_metadata_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
use super::*;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub(super) 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();
|
||||
}
|
||||
|
||||
pub(super) fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
#[path = "mod_tests/archival_metadata.rs"]
|
||||
mod archival_metadata;
|
||||
#[path = "mod_tests/basics.rs"]
|
||||
mod basics;
|
||||
#[path = "mod_tests/complex_frontmatter.rs"]
|
||||
mod complex_frontmatter;
|
||||
#[path = "mod_tests/display_metadata.rs"]
|
||||
mod display_metadata;
|
||||
#[path = "mod_tests/folder_and_file_kind.rs"]
|
||||
mod folder_and_file_kind;
|
||||
#[path = "mod_tests/journal_type_visibility.rs"]
|
||||
mod journal_type_visibility;
|
||||
#[path = "mod_tests/real_vault_consistency.rs"]
|
||||
mod real_vault_consistency;
|
||||
#[path = "mod_tests/relationships.rs"]
|
||||
mod relationships;
|
||||
#[path = "mod_tests/scan_and_file_access.rs"]
|
||||
mod scan_and_file_access;
|
||||
#[path = "mod_tests/type_and_links.rs"]
|
||||
mod type_and_links;
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
// rename_note tests are in vault/rename.rs
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
use super::*;
|
||||
|
||||
fn parse_archived_entry(file_name: &str, content: &str) -> VaultEntry {
|
||||
let dir = TempDir::new().unwrap();
|
||||
parse_test_entry(&dir, file_name, content)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_truthy_aliases() {
|
||||
let cases = [
|
||||
(
|
||||
"old-quarter.md",
|
||||
"---\narchived: true\n---\n# Old Quarter\n",
|
||||
"lowercase archived alias must be parsed",
|
||||
),
|
||||
(
|
||||
"old-quarter-2.md",
|
||||
"---\nArchived: true\n---\n# Old Quarter\n",
|
||||
"titlecase archived alias must be parsed",
|
||||
),
|
||||
(
|
||||
"old.md",
|
||||
"---\nArchived: Yes\n---\n# Old\n",
|
||||
"Archived: Yes must be parsed as true",
|
||||
),
|
||||
(
|
||||
"old2.md",
|
||||
"---\narchived: yes\n---\n# Old\n",
|
||||
"archived: yes must be parsed as true",
|
||||
),
|
||||
(
|
||||
"old3.md",
|
||||
"---\nArchived: YES\n---\n# Old\n",
|
||||
"Archived: YES must be parsed as true",
|
||||
),
|
||||
(
|
||||
"old-new.md",
|
||||
"---\n_archived: true\n---\n# Old\n",
|
||||
"_archived canonical key must be parsed",
|
||||
),
|
||||
];
|
||||
|
||||
for (file_name, content, message) in cases {
|
||||
let entry = parse_archived_entry(file_name, content);
|
||||
assert!(entry.archived, "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_falsy_inputs_and_absence() {
|
||||
let cases = [
|
||||
(
|
||||
"active2.md",
|
||||
"---\nArchived: No\n---\n# Active\n",
|
||||
"Archived: No must be parsed as false",
|
||||
),
|
||||
(
|
||||
"active3.md",
|
||||
"---\nArchived: \"false\"\n---\n# Active\n",
|
||||
"Archived: false must be parsed as false",
|
||||
),
|
||||
(
|
||||
"active4.md",
|
||||
"---\nArchived: 0\n---\n# Active\n",
|
||||
"Archived: 0 must be parsed as false",
|
||||
),
|
||||
(
|
||||
"active5.md",
|
||||
"---\nIs A: Note\n---\n# Active\n",
|
||||
"absent archived must default to false",
|
||||
),
|
||||
];
|
||||
|
||||
for (file_name, content, message) in cases {
|
||||
let entry = parse_archived_entry(file_name, content);
|
||||
assert!(!entry.archived, "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_favorite_fields() {
|
||||
let favorite = parse_archived_entry(
|
||||
"fav.md",
|
||||
"---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n",
|
||||
);
|
||||
assert!(favorite.favorite);
|
||||
assert_eq!(favorite.favorite_index, Some(3));
|
||||
|
||||
let not_favorite = parse_archived_entry("not-fav.md", "---\ntype: Note\n---\n# Not Fav\n");
|
||||
assert!(!not_favorite.favorite);
|
||||
assert_eq!(not_favorite.favorite_index, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_visible_values() {
|
||||
let cases = [
|
||||
(
|
||||
"journal.md",
|
||||
"---\ntype: Type\nvisible: false\n---\n# Journal\n",
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"project.md",
|
||||
"---\ntype: Type\nvisible: true\n---\n# Project\n",
|
||||
Some(true),
|
||||
),
|
||||
("missing.md", "---\ntype: Type\n---\n# Project\n", None),
|
||||
];
|
||||
|
||||
for (file_name, content, expected) in cases {
|
||||
let entry = parse_archived_entry(file_name, content);
|
||||
assert_eq!(
|
||||
entry.visible, expected,
|
||||
"unexpected visible value for {file_name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archived_true_with_extra_non_string_fields() {
|
||||
let entry = parse_archived_entry(
|
||||
"archived-extra.md",
|
||||
"---\nArchived: true\norder: 5\n---\n# Archived Note\n",
|
||||
);
|
||||
assert!(entry.archived);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let frontmatter = [
|
||||
"---",
|
||||
"type: Essay",
|
||||
"Notes:",
|
||||
" - \"[[slug|{\"Broken\": 'quotes'}]]\"",
|
||||
"Archived: true",
|
||||
"---",
|
||||
"",
|
||||
"# Archived Essay",
|
||||
];
|
||||
let content = frontmatter.join("\n");
|
||||
create_test_file(dir.path(), "archived-essay.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("archived-essay.md"), None).unwrap();
|
||||
assert!(entry.archived);
|
||||
assert_eq!(entry.is_a, Some("Essay".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_not_in_relationships_or_properties() {
|
||||
let entry = parse_archived_entry(
|
||||
"journal.md",
|
||||
"---\ntype: Type\nvisible: false\n---\n# Journal\n",
|
||||
);
|
||||
assert!(!entry.relationships.contains_key("visible"));
|
||||
assert!(!entry.properties.contains_key("visible"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_type_aliases_parse_correctly() {
|
||||
let cases = [
|
||||
("quarter/q1.md", "---\ntype: Quarter\n---\n# Q1 2026\n"),
|
||||
("quarter/q1-is-a.md", "---\nIs A: Quarter\n---\n# Q1 2026\n"),
|
||||
(
|
||||
"quarter/q1-snake.md",
|
||||
"---\nis_a: Quarter\n---\n# Q1 2026\n",
|
||||
),
|
||||
];
|
||||
|
||||
for (file_name, content) in cases {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, file_name, content);
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_or_list_normalization_keeps_type_and_scalar_fields() {
|
||||
let single_status = parse_archived_entry(
|
||||
"test.md",
|
||||
"---\ntype: Project\nStatus:\n - Active\n---\n# Test\n",
|
||||
);
|
||||
assert_eq!(single_status.status, Some("Active".to_string()));
|
||||
assert_eq!(single_status.is_a, Some("Project".to_string()));
|
||||
|
||||
let scalar_fields = parse_archived_entry(
|
||||
"scalar.md",
|
||||
"---\ntype: Project\nOwner: Luca\nCadence: Daily\nStatus: Done\n---\n# Test\n",
|
||||
);
|
||||
assert_eq!(
|
||||
scalar_fields
|
||||
.properties
|
||||
.get("Owner")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
assert_eq!(
|
||||
scalar_fields
|
||||
.properties
|
||||
.get("Cadence")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Daily")
|
||||
);
|
||||
assert_eq!(scalar_fields.status, Some("Done".to_string()));
|
||||
|
||||
let absent_fields = parse_archived_entry("absent.md", "---\ntype: Note\n---\n# Test\n");
|
||||
assert_eq!(absent_fields.status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_field_does_not_break_type_detection() {
|
||||
let entry = parse_archived_entry(
|
||||
"array-fields.md",
|
||||
"---\ntype: Responsibility\nOwner:\n - Luca\nCadence:\n - Weekly\nStatus:\n - Active\n---\n# My Responsibility\n",
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Responsibility".to_string()));
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
use super::*;
|
||||
|
||||
const FULL_FM_CONTENT: &str = "---\ntitle: Laputa Project\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n";
|
||||
|
||||
#[test]
|
||||
fn test_reload_entry_returns_fresh_data() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"my-note.md",
|
||||
"---\ntitle: My Note\nStatus: Active\n---\n# My Note\n\nOriginal.",
|
||||
);
|
||||
let entry = reload_entry(&dir.path().join("my-note.md")).unwrap();
|
||||
assert_eq!(entry.title, "My Note");
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"my-note.md",
|
||||
"---\ntitle: My Note\nStatus: Done\n---\n# My Note\n\nUpdated.",
|
||||
);
|
||||
let fresh = reload_entry(&dir.path().join("my-note.md")).unwrap();
|
||||
assert_eq!(fresh.status, Some("Done".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_entry_nonexistent_file() {
|
||||
let result = reload_entry(std::path::Path::new("/nonexistent/path/note.md"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_frontmatter_identity() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
|
||||
assert_eq!(entry.title, "Laputa Project");
|
||||
assert_eq!(entry.is_a, Some("Project".to_string()));
|
||||
assert_eq!(entry.filename, "laputa.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_frontmatter_lists() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
|
||||
assert_eq!(entry.aliases, vec!["Laputa", "Castle in the Sky"]);
|
||||
assert!(!entry.relationships.contains_key("Belongs to"));
|
||||
assert!(!entry.relationships.contains_key("Related to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_frontmatter_scalars() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "laputa.md", FULL_FM_CONTENT);
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get("Owner")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get("Cadence")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Weekly")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"just-a-title.md",
|
||||
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
entry.title.as_str(),
|
||||
entry.aliases.is_empty(),
|
||||
entry.belongs_to.is_empty(),
|
||||
entry.status.as_deref(),
|
||||
),
|
||||
("Just a Title", true, true, 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(), "a-note-without-frontmatter.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md"), None).unwrap();
|
||||
assert_eq!(entry.title, "A Note Without Frontmatter");
|
||||
}
|
||||
|
||||
#[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"), None).unwrap();
|
||||
assert_eq!(entry.aliases, vec!["SingleAlias"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
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"), None);
|
||||
assert!(entry.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_md_file_has_snippet() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world! This is a snippet.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.snippet, "Hello, world! This is a snippet.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_md_file_has_word_count() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_md_file_word_count_empty_body() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Empty Note\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 0);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
use super::*;
|
||||
|
||||
struct FrontmatterCase {
|
||||
file_name: &'static str,
|
||||
content: &'static str,
|
||||
expected_type: &'static str,
|
||||
context: &'static str,
|
||||
}
|
||||
|
||||
const COMPLEX_NOTE_CONTENT: &str = r#"---
|
||||
type: Note
|
||||
_organized: true
|
||||
aliases: ["My Complex Note"]
|
||||
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
|
||||
"Created at": "2021-12-31T14:19:00.000Z"
|
||||
Status: Published
|
||||
Owner: "[[person-luca|Luca]]"
|
||||
---
|
||||
# My Complex Note
|
||||
|
||||
Content here.
|
||||
"#;
|
||||
|
||||
const WRITING_NOTE_CONTENT: &str = r#"---
|
||||
Is A: Evergreen
|
||||
_organized: true
|
||||
aliases: ["Writing for Clarity vs. Writing for Credit"]
|
||||
Topics: ["[[topic-writing]]"]
|
||||
Status: Published
|
||||
"Last edited": "2024-06-15T10:30:00.000Z"
|
||||
notion_id: "abc123def456"
|
||||
---
|
||||
# Writing for Clarity vs. Writing for Credit
|
||||
|
||||
Content.
|
||||
"#;
|
||||
|
||||
const YES_NOTE_CONTENT: &str = "---\ntype: Note\n_organized: Yes\nStatus: Draft\n---\n# Test\n";
|
||||
|
||||
const TIMESTAMP_NOTE_CONTENT: &str =
|
||||
"---\ntype: Note\n_organized: true\nCreated at: 2021-12-31T14:19:00.000Z\nTopics:\n - \"[[topic-writing]]\"\n---\n# Test\n";
|
||||
|
||||
const FLOW_ARRAY_CONTENT: &str = r#"---
|
||||
type: Evergreen
|
||||
_organized: true
|
||||
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
|
||||
Has: ["[[note-one]]", "[[note-two]]", "[[note-three]]"]
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
|
||||
const NOTION_IMPORT_CONTENT: &str = r#"---
|
||||
type: Readings
|
||||
aliases:
|
||||
- "1 to 1s"
|
||||
"Discarded for digest?": false
|
||||
"Note Status": Saved
|
||||
URL: "http://theengineeringmanager.com/management-101/121s/"
|
||||
Author: James Stanier
|
||||
Category: Articles
|
||||
"Full Title": 1 to 1s
|
||||
Highlights: 21
|
||||
"Last Synced": 2025-12-10
|
||||
"Last Highlighted": 2021-04-12
|
||||
notion_id: 2c5bdf02-815c-81ce-9dce-eca60ddaeb08
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# 1 to 1s
|
||||
|
||||
Content.
|
||||
"#;
|
||||
|
||||
const BITCOIN_NOTE_CONTENT: &str = r#"---
|
||||
type: Note
|
||||
workspace: personal
|
||||
notion_id: de48a4ad-e7ad-42aa-a5ce-1efdc259d7f9
|
||||
"Created at": "2021-12-17T10:21:00.000Z"
|
||||
Reviewed: True
|
||||
Topics:
|
||||
- "[[web3|Web3 / Crypto]]"
|
||||
URL: https://studio.glassnode.com/metrics?a=BTC&category=Market%20Indicators&m=indicators.NetUnrealizedProfitLoss&s=1320105600&u=1639267199&zoom=
|
||||
aliases:
|
||||
- Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
|
||||
- Note
|
||||
Trashed: true
|
||||
"Trashed at": 2026-03-11
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
|
||||
"#;
|
||||
|
||||
fn complex_frontmatter_cases() -> [FrontmatterCase; 7] {
|
||||
[
|
||||
FrontmatterCase {
|
||||
file_name: "complex-note.md",
|
||||
content: COMPLEX_NOTE_CONTENT,
|
||||
expected_type: "Note",
|
||||
context: "type must be parsed correctly with complex frontmatter",
|
||||
},
|
||||
FrontmatterCase {
|
||||
file_name: "writing-note.md",
|
||||
content: WRITING_NOTE_CONTENT,
|
||||
expected_type: "Evergreen",
|
||||
context: "Is A must be parsed correctly with quoted keys nearby",
|
||||
},
|
||||
FrontmatterCase {
|
||||
file_name: "yes-note.md",
|
||||
content: YES_NOTE_CONTENT,
|
||||
expected_type: "Note",
|
||||
context: "_organized: Yes must be parsed as true",
|
||||
},
|
||||
FrontmatterCase {
|
||||
file_name: "timestamp-note.md",
|
||||
content: TIMESTAMP_NOTE_CONTENT,
|
||||
expected_type: "Note",
|
||||
context: "type must survive unquoted timestamp in sibling field",
|
||||
},
|
||||
FrontmatterCase {
|
||||
file_name: "flow-array.md",
|
||||
content: FLOW_ARRAY_CONTENT,
|
||||
expected_type: "Evergreen",
|
||||
context: "type must survive flow arrays with wikilinks",
|
||||
},
|
||||
FrontmatterCase {
|
||||
file_name: "1-to-1s.md",
|
||||
content: NOTION_IMPORT_CONTENT,
|
||||
expected_type: "Readings",
|
||||
context: "type must be parsed with Notion-style quoted keys",
|
||||
},
|
||||
FrontmatterCase {
|
||||
file_name: "bitcoin-note.md",
|
||||
content: BITCOIN_NOTE_CONTENT,
|
||||
expected_type: "Note",
|
||||
context: "type must be parsed correctly even with unquoted colon in alias",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn assert_complex_frontmatter_case(case: FrontmatterCase) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, case.file_name, case.content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some(case.expected_type.to_string()),
|
||||
"{}",
|
||||
case.context
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"{} must keep _organized=true",
|
||||
case.file_name
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_frontmatter_preserves_type_and_organized() {
|
||||
for case in complex_frontmatter_cases() {
|
||||
assert_complex_frontmatter_case(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_type_and_organized() {
|
||||
use super::frontmatter::extract_fm_and_rels;
|
||||
|
||||
let raw_content =
|
||||
"---\ntype: Note\n_organized: true\nBroken: value: with: colons\n---\n# Test\n";
|
||||
let (frontmatter, _, _) = extract_fm_and_rels(None, raw_content);
|
||||
assert_eq!(
|
||||
frontmatter
|
||||
.is_a
|
||||
.as_ref()
|
||||
.and_then(|value| value.clone().into_scalar()),
|
||||
Some("Note".to_string())
|
||||
);
|
||||
assert_eq!(frontmatter.organized, Some(true));
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn assert_string_property(entry: &VaultEntry, key: &str, expected: &str) {
|
||||
assert_eq!(
|
||||
entry.properties.get(key).and_then(|value| value.as_str()),
|
||||
Some(expected),
|
||||
"unexpected value for {key}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
|
||||
let entry = parse_test_entry(&dir, "news.md", content);
|
||||
assert_eq!(entry.sidebar_label, Some("News".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_display_from_note_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Project\n_display: sheet\n---\nMetric,January\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert_eq!(entry.display, Some("sheet".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_display_text_from_note_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Project\n_display: text\n---\nMetric,January\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert_eq!(entry.display, Some("text".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_metadata_not_in_properties_or_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Project\n_display: sheet\n---\nMetric,January\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(!entry.properties.contains_key("_display"));
|
||||
assert!(!entry.relationships.contains_key("_display"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert_eq!(entry.sidebar_label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sidebar_label_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
|
||||
let entry = parse_test_entry(&dir, "series.md", content);
|
||||
assert!(!entry.relationships.contains_key("sidebar label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_block_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
let template = entry.template.unwrap();
|
||||
assert!(template.contains("## Objective"));
|
||||
assert!(template.contains("## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_from_type_body_when_it_looks_like_a_template() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n\n# Book\n\nTitle:\nAuthor:\n\n## Summary\n";
|
||||
let entry = parse_test_entry(&dir, "book.md", content);
|
||||
assert_eq!(
|
||||
entry.template,
|
||||
Some("Title:\nAuthor:\n\n## Summary".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_descriptive_type_body_is_not_a_template() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n\n# Project\n\nProjects are time-bound efforts with a clear outcome.\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert_eq!(entry.template, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Note\n";
|
||||
let entry = parse_test_entry(&dir, "note.md", content);
|
||||
assert_eq!(entry.template, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(!entry.relationships.contains_key("template"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sort_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"modified:desc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert_eq!(entry.sort, Some("modified:desc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sort_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert_eq!(entry.sort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(!entry.relationships.contains_key("sort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_not_in_properties() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(!entry.properties.contains_key("sort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_scalar_values() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Priority: High
|
||||
Rating: 5
|
||||
Due date: 2026-06-15
|
||||
Reviewed: true
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "project/test.md", content);
|
||||
let expected: HashMap<String, serde_json::Value> = [
|
||||
("Priority".into(), serde_json::Value::String("High".into())),
|
||||
("Rating".into(), serde_json::json!(5)),
|
||||
(
|
||||
"Due date".into(),
|
||||
serde_json::Value::String("2026-06-15".into()),
|
||||
),
|
||||
("Reviewed".into(), serde_json::Value::Bool(true)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(entry.properties, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_structural_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: Luca
|
||||
Cadence: Weekly
|
||||
Archived: false
|
||||
Priority: High
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "project/test.md", content);
|
||||
assert_eq!(entry.properties.len(), 3);
|
||||
for (key, value) in [
|
||||
("Priority", "High"),
|
||||
("Owner", "Luca"),
|
||||
("Cadence", "Weekly"),
|
||||
] {
|
||||
assert_string_property(&entry, key, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_folders_returns_tree() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("projects/laputa")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("areas")).unwrap();
|
||||
|
||||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||||
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
|
||||
assert!(names.contains(&"projects"));
|
||||
assert!(names.contains(&"areas"));
|
||||
|
||||
let projects = folders
|
||||
.iter()
|
||||
.find(|folder| folder.name == "projects")
|
||||
.unwrap();
|
||||
assert_eq!(projects.children.len(), 1);
|
||||
assert_eq!(projects.children[0].name, "laputa");
|
||||
assert_eq!(projects.children[0].path, "projects/laputa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_folders_excludes_hidden() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".git")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".laputa")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("visible")).unwrap();
|
||||
|
||||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].name, "visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_folders_keeps_default_vault_folders_visible() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("attachments")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("type")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("views")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("projects")).unwrap();
|
||||
|
||||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||||
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
|
||||
|
||||
assert_eq!(names, vec!["attachments", "projects", "views"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_folders_flat_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note.md", "# Note\n");
|
||||
|
||||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||||
assert!(folders.is_empty(), "flat vault has no visible folders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_properties_display_values_and_non_leakage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\n_list_properties_display:\n - rating\n - genre\n---\n# Movies\n";
|
||||
let entry = parse_test_entry(&dir, "movies.md", content);
|
||||
assert_eq!(entry.list_properties_display, vec!["rating", "genre"]);
|
||||
assert!(!entry.properties.contains_key("_list_properties_display"));
|
||||
assert!(!entry.relationships.contains_key("_list_properties_display"));
|
||||
|
||||
let absent = parse_test_entry(&dir, "books.md", "---\ntype: Type\n---\n# Books\n");
|
||||
assert!(absent.list_properties_display.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_markdown_files_use_expected_titles_and_kinds() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let named_yml_path = dir.path().join("active-projects.yml");
|
||||
std::fs::write(
|
||||
&named_yml_path,
|
||||
"name: Active Projects\nicon: rocket\ncolor: blue\n",
|
||||
)
|
||||
.unwrap();
|
||||
let named_yml_entry = super::parse_non_md_file(&named_yml_path, None).unwrap();
|
||||
assert_eq!(named_yml_entry.title, "Active Projects");
|
||||
assert_eq!(named_yml_entry.filename, "active-projects.yml");
|
||||
|
||||
let unnamed_yml_path = dir.path().join("config.yml");
|
||||
std::fs::write(&unnamed_yml_path, "key: value\n").unwrap();
|
||||
let unnamed_yml_entry = super::parse_non_md_file(&unnamed_yml_path, None).unwrap();
|
||||
assert_eq!(unnamed_yml_entry.title, "config.yml");
|
||||
|
||||
let txt_path = dir.path().join("notes.txt");
|
||||
std::fs::write(&txt_path, "some content").unwrap();
|
||||
let txt_entry = super::parse_non_md_file(&txt_path, None).unwrap();
|
||||
assert_eq!(txt_entry.title, "notes.txt");
|
||||
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"views/my-view.yml",
|
||||
"name: My View\nicon: rocket\n",
|
||||
);
|
||||
let text_entry = super::parse_non_md_file(&dir.path().join("views/my-view.yml"), None).unwrap();
|
||||
assert_eq!(text_entry.file_kind, "text");
|
||||
assert_eq!(text_entry.title, "My View");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_file_kind_by_extension() {
|
||||
for (path, expected_kind) in [
|
||||
("views/active-projects.yml", "text"),
|
||||
("config.yaml", "text"),
|
||||
("data.json", "text"),
|
||||
("script.py", "text"),
|
||||
("readme.txt", "text"),
|
||||
("note.md", "markdown"),
|
||||
("README.markdown", "markdown"),
|
||||
("photo.png", "binary"),
|
||||
("archive.zip", "binary"),
|
||||
] {
|
||||
assert_eq!(classify_file_kind(Path::new(path)), expected_kind, "{path}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_preserves_explicit_journal_type_definition() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"journal.md",
|
||||
"---\ntype: Type\nvisible: true\n---\n# Journal\n",
|
||||
);
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"2026-03-11.md",
|
||||
"---\ntitle: March 11\ntype: Journal\n---\n# March 11\n",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
let journal_type = entries
|
||||
.iter()
|
||||
.find(|entry| entry.filename == "journal.md")
|
||||
.expect("expected the explicit Journal type file to be scanned");
|
||||
assert_eq!(journal_type.title, "Journal");
|
||||
assert_eq!(journal_type.is_a.as_deref(), Some("Type"));
|
||||
assert_eq!(journal_type.visible, Some(true));
|
||||
|
||||
let journal_note = entries
|
||||
.iter()
|
||||
.find(|entry| entry.filename == "2026-03-11.md")
|
||||
.expect("expected the Journal note to be scanned");
|
||||
assert_eq!(journal_note.is_a.as_deref(), Some("Journal"));
|
||||
assert_eq!(
|
||||
journal_note.relationships.get("Type"),
|
||||
Some(&vec!["[[journal]]".to_string()]),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct FrontmatterExpectations {
|
||||
has_type_with_value: bool,
|
||||
has_organized_true: bool,
|
||||
}
|
||||
|
||||
fn frontmatter_block(content: &str) -> Option<&str> {
|
||||
if !content.starts_with("---\n") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let end = content[4..].find("\n---")?;
|
||||
Some(&content[4..end + 4])
|
||||
}
|
||||
|
||||
fn frontmatter_expectations(content: &str) -> Option<FrontmatterExpectations> {
|
||||
let block = frontmatter_block(content)?;
|
||||
let has_type_with_value = block.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
["type:", "Is A:", "is_a:"]
|
||||
.iter()
|
||||
.find_map(|prefix| trimmed.strip_prefix(prefix))
|
||||
.is_some_and(|rest| !rest.trim().is_empty())
|
||||
});
|
||||
let has_organized_true = block.lines().any(|line| {
|
||||
matches!(
|
||||
line.trim(),
|
||||
"_organized: true" | "_organized: True" | "_organized: yes" | "_organized: Yes"
|
||||
)
|
||||
});
|
||||
|
||||
if !has_type_with_value && !has_organized_true {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(FrontmatterExpectations {
|
||||
has_type_with_value,
|
||||
has_organized_true,
|
||||
})
|
||||
}
|
||||
|
||||
fn mismatch_messages(
|
||||
path: &Path,
|
||||
expectations: FrontmatterExpectations,
|
||||
parsed: &VaultEntry,
|
||||
) -> Vec<String> {
|
||||
let mut mismatches = Vec::new();
|
||||
if expectations.has_type_with_value && parsed.is_a.is_none() {
|
||||
mismatches.push(format!(
|
||||
"TYPE MISSING: {} (raw has type with value but parsed isA=None)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if expectations.has_organized_true && !parsed.organized {
|
||||
mismatches.push(format!(
|
||||
"ORGANIZED MISSING: {} (raw has _organized: true but parsed organized=false)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
mismatches
|
||||
}
|
||||
|
||||
fn parse_real_vault_mismatches(vault_path: &Path) -> Vec<String> {
|
||||
let mut mismatches = Vec::new();
|
||||
let walker = walkdir::WalkDir::new(vault_path)
|
||||
.into_iter()
|
||||
.filter_entry(|entry| !entry.file_name().to_string_lossy().starts_with('.'));
|
||||
|
||||
for dir_entry in walker.filter_map(|entry| entry.ok()) {
|
||||
let path = dir_entry.path();
|
||||
if !path.is_file() || path.extension().is_none_or(|ext| ext != "md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(content) = std::fs::read_to_string(path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(expectations) = frontmatter_expectations(&content) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match parse_md_file(path, None) {
|
||||
Ok(parsed) => mismatches.extend(mismatch_messages(path, expectations, &parsed)),
|
||||
Err(error) => mismatches.push(format!("PARSE ERROR: {} -> {}", path.display(), error)),
|
||||
}
|
||||
}
|
||||
|
||||
mismatches
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_real_vault_type_and_organized_consistency() {
|
||||
let vault_path = Path::new("/Users/luca/Laputa");
|
||||
if !vault_path.exists() {
|
||||
eprintln!("Skipping: ~/Laputa vault not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let mismatches = parse_real_vault_mismatches(vault_path);
|
||||
if mismatches.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let summary = mismatches
|
||||
.iter()
|
||||
.take(20)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
panic!(
|
||||
"Found {} parsing mismatches in real vault:\n{}",
|
||||
mismatches.len(),
|
||||
summary
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn assert_relationship_values(
|
||||
relationships: &HashMap<String, Vec<String>>,
|
||||
expected: &[(&str, &[&str])],
|
||||
) {
|
||||
for (key, values) in expected {
|
||||
let actual = relationships.get(*key).unwrap();
|
||||
let expected_values = values
|
||||
.iter()
|
||||
.map(|value| (*value).to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(actual, &expected_values, "relationship {key} mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_array() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Responsibility
|
||||
Has:
|
||||
- "[[essay/foo|Foo Essay]]"
|
||||
- "[[essay/bar|Bar Essay]]"
|
||||
Topics:
|
||||
- "[[topic/rust]]"
|
||||
- "[[topic/wasm]]"
|
||||
Status: Active
|
||||
---
|
||||
# Publish Essays
|
||||
"#;
|
||||
create_test_file(dir.path(), "publish-essays.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("publish-essays.md"), None).unwrap();
|
||||
assert_eq!(entry.relationships.len(), 3);
|
||||
assert_relationship_values(
|
||||
&entry.relationships,
|
||||
&[
|
||||
(
|
||||
"Has",
|
||||
&["[[essay/foo|Foo Essay]]", "[[essay/bar|Bar Essay]]"],
|
||||
),
|
||||
("Topics", &["[[topic/rust]]", "[[topic/wasm]]"]),
|
||||
("Type", &["[[responsibility]]"]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_single_string() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Project
|
||||
Owner: "[[person/luca-rossi|Luca Rossi]]"
|
||||
Belongs to:
|
||||
- "[[responsibility/grow-newsletter]]"
|
||||
---
|
||||
# Some Project
|
||||
"#;
|
||||
create_test_file(dir.path(), "some-project.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md"), None).unwrap();
|
||||
assert!(entry.relationships.contains_key("Owner"));
|
||||
assert!(!entry.properties.contains_key("Owner"));
|
||||
assert_relationship_values(
|
||||
&entry.relationships,
|
||||
&[("Belongs to", &["[[responsibility/grow-newsletter]]"])],
|
||||
);
|
||||
assert_eq!(entry.belongs_to, vec!["[[responsibility/grow-newsletter]]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_ignores_non_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
Tags:
|
||||
- productivity
|
||||
- writing
|
||||
Custom Field: just a plain string
|
||||
---
|
||||
# A Note
|
||||
"#;
|
||||
create_test_file(dir.path(), "plain-note.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("plain-note.md"), None).unwrap();
|
||||
assert_eq!(entry.relationships.len(), 1);
|
||||
assert_relationship_values(&entry.relationships, &[("Type", &["[[note]]"])]);
|
||||
}
|
||||
|
||||
const BIG_PROJECT_CONTENT: &str = "---\nIs A: Project\nHas:\n - \"[[deliverable/mvp]]\"\n - \"[[deliverable/v2]]\"\nTopics:\n - \"[[topic/ai]]\"\n - \"[[topic/compilers]]\"\nEvents:\n - \"[[event/launch-day]]\"\nNotes:\n - \"[[note/design-rationale]]\"\n - \"[[note/meeting-2024-01]]\"\n - \"[[note/meeting-2024-02]]\"\nOwner: \"[[person/alice]]\"\nRelated to:\n - \"[[project/sibling-project]]\"\nBelongs to:\n - \"[[area/engineering]]\"\nStatus: Active\n---\n# Big Project\n";
|
||||
|
||||
fn parse_big_project_relationships() -> HashMap<String, Vec<String>> {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "big-project.md", BIG_PROJECT_CONTENT);
|
||||
entry.relationships
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_custom_fields() {
|
||||
let relationships = parse_big_project_relationships();
|
||||
assert_eq!(relationships.get("Has").unwrap().len(), 2);
|
||||
assert_eq!(relationships.get("Topics").unwrap().len(), 2);
|
||||
assert_eq!(relationships.get("Events").unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_owner_and_notes() {
|
||||
let relationships = parse_big_project_relationships();
|
||||
assert_eq!(relationships.get("Notes").unwrap().len(), 3);
|
||||
assert!(relationships.contains_key("Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_builtin_wikilink_fields() {
|
||||
let relationships = parse_big_project_relationships();
|
||||
assert_eq!(relationships.get("Related to").unwrap().len(), 1);
|
||||
assert_eq!(relationships.get("Belongs to").unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_skip_keys_excluded_from_generic() {
|
||||
let relationships = parse_big_project_relationships();
|
||||
assert!(!relationships.contains_key("Status"));
|
||||
assert!(!relationships.contains_key("Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_single_vs_array_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Mentor: "[[person/bob|Bob Smith]]"
|
||||
Reviewers:
|
||||
- "[[person/carol]]"
|
||||
- "[[person/dave]]"
|
||||
Context: "[[area/research]]"
|
||||
---
|
||||
# A Note
|
||||
"#;
|
||||
create_test_file(dir.path(), "single-vs-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("single-vs-array.md"), None).unwrap();
|
||||
assert_relationship_values(
|
||||
&entry.relationships,
|
||||
&[
|
||||
("Mentor", &["[[person/bob|Bob Smith]]"]),
|
||||
("Reviewers", &["[[person/carol]]", "[[person/dave]]"]),
|
||||
("Context", &["[[area/research]]"]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const SKIP_KEYS_CONTENT: &str = "---\nIs A: \"[[project]]\"\nAliases:\n - \"[[alias/foo]]\"\nStatus: \"[[status/active]]\"\nCadence: \"[[cadence/weekly]]\"\nCreated at: \"[[time/2024-01-01]]\"\nCreated time: \"[[time/noon]]\"\nReal Relation: \"[[note/important]]\"\n---\n# Skip Keys Test\n";
|
||||
|
||||
fn parse_skip_key_relationships() -> (HashMap<String, Vec<String>>, usize) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(&dir, "skip-keys.md", SKIP_KEYS_CONTENT);
|
||||
let relationship_count = entry.relationships.len();
|
||||
(entry.relationships, relationship_count)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_keys_identity_fields_excluded() {
|
||||
let (relationships, _) = parse_skip_key_relationships();
|
||||
assert!(!relationships.contains_key("Is A"));
|
||||
assert!(!relationships.contains_key("Aliases"));
|
||||
assert!(!relationships.contains_key("Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_keys_temporal_fields_excluded() {
|
||||
let (relationships, _) = parse_skip_key_relationships();
|
||||
for key in ["Cadence", "Created at", "Created time"] {
|
||||
assert!(relationships.contains_key(key), "missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_keys_real_relation_included() {
|
||||
let (relationships, relationship_count) = parse_skip_key_relationships();
|
||||
assert_eq!(relationship_count, 5);
|
||||
assert_relationship_values(
|
||||
&relationships,
|
||||
&[
|
||||
("Real Relation", &["[[note/important]]"]),
|
||||
("Type", &["[[project]]"]),
|
||||
],
|
||||
);
|
||||
for key in ["Cadence", "Created at", "Created time"] {
|
||||
assert!(relationships.contains_key(key), "missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_relationships_mixed_wikilinks_and_plain_in_array() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
References:
|
||||
- "[[source/paper-a]]"
|
||||
- "just a plain string"
|
||||
- "[[source/paper-b]]"
|
||||
- "no links here"
|
||||
---
|
||||
# Mixed Array
|
||||
"#;
|
||||
create_test_file(dir.path(), "mixed-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("mixed-array.md"), None).unwrap();
|
||||
assert_relationship_values(
|
||||
&entry.relationships,
|
||||
&[("References", &["[[source/paper-a]]", "[[source/paper-b]]"])],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_large_notes_relationship_array() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
type: Topic
|
||||
Referred by Data:
|
||||
- "[[michele-sampieri|Michele Sampieri]]"
|
||||
- "[[varun-anand|Varun Anand]]"
|
||||
Belongs to:
|
||||
- "[[engineering|Engineering]]"
|
||||
aliases:
|
||||
- No Code
|
||||
Notes:
|
||||
- "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]"
|
||||
- "[[airdev-build-hub|Airdev Build Hub]]"
|
||||
- "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]"
|
||||
- "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]"
|
||||
- "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]"
|
||||
- "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]"
|
||||
- "[[chameleon-microsurveys|Chameleon | Microsurveys]]"
|
||||
- "[[felt-the-best-way-to-make-maps-on-the-internet|Felt – The best way to make maps on the internet]]"
|
||||
- "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]"
|
||||
- "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]"
|
||||
- "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]"
|
||||
- "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]"
|
||||
- "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]"
|
||||
- "[[michele-sampieri|Michele Sampieri]]"
|
||||
- "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]"
|
||||
- "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]"
|
||||
- "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]"
|
||||
- "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]"
|
||||
- "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]"
|
||||
- "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]"
|
||||
- "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]"
|
||||
- "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]"
|
||||
- "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]"
|
||||
- "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]"
|
||||
- "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]"
|
||||
- "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]"
|
||||
- "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]"
|
||||
- "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]"
|
||||
- "[[varun-anand|Varun Anand]]"
|
||||
- "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]"
|
||||
- "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]"
|
||||
- "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]"
|
||||
title: No Code
|
||||
---
|
||||
# No Code
|
||||
"#;
|
||||
create_test_file(dir.path(), "no-code.md", content);
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md"), None).unwrap();
|
||||
|
||||
let notes = entry
|
||||
.relationships
|
||||
.get("Notes")
|
||||
.expect("Notes relationship should exist");
|
||||
assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed");
|
||||
|
||||
let referred = entry
|
||||
.relationships
|
||||
.get("Referred by Data")
|
||||
.expect("Referred by Data should exist");
|
||||
assert_eq!(referred.len(), 2);
|
||||
|
||||
let belongs_to = entry
|
||||
.relationships
|
||||
.get("Belongs to")
|
||||
.expect("Belongs to should exist");
|
||||
assert_eq!(belongs_to.len(), 1);
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
fn entry_filenames(entries: &[VaultEntry]) -> Vec<&str> {
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| entry.filename.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_filenames_include(entries: &[VaultEntry], expected: &[&str]) {
|
||||
let filenames = entry_filenames(entries);
|
||||
for filename in expected {
|
||||
assert!(filenames.contains(filename), "missing {filename}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_root_and_protected_folders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "root.md", "# Root Note\n");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"project.md",
|
||||
"---\ntype: Type\n---\n# Project\n",
|
||||
);
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"not-markdown.txt",
|
||||
"This should be included as text",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert_filenames_include(
|
||||
&entries,
|
||||
&["root.md", "project.md", "notes.md", "not-markdown.txt"],
|
||||
);
|
||||
|
||||
let txt_entry = entries
|
||||
.iter()
|
||||
.find(|entry| entry.filename == "not-markdown.txt")
|
||||
.unwrap();
|
||||
assert_eq!(txt_entry.file_kind, "text");
|
||||
assert_eq!(txt_entry.title, "not-markdown.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_includes_subdirectory_notes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "root.md", "# Root Note\n");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"random-folder/nested.md",
|
||||
"---\ntype: Note\n---\n# Nested\n",
|
||||
);
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"project/old-project.md",
|
||||
"---\ntype: Project\n---\n# Old\n",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
3,
|
||||
"all .md files including subdirs should be scanned"
|
||||
);
|
||||
assert_filenames_include(&entries, &["root.md", "nested.md", "old-project.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_includes_all_protected_folders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "root.md", "# Root\n");
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "assets/image.md", "# Asset\n");
|
||||
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_skips_hidden_folders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "root.md", "# Root\n");
|
||||
create_test_file(dir.path(), ".laputa/cache.md", "# Cache\n");
|
||||
create_test_file(dir.path(), ".git/objects.md", "# Git\n");
|
||||
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].filename, "root.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_nonexistent_path() {
|
||||
let result = scan_vault(
|
||||
Path::new("/nonexistent/path/that/does/not/exist"),
|
||||
&HashMap::new(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_note_content_nonexistent() {
|
||||
let result = get_note_content(Path::new("/nonexistent/path/file.md"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_note_content_invalid_utf8() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("invalid.csv");
|
||||
std::fs::write(&path, [0x66, 0x6f, 0x80]).unwrap();
|
||||
|
||||
let result = get_note_content(&path);
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
format!("File is not valid UTF-8 text: {}", path.display())
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_type_from_frontmatter_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "test.md", "---\ntype: Custom\n---\n# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, Some("Custom".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_type_when_frontmatter_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note/test.md", "# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("note/test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, None, "type should not be inferred from folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_created_at_from_filesystem() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
"created_at should come from filesystem"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_relationship_added_for_regular_entries() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Project\n---\n# My Project\n";
|
||||
let entry = parse_test_entry(&dir, "project/my-project.md", content);
|
||||
assert_eq!(
|
||||
entry.relationships.get("Type").unwrap(),
|
||||
&vec!["[[project]]".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_relationship_skipped_for_type_documents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Type\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "project.md", content);
|
||||
assert!(!entry.relationships.contains_key("Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_type_relationship_without_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "# A Person\n\nSome content.";
|
||||
let entry = parse_test_entry(&dir, "someone.md", content);
|
||||
assert_eq!(entry.is_a, None);
|
||||
assert!(!entry.relationships.contains_key("Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_relationship_handles_wikilink_is_a() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: \"[[experiment]]\"\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert_eq!(
|
||||
entry.relationships.get("Type").unwrap(),
|
||||
&vec!["[[experiment]]".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_from_frontmatter_not_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Some Type\n";
|
||||
let entry = parse_test_entry(&dir, "some-type.md", content);
|
||||
assert_eq!(entry.is_a, Some("Type".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_type_key_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Project\n---\n# My Project\n";
|
||||
let entry = parse_test_entry(&dir, "project/my-project.md", content);
|
||||
assert_eq!(entry.is_a, Some("Project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_type_key_case_insensitive() {
|
||||
for (key, expected_type) in [("Type", "Project"), ("TYPE", "Person")] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = format!("---\n{key}: {expected_type}\n---\n# Test\n");
|
||||
let entry = parse_test_entry(&dir, "note/test.md", &content);
|
||||
assert_eq!(entry.is_a, Some(expected_type.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_key_generates_type_relationship() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Person\n---\n# Alice\n";
|
||||
let entry = parse_test_entry(&dir, "person/alice.md", content);
|
||||
assert_eq!(
|
||||
entry.relationships.get("Type").unwrap(),
|
||||
&vec!["[[person]]".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_key_not_in_relationships_as_generic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\nHas:\n - \"[[task/foo]]\"\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "note/test.md", content);
|
||||
assert_eq!(entry.relationships.len(), 2);
|
||||
assert!(entry.relationships.contains_key("Has"));
|
||||
assert!(entry.relationships.contains_key("Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outgoing_links_extracted_from_content_body() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nSee [[person/alice]] and [[topic/rust]].";
|
||||
let entry = parse_test_entry(&dir, "note/my-note.md", content);
|
||||
assert_eq!(entry.outgoing_links, vec!["person/alice", "topic/rust"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outgoing_links_excludes_frontmatter_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/bob]].";
|
||||
let entry = parse_test_entry(&dir, "note/test.md", content);
|
||||
assert!(!entry.outgoing_links.contains(&"task/design".to_string()));
|
||||
assert!(entry.outgoing_links.contains(&"person/bob".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outgoing_links_handles_pipe_syntax() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "# Note\n\nSee [[project/alpha|Alpha Project]] for details.";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert!(entry.outgoing_links.contains(&"project/alpha".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_creates_parent_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("new-type/untitled-note.md");
|
||||
let content = "---\ntitle: Untitled note\n---\n# Untitled note\n\n";
|
||||
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
save_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_existing_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("note")).unwrap();
|
||||
let path = dir.path().join("note/test.md");
|
||||
let content = "# Test\n";
|
||||
|
||||
save_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_deeply_nested_new_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("a/b/c/deep-note.md");
|
||||
let content = "---\ntitle: Deep\n---\n";
|
||||
|
||||
save_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_creates_parent_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("new-type/briefing.md");
|
||||
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
|
||||
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
create_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_rejects_existing_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("briefing.md");
|
||||
fs::write(&path, "# Existing\n").unwrap();
|
||||
|
||||
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
|
||||
.expect_err("expected create-only write to reject collisions");
|
||||
|
||||
assert!(err.contains("already exists"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
use super::{parse_md_file, parse_non_md_file, resolve_entry_dates};
|
||||
use crate::git::GitDates;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn resolve_entry_dates_prefers_newer_filesystem_modified_time() {
|
||||
let resolved = resolve_entry_dates(Some(200), Some(50), Some((150, 25)));
|
||||
|
||||
assert_eq!(resolved, (Some(200), Some(25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_entry_dates_keeps_newer_git_modified_time() {
|
||||
let resolved = resolve_entry_dates(Some(150), Some(50), Some((200, 25)));
|
||||
|
||||
assert_eq!(resolved, (Some(200), Some(25)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_md_file_uses_newer_filesystem_modified_time_than_git() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("note.md");
|
||||
fs::write(&path, "# Note\n\nBody\n").unwrap();
|
||||
|
||||
let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap();
|
||||
let fs_modified = fs_modified.unwrap();
|
||||
let git_created = fs_modified.saturating_sub(600);
|
||||
let git_modified = fs_modified.saturating_sub(60);
|
||||
|
||||
let entry = parse_md_file(&path, Some((git_modified, git_created))).unwrap();
|
||||
|
||||
assert_eq!(entry.modified_at, Some(fs_modified));
|
||||
assert_eq!(entry.created_at, Some(git_created));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_md_file_falls_back_to_git_modified_when_filesystem_missing_newer_date() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("assets/data.txt");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "hello").unwrap();
|
||||
|
||||
let (fs_modified, _, _) = super::file::read_file_metadata(&path).unwrap();
|
||||
let git_modified = fs_modified.unwrap().saturating_add(60);
|
||||
let git_created = git_modified.saturating_sub(600);
|
||||
|
||||
let entry = parse_non_md_file(&path, Some((git_modified, git_created))).unwrap();
|
||||
|
||||
assert_eq!(entry.modified_at, Some(git_modified));
|
||||
assert_eq!(entry.created_at, Some(git_created));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_vault_sorts_by_newer_of_git_and_filesystem_modified_time() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let older_path = dir.path().join("older-git-newer-file.md");
|
||||
let newer_git_path = dir.path().join("newer-git-older-file.md");
|
||||
|
||||
fs::write(&newer_git_path, "# Newer Git\n\nBody\n").unwrap();
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
fs::write(&older_path, "# Newer File\n\nBody\n").unwrap();
|
||||
|
||||
let (older_file_modified, _, _) = super::file::read_file_metadata(&older_path).unwrap();
|
||||
let older_file_modified = older_file_modified.unwrap();
|
||||
|
||||
let git_dates = HashMap::from([
|
||||
(
|
||||
"older-git-newer-file.md".to_string(),
|
||||
GitDates {
|
||||
created_at: older_file_modified.saturating_sub(600),
|
||||
modified_at: older_file_modified.saturating_sub(120),
|
||||
},
|
||||
),
|
||||
(
|
||||
"newer-git-older-file.md".to_string(),
|
||||
GitDates {
|
||||
created_at: older_file_modified.saturating_sub(700),
|
||||
modified_at: older_file_modified.saturating_sub(30),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let entries = super::scan_vault(dir.path(), &git_dates).unwrap();
|
||||
let titles: Vec<_> = entries.iter().map(|entry| entry.title.as_str()).collect();
|
||||
|
||||
assert_eq!(titles, vec!["Newer File", "Newer Git"]);
|
||||
}
|
||||
346
product-source/hololake-platform/src-tauri/src/vault/parsing.rs
Normal file
346
product-source/hololake-platform/src-tauri/src/vault/parsing.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
//! Pure text-processing helpers for markdown content parsing.
|
||||
//! Snippet extraction, markdown stripping, date parsing, and string utilities.
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TextSlice<'a>(&'a str);
|
||||
|
||||
impl<'a> TextSlice<'a> {
|
||||
fn as_str(self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a human-readable title from a filename stem (slug).
|
||||
/// Converts hyphens to spaces and title-cases each word.
|
||||
/// Example: `career-tracks-depend-on-company-shape` → `Career Tracks Depend on Company Shape`
|
||||
pub(super) fn slug_to_title(stem: &str) -> String {
|
||||
stem.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(c) => {
|
||||
let upper: String = c.to_uppercase().collect();
|
||||
format!("{}{}", upper, chars.as_str())
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Extract the H1 title from the first non-empty line of the body (after frontmatter).
|
||||
/// Returns `None` if no H1 is found on the first non-empty line.
|
||||
pub(super) fn extract_h1_title(content: &str) -> Option<String> {
|
||||
let body = strip_frontmatter(TextSlice(content));
|
||||
let title =
|
||||
first_non_empty_line(TextSlice(body)).and_then(|line| markdown_h1_text(TextSlice(line)))?;
|
||||
let stripped = strip_markdown_chars(TextSlice(title));
|
||||
non_empty_trimmed(TextSlice(&stripped)).map(str::to_string)
|
||||
}
|
||||
|
||||
fn non_empty_trimmed(value: TextSlice<'_>) -> Option<&str> {
|
||||
let trimmed = value.as_str().trim();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
|
||||
fn first_non_empty_line(value: TextSlice<'_>) -> Option<&str> {
|
||||
value
|
||||
.as_str()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
}
|
||||
|
||||
fn markdown_h1_text(line: TextSlice<'_>) -> Option<&str> {
|
||||
line.as_str()
|
||||
.strip_prefix("# ")
|
||||
.and_then(|text| non_empty_trimmed(TextSlice(text)))
|
||||
}
|
||||
|
||||
/// Extract the display title for a note.
|
||||
/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title.
|
||||
pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String {
|
||||
// 1. H1 on first non-empty line of body
|
||||
if let Some(h1) = extract_h1_title(content) {
|
||||
return h1;
|
||||
}
|
||||
// 2. frontmatter title (legacy, backward compat)
|
||||
if let Some(title) = fm_title {
|
||||
if !title.is_empty() {
|
||||
return title.to_string();
|
||||
}
|
||||
}
|
||||
// 3. filename slug
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
slug_to_title(stem)
|
||||
}
|
||||
|
||||
/// Remove YAML frontmatter (triple-dash delimited) from content.
|
||||
/// The closing `---` must appear at the start of a line to avoid matching
|
||||
/// occurrences inside frontmatter values (e.g. `title: foo---bar`).
|
||||
fn strip_frontmatter(content: TextSlice<'_>) -> &str {
|
||||
let value = content.as_str();
|
||||
let Some(rest) = value.strip_prefix("---") else {
|
||||
return value;
|
||||
};
|
||||
// Find closing `---` at the start of a line (preceded by newline)
|
||||
match rest.find("\n---") {
|
||||
Some(end) => {
|
||||
let after = end + 4; // skip past "\n---"
|
||||
rest[after..].trim_start()
|
||||
}
|
||||
None => value,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule).
|
||||
fn is_snippet_line(line: TextSlice<'_>) -> bool {
|
||||
let t = line.as_str().trim();
|
||||
!t.is_empty() && !t.starts_with('#') && !t.starts_with("```") && !t.starts_with("---")
|
||||
}
|
||||
|
||||
/// Extract sub-heading text (## , ### , etc.) stripped of the `#` prefix.
|
||||
fn extract_subheading_text(line: TextSlice<'_>) -> Option<&str> {
|
||||
let t = line.as_str().trim();
|
||||
let stripped = t.trim_start_matches('#');
|
||||
if stripped.len() < t.len() && stripped.starts_with(' ') {
|
||||
let text = stripped.trim();
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip leading list markers (*, -, +, 1.) from a line.
|
||||
fn strip_list_marker(line: TextSlice<'_>) -> &str {
|
||||
let t = line.as_str().trim_start();
|
||||
strip_unordered_marker(TextSlice(t))
|
||||
.or_else(|| strip_ordered_marker(TextSlice(t)))
|
||||
.unwrap_or(t)
|
||||
}
|
||||
|
||||
/// Strip unordered list markers: "* ", "- ", "+ "
|
||||
fn strip_unordered_marker(s: TextSlice<'_>) -> Option<&str> {
|
||||
["* ", "- ", "+ "]
|
||||
.iter()
|
||||
.find_map(|prefix| s.as_str().strip_prefix(prefix))
|
||||
}
|
||||
|
||||
/// Strip ordered list markers: "1. ", "2. ", etc.
|
||||
fn strip_ordered_marker(s: TextSlice<'_>) -> Option<&str> {
|
||||
let value = s.as_str();
|
||||
let dot_pos = value.find(". ")?;
|
||||
if dot_pos <= 3 && value[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
|
||||
Some(&value[dot_pos + 2..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to `max_len` bytes at a valid UTF-8 boundary, appending "...".
|
||||
fn truncate_with_ellipsis(s: TextSlice<'_>, max_len: usize) -> String {
|
||||
let value = s.as_str();
|
||||
if value.len() <= max_len {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut idx = max_len;
|
||||
while idx > 0 && !value.is_char_boundary(idx) {
|
||||
idx -= 1;
|
||||
}
|
||||
format!("{}...", &value[..idx])
|
||||
}
|
||||
|
||||
/// Count the number of words in the note body (excluding frontmatter and H1 title).
|
||||
pub(super) fn count_body_words(content: &str) -> u32 {
|
||||
let without_fm = strip_frontmatter(TextSlice(content));
|
||||
let body = without_h1_line(TextSlice(without_fm)).unwrap_or(without_fm);
|
||||
body.split_whitespace()
|
||||
.filter(|w| {
|
||||
!w.chars()
|
||||
.all(|c| matches!(c, '#' | '*' | '_' | '`' | '~' | '-' | '>' | '|'))
|
||||
})
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
/// Extract a snippet: first ~160 chars of content after frontmatter/title, stripped of markdown.
|
||||
pub(super) fn extract_snippet(content: &str) -> String {
|
||||
let without_fm = strip_frontmatter(TextSlice(content));
|
||||
let body = without_h1_line(TextSlice(without_fm)).unwrap_or(without_fm);
|
||||
let clean: String = body
|
||||
.lines()
|
||||
.filter(|line| is_snippet_line(TextSlice(line)))
|
||||
.map(|line| strip_list_marker(TextSlice(line)))
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
let stripped = strip_markdown_chars(TextSlice(&clean));
|
||||
let trimmed = stripped.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return truncate_with_ellipsis(TextSlice(trimmed), 160);
|
||||
}
|
||||
// Fallback: collect sub-heading text when no paragraph content exists
|
||||
let heading_text: String = body
|
||||
.lines()
|
||||
.filter_map(|line| extract_subheading_text(TextSlice(line)))
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ");
|
||||
let heading_trimmed = strip_markdown_chars(TextSlice(&heading_text));
|
||||
let heading_trimmed = heading_trimmed.trim();
|
||||
if heading_trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
truncate_with_ellipsis(TextSlice(heading_trimmed), 160)
|
||||
}
|
||||
|
||||
fn without_h1_line(s: TextSlice<'_>) -> Option<&str> {
|
||||
let value = s.as_str();
|
||||
let mut offset = 0;
|
||||
for line in value.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']).trim();
|
||||
if trimmed.starts_with("# ") {
|
||||
return Some(&value[offset + line.len()..]);
|
||||
}
|
||||
// If we hit non-empty non-heading content first, there's no H1 to skip
|
||||
if !trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
offset += line.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Collect chars until a delimiter, returning the collected string.
|
||||
fn collect_until(chars: &mut impl Iterator<Item = char>, delimiter: char) -> String {
|
||||
let mut buf = String::new();
|
||||
for c in chars.by_ref() {
|
||||
if c == delimiter {
|
||||
break;
|
||||
}
|
||||
buf.push(c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Skip all chars until a delimiter (consuming the delimiter).
|
||||
fn skip_until(chars: &mut impl Iterator<Item = char>, delimiter: char) {
|
||||
for c in chars.by_ref() {
|
||||
if c == delimiter {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a char is markdown formatting that should be stripped.
|
||||
fn is_markdown_formatting(ch: char) -> bool {
|
||||
matches!(ch, '*' | '_' | '`' | '~')
|
||||
}
|
||||
|
||||
fn strip_markdown_chars(s: TextSlice<'_>) -> String {
|
||||
let value = s.as_str();
|
||||
let mut result = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'[' if chars.peek() == Some(&'[') => {
|
||||
process_wikilink(&mut chars, &mut result);
|
||||
}
|
||||
'[' => {
|
||||
process_markdown_link(&mut chars, &mut result);
|
||||
}
|
||||
c if is_markdown_formatting(c) => {}
|
||||
_ => result.push(ch),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Process a wikilink `[[...]]` or `[[...|display]]`, extracting the display text.
|
||||
fn process_wikilink(
|
||||
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
|
||||
result: &mut String,
|
||||
) {
|
||||
chars.next(); // consume second '['
|
||||
let inner = collect_wikilink_inner(chars);
|
||||
let display_text = extract_wikilink_display(&inner);
|
||||
result.push_str(display_text);
|
||||
}
|
||||
|
||||
/// Extract display text from wikilink inner content.
|
||||
/// Returns the part after '|' if present, otherwise the whole inner text.
|
||||
fn extract_wikilink_display(inner: &str) -> &str {
|
||||
inner.find('|').map_or(inner, |idx| &inner[idx + 1..])
|
||||
}
|
||||
|
||||
/// Process bracketed text.
|
||||
/// Real markdown links `[text](url)` are unwrapped to `text`.
|
||||
/// Plain bracketed text `[text]` is preserved verbatim.
|
||||
fn process_markdown_link(
|
||||
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
|
||||
result: &mut String,
|
||||
) {
|
||||
let inner = collect_until(chars, ']');
|
||||
if chars.peek() == Some(&'(') {
|
||||
chars.next();
|
||||
skip_until(chars, ')');
|
||||
result.push_str(&inner);
|
||||
return;
|
||||
}
|
||||
|
||||
result.push('[');
|
||||
result.push_str(&inner);
|
||||
result.push(']');
|
||||
}
|
||||
|
||||
/// Collect chars inside a wikilink until `]]`, consuming both closing brackets.
|
||||
fn collect_wikilink_inner(chars: &mut std::iter::Peekable<impl Iterator<Item = char>>) -> String {
|
||||
let mut buf = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ']' && chars.peek() == Some(&']') {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
buf.push(c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Check if a string contains a wikilink pattern `[[...]]`.
|
||||
pub(super) fn contains_wikilink(s: &str) -> bool {
|
||||
s.contains("[[") && s.contains("]]")
|
||||
}
|
||||
|
||||
/// Extract all outgoing wikilink targets from content.
|
||||
/// Finds `[[target]]` and `[[target|display]]` patterns, returning just the target part.
|
||||
/// Returns a sorted, deduplicated Vec of targets.
|
||||
pub(super) fn extract_outgoing_links(content: &str) -> Vec<String> {
|
||||
let mut links = Vec::new();
|
||||
let mut search_from = 0;
|
||||
let bytes = content.as_bytes();
|
||||
while search_from + 3 < bytes.len() {
|
||||
let Some(start) = content[search_from..].find("[[") else {
|
||||
break;
|
||||
};
|
||||
let abs_start = search_from + start + 2;
|
||||
let Some(end) = content[abs_start..].find("]]") else {
|
||||
break;
|
||||
};
|
||||
let inner = &content[abs_start..abs_start + end];
|
||||
let target = match inner.find('|') {
|
||||
Some(idx) => &inner[..idx],
|
||||
None => inner,
|
||||
};
|
||||
if !target.is_empty() {
|
||||
links.push(target.to_string());
|
||||
}
|
||||
search_from = abs_start + end + 2;
|
||||
}
|
||||
links.sort();
|
||||
links.dedup();
|
||||
links
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parsing_tests.rs"]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
use super::*;
|
||||
|
||||
fn text(value: &str) -> TextSlice<'_> {
|
||||
TextSlice(value)
|
||||
}
|
||||
|
||||
// --- slug_to_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_basic() {
|
||||
assert_eq!(slug_to_title("career-tracks"), "Career Tracks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_single_word() {
|
||||
assert_eq!(slug_to_title("hello"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_empty() {
|
||||
assert_eq!(slug_to_title(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_e2e() {
|
||||
assert_eq!(slug_to_title("e2e-test"), "E2e Test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slug_to_title_multiple_hyphens() {
|
||||
assert_eq!(slug_to_title("a--b"), "A B");
|
||||
}
|
||||
|
||||
// --- extract_h1_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_basic() {
|
||||
assert_eq!(
|
||||
extract_h1_title("# Hello World\n\nBody."),
|
||||
Some("Hello World".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_after_frontmatter() {
|
||||
let content = "---\ntype: Note\n---\n# My Note\n\nBody.";
|
||||
assert_eq!(extract_h1_title(content), Some("My Note".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_with_empty_lines_before() {
|
||||
let content = "---\ntype: Note\n---\n\n# Spaced Title\n\nBody.";
|
||||
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_preserves_plain_square_brackets() {
|
||||
let content = "# [26Q2] Tolaria MVP\n\nBody.";
|
||||
assert_eq!(
|
||||
extract_h1_title(content),
|
||||
Some("[26Q2] Tolaria MVP".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_none_when_no_h1() {
|
||||
assert_eq!(extract_h1_title("Just body text."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_none_when_h1_not_first() {
|
||||
assert_eq!(extract_h1_title("Some text\n# Not first\n"), None);
|
||||
}
|
||||
|
||||
// --- extract_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_h1_takes_priority_over_frontmatter() {
|
||||
assert_eq!(
|
||||
extract_title(
|
||||
Some("FM Title"),
|
||||
"---\ntitle: FM Title\n---\n# H1 Title\n\nBody.",
|
||||
"note.md"
|
||||
),
|
||||
"H1 Title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_h1_when_no_frontmatter_title() {
|
||||
assert_eq!(
|
||||
extract_title(None, "# Hello World\n\nBody text.", "some-file.md"),
|
||||
"Hello World"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_h1_after_frontmatter() {
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nBody.";
|
||||
assert_eq!(extract_title(None, content, "fallback.md"), "My Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_frontmatter_when_no_h1() {
|
||||
assert_eq!(
|
||||
extract_title(Some("My Great Note"), "Just body text.", "my-great-note.md"),
|
||||
"My Great Note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_fallback_to_filename() {
|
||||
assert_eq!(
|
||||
extract_title(None, "", "fallback-title.md"),
|
||||
"Fallback Title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_h1_wins_over_empty_frontmatter() {
|
||||
assert_eq!(
|
||||
extract_title(Some(""), "# From H1\n", "empty-h1.md"),
|
||||
"From H1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_empty_fm_no_h1_falls_back_to_filename() {
|
||||
assert_eq!(
|
||||
extract_title(Some(""), "No heading here.", "empty-h1.md"),
|
||||
"Empty H1"
|
||||
);
|
||||
}
|
||||
|
||||
// --- extract_snippet tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_basic() {
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nThis is the first paragraph of content.\n\n## Section Two\n\nMore content here.";
|
||||
let snippet = extract_snippet(content);
|
||||
assert!(snippet.starts_with("This is the first paragraph"));
|
||||
assert!(snippet.contains("More content here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_strips_markdown() {
|
||||
let content = "# Title\n\nSome **bold** and *italic* and `code` text.";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "Some bold and italic and code text.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_strips_links() {
|
||||
let content = "# Title\n\nSee [this link](https://example.com) and [[wiki link]].";
|
||||
let snippet = extract_snippet(content);
|
||||
assert!(snippet.contains("this link"));
|
||||
assert!(!snippet.contains("https://example.com"));
|
||||
assert!(snippet.contains("wiki link"));
|
||||
assert!(!snippet.contains("[["));
|
||||
assert!(!snippet.contains("]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_wikilink_alias() {
|
||||
let content = "# Title\n\nDiscussed in [[meetings/standup|standup]] today.";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "Discussed in standup today.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_truncates() {
|
||||
let long_content = format!("# Title\n\n{}", "word ".repeat(100));
|
||||
let snippet = extract_snippet(&long_content);
|
||||
assert!(snippet.len() <= 165); // 160 + "..."
|
||||
assert!(snippet.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_no_content() {
|
||||
let content = "---\nIs A: Note\n---\n# Just a Title\n";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_code_fence_delimiters_skipped() {
|
||||
let content = "# Title\n\n```rust\nfn main() {}\n```\n\nReal content here.";
|
||||
let snippet = extract_snippet(content);
|
||||
assert!(!snippet.contains("```"));
|
||||
assert!(snippet.contains("Real content here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_only_headings_uses_fallback() {
|
||||
let content = "# Title\n\n## Section One\n\n### Sub Section\n";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "Section One Sub Section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_no_frontmatter_no_h1() {
|
||||
let content = "Just plain text content without any heading.";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "Just plain text content without any heading.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_unclosed_frontmatter() {
|
||||
let content = "---\nIs A: Note\nThis has no closing fence\n# Title\n\nBody text.";
|
||||
let snippet = extract_snippet(content);
|
||||
assert!(snippet.contains("Body text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_horizontal_rules_skipped() {
|
||||
let content = "# Title\n\n---\n\nContent after rule.";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "Content after rule.");
|
||||
}
|
||||
|
||||
// --- strip_list_marker tests ---
|
||||
|
||||
#[test]
|
||||
fn test_strip_list_marker_unordered() {
|
||||
assert_eq!(strip_list_marker(text("* Item one")), "Item one");
|
||||
assert_eq!(strip_list_marker(text("- Item two")), "Item two");
|
||||
assert_eq!(strip_list_marker(text("+ Item three")), "Item three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_list_marker_ordered() {
|
||||
assert_eq!(strip_list_marker(text("1. First item")), "First item");
|
||||
assert_eq!(strip_list_marker(text("10. Tenth item")), "Tenth item");
|
||||
assert_eq!(strip_list_marker(text("99. Large number")), "Large number");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_list_marker_preserves_non_list() {
|
||||
assert_eq!(strip_list_marker(text("Regular text")), "Regular text");
|
||||
assert_eq!(strip_list_marker(text(" Indented text")), "Indented text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_strips_list_markers() {
|
||||
let content =
|
||||
"---\ntype: Project\n---\n# My Project\n\n* First bullet\n* Second bullet\n- Dash item";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "First bullet Second bullet Dash item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_mixed_headings_and_bullets() {
|
||||
let content = "---\ntype: Project\nstatus: Active\n---\n# Migrate newsletter to Beehiiv\n\n### 1) Newsletter is 100% on Beehiiv\n\n* Migration is successful\n\n### 2) Open rate is >27%\n\n* No regressions on open rate";
|
||||
let snippet = extract_snippet(content);
|
||||
assert!(
|
||||
snippet.starts_with("Migration is successful"),
|
||||
"snippet should start with first bullet content, got: {}",
|
||||
snippet
|
||||
);
|
||||
assert!(snippet.contains("No regressions on open rate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_ordered_list() {
|
||||
let content = "# Title\n\n1. First step\n2. Second step\n3. Third step";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "First step Second step Third step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_only_subheadings_fallback() {
|
||||
let content =
|
||||
"---\ntype: Project\n---\n# My Project\n\n## Description\n\n---\n\n## Key Results\n\n---\n";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "Description Key Results");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_subheadings_with_emoji() {
|
||||
let content = "# Daily\n\n## Intentions\n\n## Reflections\n";
|
||||
let snippet = extract_snippet(content);
|
||||
assert_eq!(snippet, "Intentions Reflections");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_paragraph_takes_priority_over_headings() {
|
||||
let content = "# Title\n\n## Section One\n\nActual paragraph content.\n\n## Section Two\n";
|
||||
let snippet = extract_snippet(content);
|
||||
assert!(
|
||||
snippet.starts_with("Actual paragraph content"),
|
||||
"paragraph content should be preferred over headings, got: {}",
|
||||
snippet
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_crlf_chinese_h1_table_content() {
|
||||
let content =
|
||||
"\r\n\r\n# 上海复盘\r\n\r\n| 指标 | 值 |\r\n| --- | --- |\r\n| 收入 | 增长 |\r\n\r\n正文包含中文字符。";
|
||||
let snippet = extract_snippet(content);
|
||||
|
||||
assert!(snippet.contains("指标"));
|
||||
assert!(snippet.contains("正文包含中文字符"));
|
||||
}
|
||||
|
||||
// --- count_body_words tests ---
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_basic() {
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nHello world, this is a test.";
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_no_frontmatter() {
|
||||
let content = "# Title\n\nOne two three four five.";
|
||||
assert_eq!(count_body_words(content), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_empty_body() {
|
||||
let content = "---\nIs A: Note\n---\n# Just a Title\n";
|
||||
assert_eq!(count_body_words(content), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_no_content() {
|
||||
assert_eq!(count_body_words(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_excludes_markdown_markers() {
|
||||
let content = "# Title\n\n## Section\n\nReal words here. ---\n\n> quote text";
|
||||
// "Real", "words", "here.", "quote", "text" = 5 real words
|
||||
// "##", "Section", "---", ">" are markdown markers (## is a heading, --- is a rule, > is blockquote)
|
||||
// "Section" passes the filter (not all markdown chars), so count includes it
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_plain_text_only() {
|
||||
let content = "Just plain text without any heading.";
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
// --- strip_frontmatter tests ---
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_basic() {
|
||||
let content = "---\ntitle: Test\n---\nBody content.";
|
||||
assert_eq!(strip_frontmatter(text(content)), "Body content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_no_frontmatter() {
|
||||
let content = "Just plain content.";
|
||||
assert_eq!(strip_frontmatter(text(content)), "Just plain content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_dashes_in_value() {
|
||||
// The closing --- must be at line start, not inside a value
|
||||
let content = "---\ntitle: foo---bar\nstatus: active\n---\nBody here.";
|
||||
assert_eq!(strip_frontmatter(text(content)), "Body here.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_unclosed() {
|
||||
let content = "---\ntitle: Test\nNo closing fence";
|
||||
assert_eq!(strip_frontmatter(text(content)), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_empty_body() {
|
||||
let content = "---\ntitle: Test\n---\n";
|
||||
assert_eq!(strip_frontmatter(text(content)), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_with_dashes_in_frontmatter_value() {
|
||||
// Regression: strip_frontmatter previously matched --- inside values
|
||||
let content = "---\ntitle: my---note\nstatus: active\n---\n# Title\n\nThree body words.";
|
||||
assert_eq!(count_body_words(content), 3);
|
||||
}
|
||||
|
||||
// --- strip_markdown_chars tests ---
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_plain_text() {
|
||||
assert_eq!(strip_markdown_chars(text("hello world")), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_emphasis() {
|
||||
assert_eq!(
|
||||
strip_markdown_chars(text("**bold** and *italic*")),
|
||||
"bold and italic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_backticks() {
|
||||
assert_eq!(
|
||||
strip_markdown_chars(text("use `code` here")),
|
||||
"use code here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_strikethrough() {
|
||||
assert_eq!(strip_markdown_chars(text("~~deleted~~")), "deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_link_with_url() {
|
||||
assert_eq!(
|
||||
strip_markdown_chars(text("[click here](https://example.com)")),
|
||||
"click here"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_wikilink() {
|
||||
assert_eq!(strip_markdown_chars(text("see [[my note]]")), "see my note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_wikilink_alias() {
|
||||
assert_eq!(
|
||||
strip_markdown_chars(text("visit [[project/alpha|Alpha Project]]")),
|
||||
"visit Alpha Project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_wikilink_unclosed() {
|
||||
assert_eq!(
|
||||
strip_markdown_chars(text("see [[broken link")),
|
||||
"see broken link"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_bracket_without_url() {
|
||||
assert_eq!(
|
||||
strip_markdown_chars(text("[just brackets]")),
|
||||
"[just brackets]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_markdown_chars_empty() {
|
||||
assert_eq!(strip_markdown_chars(text("")), "");
|
||||
}
|
||||
|
||||
// --- without_h1_line tests ---
|
||||
|
||||
#[test]
|
||||
fn test_without_h1_line_starts_with_h1() {
|
||||
let result = without_h1_line(text("# Title\nBody text"));
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap(), "Body text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_without_h1_line_blank_lines_then_h1() {
|
||||
let result = without_h1_line(text("\n\n# Title\nBody"));
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap(), "Body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_without_h1_line_non_heading_first() {
|
||||
let result = without_h1_line(text("Some text\n# Title\n"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_without_h1_line_empty() {
|
||||
let result = without_h1_line(text(""));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_without_h1_line_only_blank_lines() {
|
||||
let result = without_h1_line(text("\n\n\n"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// --- contains_wikilink tests ---
|
||||
|
||||
#[test]
|
||||
fn test_contains_wikilink_true() {
|
||||
assert!(contains_wikilink("[[some note]]"));
|
||||
assert!(contains_wikilink("text before [[link]] text after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_wikilink_false_plain_text() {
|
||||
assert!(!contains_wikilink("no links here"));
|
||||
assert!(!contains_wikilink("[single bracket]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_wikilink_false_partial_markers() {
|
||||
assert!(!contains_wikilink("only [[ opening"));
|
||||
assert!(!contains_wikilink("only ]] closing"));
|
||||
}
|
||||
|
||||
// --- extract_outgoing_links tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_outgoing_links_basic() {
|
||||
let content = "# Note\n\nSee [[Alice]] and [[Bob]] for details.";
|
||||
let links = extract_outgoing_links(content);
|
||||
assert_eq!(links, vec!["Alice", "Bob"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_outgoing_links_pipe_syntax() {
|
||||
let content = "Link to [[project/alpha|Alpha Project]] here.";
|
||||
let links = extract_outgoing_links(content);
|
||||
assert_eq!(links, vec!["project/alpha"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_outgoing_links_deduplicates() {
|
||||
let content = "See [[Alice]] and then [[Alice]] again.";
|
||||
let links = extract_outgoing_links(content);
|
||||
assert_eq!(links, vec!["Alice"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_outgoing_links_sorted() {
|
||||
let content = "[[Zebra]] then [[Alpha]] then [[Middle]]";
|
||||
let links = extract_outgoing_links(content);
|
||||
assert_eq!(links, vec!["Alpha", "Middle", "Zebra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_outgoing_links_with_frontmatter() {
|
||||
let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/alice]].";
|
||||
let links = extract_outgoing_links(content);
|
||||
assert_eq!(links, vec!["person/alice", "task/design"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_outgoing_links_empty_content() {
|
||||
assert!(extract_outgoing_links("").is_empty());
|
||||
assert!(extract_outgoing_links("No links here").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_outgoing_links_unclosed_bracket() {
|
||||
// First [[ matches with the only ]], yielding "unclosed and [[valid"
|
||||
let content = "[[unclosed and [[valid]]";
|
||||
let links = extract_outgoing_links(content);
|
||||
assert_eq!(links, vec!["unclosed and [[valid"]);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
use std::path::Path;
|
||||
|
||||
type PathText = str;
|
||||
type RelativePathText = str;
|
||||
|
||||
fn normalize_tmp_alias(path: &PathText) -> String {
|
||||
if path == "/private/tmp" {
|
||||
return "/tmp".to_string();
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("/private/tmp/") {
|
||||
return format!("/tmp/{rest}");
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn trim_trailing_slashes(path: String) -> String {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if trimmed.is_empty() && path.starts_with('/') {
|
||||
"/".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_path_for_identity(path: &PathText) -> String {
|
||||
trim_trailing_slashes(normalize_tmp_alias(&path.replace('\\', "/")))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_relative_path(path: &RelativePathText) -> String {
|
||||
path.replace('\\', "/").trim_matches('/').to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn relative_path_key(path: &RelativePathText) -> String {
|
||||
normalize_relative_path(path).to_lowercase()
|
||||
}
|
||||
|
||||
pub(crate) fn has_hidden_segment(path: &RelativePathText) -> bool {
|
||||
normalize_relative_path(path)
|
||||
.split('/')
|
||||
.any(|segment| segment.starts_with('.'))
|
||||
}
|
||||
|
||||
pub(crate) fn push_unique_relative_path(
|
||||
paths: &mut Vec<String>,
|
||||
path: impl AsRef<RelativePathText>,
|
||||
) {
|
||||
let normalized = normalize_relative_path(path.as_ref());
|
||||
if normalized.is_empty() || has_hidden_segment(&normalized) {
|
||||
return;
|
||||
}
|
||||
let key = relative_path_key(&normalized);
|
||||
if !paths
|
||||
.iter()
|
||||
.any(|existing| relative_path_key(existing) == key)
|
||||
{
|
||||
paths.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn vault_relative_path_string(vault: &Path, file: &Path) -> Result<String, String> {
|
||||
let vault_path = normalize_path_for_identity(&vault.to_string_lossy());
|
||||
let file_path = normalize_path_for_identity(&file.to_string_lossy());
|
||||
if file_path == vault_path {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let prefix = format!("{vault_path}/");
|
||||
file_path
|
||||
.strip_prefix(&prefix)
|
||||
.map(normalize_relative_path)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"File {} is not inside vault {}",
|
||||
file.display(),
|
||||
vault.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn vault_relative_markdown_stem(path: &Path, vault: &Path) -> String {
|
||||
let relative = vault_relative_path_string(vault, path)
|
||||
.unwrap_or_else(|_| normalize_path_for_identity(&path.to_string_lossy()));
|
||||
relative
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&relative)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vault_relative_path_string_normalizes_tmp_alias_and_backslashes() {
|
||||
assert_eq!(
|
||||
vault_relative_path_string(
|
||||
Path::new("/private/tmp/tolaria-vault"),
|
||||
Path::new("/tmp/tolaria-vault/projects\\active.md"),
|
||||
)
|
||||
.unwrap(),
|
||||
"projects/active.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_path_key_is_case_insensitive_without_changing_output_path() {
|
||||
let mut paths = vec![];
|
||||
push_unique_relative_path(&mut paths, "Projects\\Active.md");
|
||||
push_unique_relative_path(&mut paths, "projects/active.md");
|
||||
|
||||
assert_eq!(paths, vec!["Projects/Active.md"]);
|
||||
assert_eq!(
|
||||
relative_path_key("Projects\\Active.md"),
|
||||
"projects/active.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
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();
|
||||
}
|
||||
|
||||
fn parse_test_entry(content: &str) -> VaultEntry {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "relationship-note.md", content);
|
||||
parse_md_file(&dir.path().join("relationship-note.md"), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_snake_case_relationship_keys_for_convenience_fields() {
|
||||
let entry = parse_test_entry(
|
||||
"---\n\
|
||||
belongs_to:\n\
|
||||
- \"[[canonical-parent]]\"\n\
|
||||
\"Belongs to\":\n\
|
||||
- \"[[legacy-parent]]\"\n\
|
||||
related_to:\n\
|
||||
- \"[[canonical-related]]\"\n\
|
||||
\"Related to\":\n\
|
||||
- \"[[legacy-related]]\"\n\
|
||||
---\n\
|
||||
# Relationship Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.belongs_to, vec!["[[canonical-parent]]"]);
|
||||
assert_eq!(entry.related_to, vec!["[[canonical-related]]"]);
|
||||
assert_eq!(
|
||||
entry.relationships.get("belongs_to"),
|
||||
Some(&vec!["[[canonical-parent]]".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
entry.relationships.get("Belongs to"),
|
||||
Some(&vec!["[[legacy-parent]]".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_legacy_relationship_keys_when_snake_case_is_absent() {
|
||||
let entry = parse_test_entry(
|
||||
"---\n\
|
||||
Belongs to:\n\
|
||||
- \"[[legacy-parent]]\"\n\
|
||||
Related to:\n\
|
||||
- \"[[legacy-related]]\"\n\
|
||||
---\n\
|
||||
# Relationship Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.belongs_to, vec!["[[legacy-parent]]"]);
|
||||
assert_eq!(entry.related_to, vec!["[[legacy-related]]"]);
|
||||
}
|
||||
1503
product-source/hololake-platform/src-tauri/src/vault/rename.rs
Normal file
1503
product-source/hololake-platform/src-tauri/src/vault/rename.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,287 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RenameTransaction {
|
||||
old_path: String,
|
||||
new_path: String,
|
||||
backup_path: String,
|
||||
}
|
||||
|
||||
pub(super) struct RenameWorkspace {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RenameWorkspace {
|
||||
pub(super) fn new(vault: &Path) -> Result<Self, String> {
|
||||
let dir = vault.join(".tolaria-rename-txn");
|
||||
fs::create_dir_all(&dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create rename transaction dir {}: {}",
|
||||
dir.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
Ok(Self { dir })
|
||||
}
|
||||
|
||||
pub(super) fn stage_note_content(&self, content: &str) -> Result<NamedTempFile, String> {
|
||||
let mut staged = NamedTempFile::new_in(&self.dir)
|
||||
.map_err(|e| format!("Failed to create staged rename file: {}", e))?;
|
||||
staged
|
||||
.write_all(content.as_bytes())
|
||||
.map_err(|e| format!("Failed to write staged rename file: {}", e))?;
|
||||
staged
|
||||
.as_file_mut()
|
||||
.sync_all()
|
||||
.map_err(|e| format!("Failed to sync staged rename file: {}", e))?;
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
pub(super) fn operation<'a>(
|
||||
&self,
|
||||
old_path: &'a str,
|
||||
old_file: &'a Path,
|
||||
) -> RenameOperation<'a> {
|
||||
RenameOperation {
|
||||
old_path,
|
||||
old_file,
|
||||
backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())),
|
||||
manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct CommittedRename {
|
||||
new_file: PathBuf,
|
||||
manifest_path: PathBuf,
|
||||
backup_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CommittedRename {
|
||||
pub(super) fn new_file(&self) -> &Path {
|
||||
&self.new_file
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CommittedRename {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.backup_path);
|
||||
let _ = fs::remove_file(&self.manifest_path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RenameOperation<'a> {
|
||||
old_path: &'a str,
|
||||
old_file: &'a Path,
|
||||
backup_path: PathBuf,
|
||||
manifest_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<'a> RenameOperation<'a> {
|
||||
pub(super) fn rename_with_candidates(
|
||||
&self,
|
||||
staged: NamedTempFile,
|
||||
desired_filename: &str,
|
||||
parent_dir: &Path,
|
||||
) -> Result<CommittedRename, String> {
|
||||
let mut staged = staged;
|
||||
for attempt in 0.. {
|
||||
let candidate = parent_dir.join(candidate_filename(desired_filename, attempt));
|
||||
self.prepare(&candidate)?;
|
||||
|
||||
match staged.persist_noclobber(&candidate) {
|
||||
Ok(_) => return Ok(self.committed(candidate)),
|
||||
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
|
||||
staged = err.file;
|
||||
self.rollback()?;
|
||||
}
|
||||
Err(err) => {
|
||||
self.rollback()?;
|
||||
return Err(format!(
|
||||
"Failed to create {}: {}",
|
||||
candidate.display(),
|
||||
err.error
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub(super) fn rename_exact(
|
||||
&self,
|
||||
staged: NamedTempFile,
|
||||
new_file: &Path,
|
||||
) -> Result<CommittedRename, String> {
|
||||
self.prepare(new_file)?;
|
||||
match staged.persist_noclobber(new_file) {
|
||||
Ok(_) => Ok(self.committed(new_file.to_path_buf())),
|
||||
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
|
||||
self.rollback()?;
|
||||
Err("A note with that name already exists".to_string())
|
||||
}
|
||||
Err(err) => {
|
||||
self.rollback()?;
|
||||
Err(format!(
|
||||
"Failed to rename {} to {}: {}",
|
||||
self.old_path,
|
||||
new_file.to_string_lossy(),
|
||||
err.error
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&self, new_file: &Path) -> Result<(), String> {
|
||||
self.write_manifest(new_file)?;
|
||||
self.move_into_backup()
|
||||
}
|
||||
|
||||
fn write_manifest(&self, new_file: &Path) -> Result<(), String> {
|
||||
let transaction = RenameTransaction {
|
||||
old_path: self.old_path.to_string(),
|
||||
new_path: new_file.to_string_lossy().to_string(),
|
||||
backup_path: self.backup_path.to_string_lossy().to_string(),
|
||||
};
|
||||
let data = serde_json::to_string(&transaction)
|
||||
.map_err(|e| format!("Failed to serialize rename transaction: {}", e))?;
|
||||
fs::write(&self.manifest_path, data).map_err(|e| {
|
||||
format!(
|
||||
"Failed to write rename transaction {}: {}",
|
||||
self.manifest_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn move_into_backup(&self) -> Result<(), String> {
|
||||
fs::rename(self.old_file, &self.backup_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to move {} into rename backup {}: {}",
|
||||
self.old_file.display(),
|
||||
self.backup_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn rollback(&self) -> Result<(), String> {
|
||||
if self.backup_path.exists() {
|
||||
if let Some(parent) = self.old_file.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::rename(&self.backup_path, self.old_file).map_err(|e| {
|
||||
format!(
|
||||
"Failed to restore {} from {}: {}",
|
||||
self.old_file.display(),
|
||||
self.backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let _ = fs::remove_file(&self.manifest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn committed(&self, new_file: PathBuf) -> CommittedRename {
|
||||
CommittedRename {
|
||||
new_file,
|
||||
manifest_path: self.manifest_path.clone(),
|
||||
backup_path: self.backup_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_filename(filename: &str, attempt: usize) -> String {
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
if attempt == 0 {
|
||||
return filename.to_string();
|
||||
}
|
||||
format!("{}-{}{}", stem, attempt + 1, ext)
|
||||
}
|
||||
|
||||
fn transaction_dir(vault: &Path) -> PathBuf {
|
||||
vault.join(".tolaria-rename-txn")
|
||||
}
|
||||
|
||||
pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
let txn_dir = transaction_dir(vault);
|
||||
if !txn_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&txn_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to read rename transaction dir {}: {}",
|
||||
txn_dir.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
for entry in entries {
|
||||
let path = entry
|
||||
.map_err(|e| format!("Failed to read rename transaction entry: {}", e))?
|
||||
.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(data) = fs::read_to_string(&path) else {
|
||||
let _ = fs::remove_file(&path);
|
||||
continue;
|
||||
};
|
||||
let Ok(transaction) = serde_json::from_str::<RenameTransaction>(&data) else {
|
||||
let _ = fs::remove_file(&path);
|
||||
continue;
|
||||
};
|
||||
recover_rename_transaction(&path, transaction)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recover_rename_transaction(
|
||||
manifest_path: &Path,
|
||||
transaction: RenameTransaction,
|
||||
) -> Result<(), String> {
|
||||
let old_path = Path::new(&transaction.old_path);
|
||||
let new_path = Path::new(&transaction.new_path);
|
||||
let backup_path = Path::new(&transaction.backup_path);
|
||||
|
||||
if !backup_path.exists() {
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if new_path.exists() || old_path.exists() {
|
||||
let _ = fs::remove_file(backup_path);
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = old_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
fs::rename(backup_path, old_path).map_err(|e| {
|
||||
format!(
|
||||
"Failed to recover {} from {}: {}",
|
||||
old_path.display(),
|
||||
backup_path.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
let _ = fs::remove_file(manifest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
use super::*;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
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();
|
||||
}
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_canonical_system_metadata_keys() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project.md",
|
||||
"---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n---\n# Project\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.icon.as_deref(), Some("rocket"));
|
||||
assert_eq!(entry.order, Some(4));
|
||||
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
|
||||
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_legacy_system_metadata_keys_without_property_leaks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"project.md",
|
||||
"---\ntype: Type\nicon: rocket\norder: 4\nsidebar label: Projects\nsort: title:asc\n---\n# Project\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.icon.as_deref(), Some("rocket"));
|
||||
assert_eq!(entry.order, Some(4));
|
||||
assert_eq!(entry.sidebar_label.as_deref(), Some("Projects"));
|
||||
assert_eq!(entry.sort.as_deref(), Some("title:asc"));
|
||||
assert!(!entry.properties.contains_key("icon"));
|
||||
assert!(!entry.properties.contains_key("order"));
|
||||
assert!(!entry.properties.contains_key("sidebar label"));
|
||||
assert!(!entry.properties.contains_key("sort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_internal: secret\n_hidden_link: \"[[secret]]\"\nOwner: Luca\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert!(!entry.properties.contains_key("_internal"));
|
||||
assert!(!entry.relationships.contains_key("_hidden_link"));
|
||||
assert_eq!(
|
||||
entry
|
||||
.properties
|
||||
.get("Owner")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("Luca")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_note_width_without_property_leaks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: wide\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width.as_deref(), Some("wide"));
|
||||
assert!(!entry.properties.contains_key("_width"));
|
||||
assert!(!entry.relationships.contains_key("_width"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_note_width_modes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: expanded\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width, None);
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
use super::parsing::slug_to_title;
|
||||
use super::rename::title_to_slug;
|
||||
|
||||
const TITLE_PREFIXES: [&str; 2] = ["title:", "\"title\":"];
|
||||
|
||||
/// Result of a title sync check.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SyncAction {
|
||||
/// Title and filename are already in sync.
|
||||
InSync,
|
||||
/// Title was absent or desynced; frontmatter was updated on disk.
|
||||
Updated { title: String },
|
||||
}
|
||||
|
||||
/// Extract the raw `title:` value from frontmatter in file content.
|
||||
fn extract_raw_title(content: &str) -> Option<String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return None;
|
||||
}
|
||||
let fm = content[4..].split("\n---").next()?;
|
||||
fm.lines().find_map(extract_title_from_line)
|
||||
}
|
||||
|
||||
fn extract_title_from_line(line: &str) -> Option<String> {
|
||||
TITLE_PREFIXES
|
||||
.iter()
|
||||
.find_map(|prefix| line.trim_start().strip_prefix(prefix))
|
||||
.map(clean_title_value)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn clean_title_value(raw_value: &str) -> String {
|
||||
raw_value
|
||||
.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Sync the `title` frontmatter field with the filename.
|
||||
///
|
||||
/// Rules (filename is source of truth):
|
||||
/// - If `title` is absent → derive from filename, write to frontmatter
|
||||
/// - If `title` is present but its slug doesn't match the filename stem → overwrite
|
||||
/// - If both are in sync → no-op
|
||||
pub fn sync_title_on_open(path: &Path) -> Result<SyncAction, 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 stem = filename.strip_suffix(".md").unwrap_or(&filename);
|
||||
let expected_title = slug_to_title(stem);
|
||||
|
||||
let fm_title = extract_raw_title(&content);
|
||||
|
||||
match fm_title {
|
||||
Some(ref title) if title_to_slug(title) == stem => Ok(SyncAction::InSync),
|
||||
_ => {
|
||||
// Title absent or desynced — filename wins
|
||||
let value = FrontmatterValue::String(expected_title.clone());
|
||||
let updated = update_frontmatter_content(&content, "title", Some(value))
|
||||
.map_err(|e| format!("Failed to update frontmatter: {}", e))?;
|
||||
fs::write(path, &updated)
|
||||
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
|
||||
Ok(SyncAction::Updated {
|
||||
title: expected_title,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_note(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn assert_title_update(dir: &Path, name: &str, content: &str, expected_title: &str) -> String {
|
||||
let path = write_note(dir, name, content);
|
||||
let result = sync_title_on_open(&path).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
SyncAction::Updated {
|
||||
title: expected_title.to_string()
|
||||
}
|
||||
);
|
||||
fs::read_to_string(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_adds_title_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = assert_title_update(
|
||||
dir.path(),
|
||||
"career-tracks.md",
|
||||
"---\ntype: Note\n---\n# Career Tracks\n",
|
||||
"Career Tracks",
|
||||
);
|
||||
assert!(content.contains("title: Career Tracks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_noop_when_in_sync() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_note(
|
||||
dir.path(),
|
||||
"my-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n# My Note\n",
|
||||
);
|
||||
let result = sync_title_on_open(&path).unwrap();
|
||||
assert_eq!(result, SyncAction::InSync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_overwrites_desynced_title() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Filename says "new-name" but title says "Old Name"
|
||||
let content = assert_title_update(
|
||||
dir.path(),
|
||||
"new-name.md",
|
||||
"---\ntitle: Old Name\ntype: Note\n---\n# Old Name\n",
|
||||
"New Name",
|
||||
);
|
||||
assert!(content.contains("title: New Name"));
|
||||
assert!(!content.contains("title: Old Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_adds_frontmatter_when_none_exists() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = assert_title_update(
|
||||
dir.path(),
|
||||
"plain-note.md",
|
||||
"# Plain Note\n\nSome content.\n",
|
||||
"Plain Note",
|
||||
);
|
||||
assert!(content.starts_with("---\n"));
|
||||
assert!(content.contains("title: Plain Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_e2e_filename() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert_title_update(
|
||||
dir.path(),
|
||||
"e2e-test.md",
|
||||
"---\ntype: Note\n---\n",
|
||||
"E2e Test",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_preserves_other_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = write_note(
|
||||
dir.path(),
|
||||
"my-note.md",
|
||||
"---\ntype: Project\nstatus: Active\n---\n# My Note\n",
|
||||
);
|
||||
sync_title_on_open(&path).unwrap();
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("type: Project"));
|
||||
assert!(content.contains("status: Active"));
|
||||
assert!(content.contains("title: My Note"));
|
||||
}
|
||||
}
|
||||
111
product-source/hololake-platform/src-tauri/src/vault/trash.rs
Normal file
111
product-source/hololake-platform/src-tauri/src/vault/trash.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Permanently delete a single note file.
|
||||
/// Returns the deleted path on success, or an error if the file doesn't exist.
|
||||
pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
let file = Path::new(path);
|
||||
if !file.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
if !file.is_file() {
|
||||
return Err(format!("Path is not a file: {}", path));
|
||||
}
|
||||
fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?;
|
||||
log::info!("Permanently deleted note: {}", path);
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// Delete multiple note files from disk.
|
||||
/// Returns the list of successfully deleted paths.
|
||||
/// Skips files that don't exist or fail to delete (logs warnings).
|
||||
pub fn batch_delete_notes(paths: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut deleted = Vec::new();
|
||||
for path in paths {
|
||||
let file = Path::new(path.as_str());
|
||||
if !file.exists() {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
continue;
|
||||
}
|
||||
match fs::remove_file(file) {
|
||||
Ok(()) => {
|
||||
log::info!("Permanently deleted note: {}", path);
|
||||
deleted.push(path.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete {}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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_delete_note_removes_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"doomed.md",
|
||||
"---\ntitle: Doomed\n---\n# Doomed\n",
|
||||
);
|
||||
let path = dir.path().join("doomed.md");
|
||||
assert!(path.exists());
|
||||
let result = delete_note(path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_note_nonexistent_file() {
|
||||
let result = delete_note("/nonexistent/path/that/does/not/exist.md");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_removes_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "a.md", "---\ntitle: A\n---\n# A\n");
|
||||
create_test_file(dir.path(), "b.md", "---\ntitle: B\n---\n# B\n");
|
||||
create_test_file(dir.path(), "keep.md", "---\ntitle: Keep\n---\n# Keep\n");
|
||||
|
||||
let paths = vec![
|
||||
dir.path().join("a.md").to_str().unwrap().to_string(),
|
||||
dir.path().join("b.md").to_str().unwrap().to_string(),
|
||||
];
|
||||
let deleted = batch_delete_notes(&paths).unwrap();
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!dir.path().join("a.md").exists());
|
||||
assert!(!dir.path().join("b.md").exists());
|
||||
assert!(dir.path().join("keep.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_skips_nonexistent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "exists.md", "---\ntitle: X\n---\n# X\n");
|
||||
|
||||
let paths = vec![
|
||||
dir.path().join("exists.md").to_str().unwrap().to_string(),
|
||||
"/nonexistent/path.md".to_string(),
|
||||
];
|
||||
let deleted = batch_delete_notes(&paths).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(!dir.path().join("exists.md").exists());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
pub(crate) struct TypeTemplateSource<'a> {
|
||||
pub explicit_template: Option<String>,
|
||||
pub is_a: Option<&'a str>,
|
||||
pub title: &'a str,
|
||||
pub body: &'a str,
|
||||
}
|
||||
|
||||
struct TemplateLine<'a>(&'a str);
|
||||
|
||||
impl TemplateLine<'_> {
|
||||
fn has_structure(&self) -> bool {
|
||||
let trimmed = self.0.trim_start();
|
||||
trimmed.starts_with("## ")
|
||||
|| trimmed.starts_with("- [ ] ")
|
||||
|| TemplateLine(trimmed).is_field()
|
||||
}
|
||||
|
||||
fn is_field(&self) -> bool {
|
||||
let trimmed = self.0.trim();
|
||||
if !trimmed.ends_with(':') {
|
||||
return false;
|
||||
}
|
||||
let label = trimmed.trim_end_matches(':').trim();
|
||||
!label.is_empty() && !label.starts_with('-')
|
||||
}
|
||||
}
|
||||
|
||||
impl TypeTemplateSource<'_> {
|
||||
pub fn resolve(self) -> Option<String> {
|
||||
match self.explicit_template {
|
||||
Some(template) => Some(template),
|
||||
None if self.is_a == Some("Type") => self.body_template(),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn body_template(&self) -> Option<String> {
|
||||
let template = self.body_after_type_title()?.trim();
|
||||
if template
|
||||
.lines()
|
||||
.map(TemplateLine)
|
||||
.any(|line| line.has_structure())
|
||||
{
|
||||
Some(template.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn body_after_type_title(&self) -> Option<&str> {
|
||||
let body = self.body.trim_start();
|
||||
let (first_line, rest) = body.split_once('\n').unwrap_or((body, ""));
|
||||
let first_line = first_line.trim_end_matches('\r');
|
||||
let heading = first_line.strip_prefix("# ")?.trim();
|
||||
(heading == self.title).then_some(rest)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc};
|
||||
|
||||
fn parse_relative_amount(token: &str) -> Option<u32> {
|
||||
match token {
|
||||
"a" | "an" | "one" => Some(1),
|
||||
"two" => Some(2),
|
||||
"three" => Some(3),
|
||||
"four" => Some(4),
|
||||
"five" => Some(5),
|
||||
"six" => Some(6),
|
||||
"seven" => Some(7),
|
||||
"eight" => Some(8),
|
||||
"nine" => Some(9),
|
||||
"ten" => Some(10),
|
||||
"eleven" => Some(11),
|
||||
"twelve" => Some(12),
|
||||
_ => token.parse::<u32>().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_relative_date_filter(value: &str, reference: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let base = reference.date_naive().and_hms_opt(0, 0, 0)?.and_utc();
|
||||
match normalized.as_str() {
|
||||
"today" => return Some(base),
|
||||
"yesterday" => return Some(base - Duration::days(1)),
|
||||
"tomorrow" => return Some(base + Duration::days(1)),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let tokens: Vec<&str> = normalized.split_whitespace().collect();
|
||||
let (future, amount_token, unit_token) = match tokens.as_slice() {
|
||||
["in", amount, unit] => (true, *amount, *unit),
|
||||
[amount, unit, "ago"] => (false, *amount, *unit),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let amount = parse_relative_amount(amount_token)?;
|
||||
let unit = unit_token.strip_suffix('s').unwrap_or(unit_token);
|
||||
|
||||
match (future, unit) {
|
||||
(true, "day") => Some(base + Duration::days(amount as i64)),
|
||||
(false, "day") => Some(base - Duration::days(amount as i64)),
|
||||
(true, "week") => Some(base + Duration::weeks(amount as i64)),
|
||||
(false, "week") => Some(base - Duration::weeks(amount as i64)),
|
||||
(true, "month") => base.checked_add_months(Months::new(amount)),
|
||||
(false, "month") => base.checked_sub_months(Months::new(amount)),
|
||||
(true, "year") => base.checked_add_months(Months::new(amount * 12)),
|
||||
(false, "year") => base.checked_sub_months(Months::new(amount * 12)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_date_filter_timestamp(value: &str, reference: DateTime<Utc>) -> Option<i64> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
||||
return Some(date.and_hms_opt(0, 0, 0)?.and_utc().timestamp_millis());
|
||||
}
|
||||
|
||||
if let Ok(datetime) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(datetime.and_utc().timestamp_millis());
|
||||
}
|
||||
|
||||
if let Ok(datetime) = DateTime::parse_from_rfc3339(trimmed) {
|
||||
return Some(datetime.with_timezone(&Utc).timestamp_millis());
|
||||
}
|
||||
|
||||
parse_relative_date_filter(trimmed, reference).map(|datetime| datetime.timestamp_millis())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn parses_relative_days_ago() {
|
||||
let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap();
|
||||
let parsed = parse_date_filter_timestamp("10 days ago", reference).unwrap();
|
||||
let expected = Utc
|
||||
.with_ymd_and_hms(2026, 3, 28, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp_millis();
|
||||
|
||||
assert_eq!(parsed, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_relative_one_week_ago() {
|
||||
let reference = Utc.with_ymd_and_hms(2026, 4, 7, 12, 0, 0).unwrap();
|
||||
let parsed = parse_date_filter_timestamp("one week ago", reference).unwrap();
|
||||
let expected = Utc
|
||||
.with_ymd_and_hms(2026, 3, 31, 0, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp_millis();
|
||||
|
||||
assert_eq!(parsed, expected);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn is_view_definition_file(path: &Path) -> bool {
|
||||
path.extension().and_then(|ext| ext.to_str()) == Some("yml")
|
||||
}
|
||||
|
||||
pub(super) fn migrate_views(vault_path: &Path) {
|
||||
let old_dir = legacy_views_dir(vault_path);
|
||||
if !old_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(yml_files) = legacy_view_files(&old_dir) else {
|
||||
return;
|
||||
};
|
||||
let new_dir = current_views_dir(vault_path);
|
||||
if fs::create_dir_all(&new_dir).is_err() {
|
||||
log::warn!("Failed to create views/ directory for migration");
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in yml_files {
|
||||
migrate_view_file(&new_dir, entry);
|
||||
}
|
||||
|
||||
remove_empty_legacy_views_dir(&old_dir);
|
||||
}
|
||||
|
||||
fn legacy_views_dir(vault_path: &Path) -> PathBuf {
|
||||
vault_path.join(".laputa").join("views")
|
||||
}
|
||||
|
||||
fn current_views_dir(vault_path: &Path) -> PathBuf {
|
||||
vault_path.join("views")
|
||||
}
|
||||
|
||||
fn legacy_view_files(old_dir: &Path) -> Option<Vec<fs::DirEntry>> {
|
||||
let entries = fs::read_dir(old_dir).ok()?;
|
||||
let yml_files: Vec<_> = entries
|
||||
.flatten()
|
||||
.filter(|entry| is_view_definition_file(&entry.path()))
|
||||
.collect();
|
||||
|
||||
(!yml_files.is_empty()).then_some(yml_files)
|
||||
}
|
||||
|
||||
fn migrate_view_file(new_dir: &Path, entry: fs::DirEntry) {
|
||||
let src = entry.path();
|
||||
let dst = new_dir.join(entry.file_name());
|
||||
if dst.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = fs::rename(&src, &dst) {
|
||||
log::warn!("Failed to migrate view {:?}: {}", src, error);
|
||||
} else {
|
||||
log::info!("Migrated view {:?} -> {:?}", src, dst);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_empty_legacy_views_dir(old_dir: &Path) {
|
||||
if fs::read_dir(old_dir)
|
||||
.map(|mut entries| entries.next().is_none())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_dir(old_dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
use super::view_value_conversions::{yaml_value_to_string, yaml_value_to_string_vec};
|
||||
use super::views::FilterOp;
|
||||
|
||||
pub(super) fn relationship_candidates(link: &str) -> Vec<String> {
|
||||
RelationshipLink::new(link).candidates()
|
||||
}
|
||||
|
||||
pub(super) fn evaluate_relationship_op(
|
||||
op: &FilterOp,
|
||||
rels: &[String],
|
||||
value: &Option<serde_yaml::Value>,
|
||||
) -> bool {
|
||||
let relationships = Relationships::new(rels);
|
||||
match op {
|
||||
FilterOp::Contains => {
|
||||
relationship_target(value).is_some_and(|target| relationships.contains(&target))
|
||||
}
|
||||
FilterOp::NotContains => {
|
||||
relationship_target(value).map_or(true, |target| !relationships.contains(&target))
|
||||
}
|
||||
FilterOp::AnyOf => relationships.matches_any(&relationship_values(value)),
|
||||
FilterOp::NoneOf => !relationships.matches_any(&relationship_values(value)),
|
||||
FilterOp::IsEmpty => relationships.is_empty(),
|
||||
FilterOp::IsNotEmpty => !relationships.is_empty(),
|
||||
FilterOp::Equals => relationship_target(value).map_or_else(
|
||||
|| relationships.is_empty(),
|
||||
|target| relationships.equals(&target),
|
||||
),
|
||||
FilterOp::NotEquals => relationship_target(value).map_or_else(
|
||||
|| !relationships.is_empty(),
|
||||
|target| !relationships.equals(&target),
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
struct Relationships<'a> {
|
||||
values: &'a [String],
|
||||
}
|
||||
|
||||
impl<'a> Relationships<'a> {
|
||||
fn new(values: &'a [String]) -> Self {
|
||||
Self { values }
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
|
||||
fn contains(&self, target: &RelationshipTarget) -> bool {
|
||||
self.values
|
||||
.iter()
|
||||
.any(|relationship| target.matches(RelationshipLink::new(relationship)))
|
||||
}
|
||||
|
||||
fn matches_any(&self, targets: &RelationshipTargets) -> bool {
|
||||
self.values
|
||||
.iter()
|
||||
.any(|relationship| targets.matches(RelationshipLink::new(relationship)))
|
||||
}
|
||||
|
||||
fn equals(&self, target: &RelationshipTarget) -> bool {
|
||||
self.values.len() == 1 && self.contains(target)
|
||||
}
|
||||
}
|
||||
|
||||
struct RelationshipTarget {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl RelationshipTarget {
|
||||
fn new(value: String) -> Self {
|
||||
Self { value }
|
||||
}
|
||||
|
||||
fn matches(&self, relationship: RelationshipLink<'_>) -> bool {
|
||||
self.as_link().normalized_stem() == relationship.normalized_stem()
|
||||
}
|
||||
|
||||
fn as_link(&self) -> RelationshipLink<'_> {
|
||||
RelationshipLink::new(&self.value)
|
||||
}
|
||||
}
|
||||
|
||||
struct RelationshipTargets {
|
||||
values: Vec<RelationshipTarget>,
|
||||
}
|
||||
|
||||
impl RelationshipTargets {
|
||||
fn new(values: Vec<RelationshipTarget>) -> Self {
|
||||
Self { values }
|
||||
}
|
||||
|
||||
fn matches(&self, relationship: RelationshipLink<'_>) -> bool {
|
||||
let relationship_stem = relationship.normalized_stem();
|
||||
self.values
|
||||
.iter()
|
||||
.any(|value| value.as_link().normalized_stem() == relationship_stem)
|
||||
}
|
||||
}
|
||||
|
||||
struct RelationshipLink<'a> {
|
||||
value: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> RelationshipLink<'a> {
|
||||
fn new(value: &'a str) -> Self {
|
||||
Self { value }
|
||||
}
|
||||
|
||||
fn candidates(&self) -> Vec<String> {
|
||||
let trimmed = self.value.trim();
|
||||
match self.inner().split_once('|') {
|
||||
Some((stem, alias)) => vec![trimmed.to_string(), stem.to_string(), alias.to_string()],
|
||||
None => vec![trimmed.to_string(), self.inner().to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_stem(&self) -> String {
|
||||
self.stem().to_lowercase()
|
||||
}
|
||||
|
||||
fn stem(&self) -> &str {
|
||||
match self.inner().split_once('|') {
|
||||
Some((stem, _)) => stem,
|
||||
None => self.inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inner(&self) -> &str {
|
||||
let trimmed = self.value.trim();
|
||||
trimmed
|
||||
.strip_prefix("[[")
|
||||
.unwrap_or(trimmed)
|
||||
.strip_suffix("]]")
|
||||
.unwrap_or(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn relationship_target(value: &Option<serde_yaml::Value>) -> Option<RelationshipTarget> {
|
||||
value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string)
|
||||
.map(RelationshipTarget::new)
|
||||
}
|
||||
|
||||
fn relationship_values(value: &Option<serde_yaml::Value>) -> RelationshipTargets {
|
||||
let values = value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(RelationshipTarget::new)
|
||||
.collect();
|
||||
RelationshipTargets::new(values)
|
||||
}
|
||||
|
|
@ -0,0 +1,541 @@
|
|||
use super::views::*;
|
||||
use super::VaultEntry;
|
||||
use std::fs;
|
||||
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry {
|
||||
let mut entry = VaultEntry::default();
|
||||
overrides(&mut entry);
|
||||
entry
|
||||
}
|
||||
|
||||
fn make_project_view(name: &str) -> ViewDefinition {
|
||||
ViewDefinition {
|
||||
name: name.to_string(),
|
||||
icon: None,
|
||||
color: None,
|
||||
order: None,
|
||||
sort: None,
|
||||
list_properties_display: Vec::new(),
|
||||
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
|
||||
field: "type".to_string(),
|
||||
op: FilterOp::Equals,
|
||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||
regex: false,
|
||||
})]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_view() {
|
||||
let yaml = r#"
|
||||
name: Active Projects
|
||||
icon: rocket
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(def.name, "Active Projects");
|
||||
assert_eq!(def.icon.as_deref(), Some("rocket"));
|
||||
assert!(def.list_properties_display.is_empty());
|
||||
match &def.filters {
|
||||
FilterGroup::All(nodes) => {
|
||||
assert_eq!(nodes.len(), 1);
|
||||
match &nodes[0] {
|
||||
FilterNode::Condition(c) => {
|
||||
assert_eq!(c.field, "type");
|
||||
}
|
||||
_ => panic!("Expected condition"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected All group"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_equals() {
|
||||
let yaml = r#"
|
||||
name: Projects
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let matching = make_entry(|e| e.is_a = Some("Project".to_string()));
|
||||
let non_matching = make_entry(|e| e.is_a = Some("Note".to_string()));
|
||||
let entries = vec![matching, non_matching];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_contains_relationship() {
|
||||
let yaml = r#"
|
||||
name: Related to Target
|
||||
filters:
|
||||
all:
|
||||
- field: Related to
|
||||
op: contains
|
||||
value: "[[target]]"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let mut rels = HashMap::new();
|
||||
rels.insert(
|
||||
"Related to".to_string(),
|
||||
vec!["[[target]]".to_string(), "[[other]]".to_string()],
|
||||
);
|
||||
let matching = make_entry(|e| e.relationships = rels);
|
||||
|
||||
let non_matching = make_entry(|_| {});
|
||||
let entries = vec![matching, non_matching];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_regex_on_scalar_field() {
|
||||
let yaml = r#"
|
||||
name: Regex Title
|
||||
filters:
|
||||
all:
|
||||
- field: title
|
||||
op: contains
|
||||
value: "^alpha\\s+project$"
|
||||
regex: true
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let matching = make_entry(|e| e.title = "Alpha Project".to_string());
|
||||
let case_matching = make_entry(|e| e.title = "alpha project".to_string());
|
||||
let non_matching = make_entry(|e| e.title = "Alpha Notes".to_string());
|
||||
let entries = vec![matching, case_matching, non_matching];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_regex_on_relationship_field() {
|
||||
let yaml = r#"
|
||||
name: Regex Relationship
|
||||
filters:
|
||||
all:
|
||||
- field: Related to
|
||||
op: contains
|
||||
value: "monday-(112|113)|Monday #112"
|
||||
regex: true
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let mut alias_rels = HashMap::new();
|
||||
alias_rels.insert(
|
||||
"Related to".to_string(),
|
||||
vec!["[[monday-112|Monday #112]]".to_string()],
|
||||
);
|
||||
let alias_match = make_entry(|e| e.relationships = alias_rels);
|
||||
|
||||
let mut stem_rels = HashMap::new();
|
||||
stem_rels.insert("Related to".to_string(), vec!["[[monday-113]]".to_string()]);
|
||||
let stem_match = make_entry(|e| e.relationships = stem_rels);
|
||||
|
||||
let mut other_rels = HashMap::new();
|
||||
other_rels.insert(
|
||||
"Related to".to_string(),
|
||||
vec!["[[tuesday-200|Tuesday]]".to_string()],
|
||||
);
|
||||
let non_matching = make_entry(|e| e.relationships = other_rels);
|
||||
|
||||
let entries = vec![alias_match, stem_match, non_matching];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_regex_matches_nothing() {
|
||||
let yaml = r#"
|
||||
name: Broken Regex
|
||||
filters:
|
||||
all:
|
||||
- field: title
|
||||
op: contains
|
||||
value: "("
|
||||
regex: true
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let entries = vec![
|
||||
make_entry(|e| e.title = "Alpha Project".to_string()),
|
||||
make_entry(|e| e.title = "Beta Project".to_string()),
|
||||
];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_nested_and_or() {
|
||||
let yaml = r#"
|
||||
name: Complex
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- any:
|
||||
- field: status
|
||||
op: equals
|
||||
value: Active
|
||||
- field: status
|
||||
op: equals
|
||||
value: Planning
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let active_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Active".to_string());
|
||||
});
|
||||
let planning_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Planning".to_string());
|
||||
});
|
||||
let done_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Done".to_string());
|
||||
});
|
||||
let active_note = make_entry(|e| {
|
||||
e.is_a = Some("Note".to_string());
|
||||
e.status = Some("Active".to_string());
|
||||
});
|
||||
|
||||
let entries = vec![active_project, planning_project, done_project, active_note];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_is_empty() {
|
||||
let yaml_empty = r#"
|
||||
name: No Status
|
||||
filters:
|
||||
all:
|
||||
- field: status
|
||||
op: is_empty
|
||||
"#;
|
||||
let yaml_not_empty = r#"
|
||||
name: Has Status
|
||||
filters:
|
||||
all:
|
||||
- field: status
|
||||
op: is_not_empty
|
||||
"#;
|
||||
let def_empty: ViewDefinition = serde_yaml::from_str(yaml_empty).unwrap();
|
||||
let def_not_empty: ViewDefinition = serde_yaml::from_str(yaml_not_empty).unwrap();
|
||||
|
||||
let with_status = make_entry(|e| e.status = Some("Active".to_string()));
|
||||
let without_status = make_entry(|_| {});
|
||||
let entries = vec![with_status, without_status];
|
||||
|
||||
assert_eq!(evaluate_view(&def_empty, &entries), vec![1]);
|
||||
assert_eq!(evaluate_view(&def_not_empty, &entries), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_views_reads_yml_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let views_dir = dir.path().join("views");
|
||||
fs::create_dir_all(&views_dir).unwrap();
|
||||
|
||||
let yaml_a = "name: Alpha\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
let yaml_b =
|
||||
"name: Beta\nfilters:\n any:\n - field: status\n op: equals\n value: Active\n";
|
||||
fs::write(views_dir.join("a-view.yml"), yaml_a).unwrap();
|
||||
fs::write(views_dir.join("b-view.yml"), yaml_b).unwrap();
|
||||
fs::write(views_dir.join("readme.txt"), "ignore me").unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 2);
|
||||
assert_eq!(views[0].filename, "a-view.yml");
|
||||
assert_eq!(views[0].definition.name, "Alpha");
|
||||
assert_eq!(views[1].filename, "b-view.yml");
|
||||
assert_eq!(views[1].definition.name, "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_views_sorts_by_persisted_order_then_filename() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let views_dir = dir.path().join("views");
|
||||
fs::create_dir_all(&views_dir).unwrap();
|
||||
|
||||
let alpha =
|
||||
"name: Alpha\norder: 20\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
let beta =
|
||||
"name: Beta\norder: 10\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
let gamma =
|
||||
"name: Gamma\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
fs::write(views_dir.join("alpha.yml"), alpha).unwrap();
|
||||
fs::write(views_dir.join("beta.yml"), beta).unwrap();
|
||||
fs::write(views_dir.join("gamma.yml"), gamma).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
|
||||
assert_eq!(
|
||||
views
|
||||
.iter()
|
||||
.map(|view| (view.filename.as_str(), view.definition.order))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("beta.yml", Some(10)),
|
||||
("alpha.yml", Some(20)),
|
||||
("gamma.yml", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_views_from_old_location() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let old_dir = dir.path().join(".laputa").join("views");
|
||||
fs::create_dir_all(&old_dir).unwrap();
|
||||
|
||||
let yaml = "name: Migrated\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
fs::write(old_dir.join("test.yml"), yaml).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Migrated");
|
||||
|
||||
assert!(dir.path().join("views").join("test.yml").exists());
|
||||
assert!(!old_dir.join("test.yml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let mut def = make_project_view("Test View");
|
||||
def.icon = Some("star".to_string());
|
||||
def.sort = Some("modified:desc".to_string());
|
||||
def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()];
|
||||
|
||||
save_view(dir.path(), "test.yml", &def).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Test View");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
|
||||
assert_eq!(
|
||||
views[0].definition.list_properties_display,
|
||||
vec!["Priority".to_string(), "Owner".to_string()]
|
||||
);
|
||||
|
||||
delete_view(dir.path(), "test.yml").unwrap();
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_view_treats_missing_file_as_deleted() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("views")).unwrap();
|
||||
|
||||
delete_view(dir.path(), "missing.yml").unwrap();
|
||||
|
||||
assert!(scan_views(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_view_treats_missing_views_directory_as_deleted() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
delete_view(dir.path(), "missing.yml").unwrap();
|
||||
|
||||
assert!(scan_views(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view_with_emoji_icon() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let mut def = make_project_view("Monday");
|
||||
def.icon = Some("🗂️".to_string());
|
||||
|
||||
save_view(dir.path(), "monday.yml", &def).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Monday");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("🗂️"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wikilink_stem_matching() {
|
||||
let yaml = r#"
|
||||
name: Linked
|
||||
filters:
|
||||
all:
|
||||
- field: Topics
|
||||
op: contains
|
||||
value: "[[target]]"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let mut rels = HashMap::new();
|
||||
rels.insert("Topics".to_string(), vec!["[[target|Alias]]".to_string()]);
|
||||
let matching = make_entry(|e| e.relationships = rels);
|
||||
|
||||
let mut rels2 = HashMap::new();
|
||||
rels2.insert("Topics".to_string(), vec!["[[other|Alias]]".to_string()]);
|
||||
let non_matching = make_entry(|e| e.relationships = rels2);
|
||||
|
||||
let entries = vec![matching, non_matching];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contains_matches_scalar_array_property_element() {
|
||||
let yaml = r#"
|
||||
name: Tagged
|
||||
filters:
|
||||
all:
|
||||
- field: tags
|
||||
op: contains
|
||||
value: blues
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let matching = make_entry(|e| {
|
||||
e.properties
|
||||
.insert("tags".to_string(), serde_json::json!(["blues", "chicago"]));
|
||||
});
|
||||
let partial = make_entry(|e| {
|
||||
e.properties.insert(
|
||||
"tags".to_string(),
|
||||
serde_json::json!(["bluegrass", "chicago"]),
|
||||
);
|
||||
});
|
||||
|
||||
let entries = vec![matching, partial];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_contains_filters_on_snippet() {
|
||||
let yaml = r#"
|
||||
name: Body Search
|
||||
filters:
|
||||
all:
|
||||
- field: body
|
||||
op: contains
|
||||
value: "quarterly"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let matching = make_entry(|e| {
|
||||
e.title = "Match".to_string();
|
||||
e.snippet = "This is the quarterly review summary".to_string();
|
||||
});
|
||||
let non_matching = make_entry(|e| {
|
||||
e.title = "No match".to_string();
|
||||
e.snippet = "Daily standup notes".to_string();
|
||||
});
|
||||
let case_match = make_entry(|e| {
|
||||
e.title = "Case match".to_string();
|
||||
e.snippet = "QUARTERLY PLANNING session".to_string();
|
||||
});
|
||||
|
||||
let entries = vec![matching, non_matching, case_match];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_not_contains() {
|
||||
let yaml = r#"
|
||||
name: Body Exclude
|
||||
filters:
|
||||
all:
|
||||
- field: body
|
||||
op: not_contains
|
||||
value: "draft"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let final_note = make_entry(|e| {
|
||||
e.snippet = "Final version of the document".to_string();
|
||||
});
|
||||
let draft_note = make_entry(|e| {
|
||||
e.snippet = "This is a draft version".to_string();
|
||||
});
|
||||
|
||||
let entries = vec![final_note, draft_note];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_combined_with_type_filter() {
|
||||
let yaml = r#"
|
||||
name: Combined
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Note
|
||||
- field: body
|
||||
op: contains
|
||||
value: "important"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let yes = make_entry(|e| {
|
||||
e.is_a = Some("Note".to_string());
|
||||
e.snippet = "This is important content".to_string();
|
||||
});
|
||||
let wrong_type = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.snippet = "This is important content".to_string();
|
||||
});
|
||||
let no_match = make_entry(|e| {
|
||||
e.is_a = Some("Note".to_string());
|
||||
e.snippet = "Regular content".to_string();
|
||||
});
|
||||
|
||||
let entries = vec![yes, wrong_type, no_match];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_body_is_empty() {
|
||||
let yaml = r#"
|
||||
name: Empty Body
|
||||
filters:
|
||||
all:
|
||||
- field: body
|
||||
op: is_empty
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let empty = make_entry(|e| e.snippet = String::new());
|
||||
let has_content = make_entry(|e| e.snippet = "Some text here".to_string());
|
||||
|
||||
let entries = vec![empty, has_content];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
pub(super) fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
|
||||
match value {
|
||||
serde_json::Value::String(value) => Some(value.clone()),
|
||||
serde_json::Value::Number(value) => Some(value.to_string()),
|
||||
serde_json::Value::Bool(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn json_scalar_array_to_strings(value: &serde_json::Value) -> Option<Vec<String>> {
|
||||
value
|
||||
.as_array()
|
||||
.map(|sequence| sequence.iter().filter_map(json_scalar_to_string).collect())
|
||||
}
|
||||
|
||||
pub(super) fn yaml_value_to_string(value: &serde_yaml::Value) -> Option<String> {
|
||||
match value {
|
||||
serde_yaml::Value::String(value) => Some(value.clone()),
|
||||
serde_yaml::Value::Number(value) => Some(value.to_string()),
|
||||
serde_yaml::Value::Bool(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn yaml_value_to_string_vec(value: &serde_yaml::Value) -> Option<Vec<String>> {
|
||||
value
|
||||
.as_sequence()
|
||||
.map(|sequence| sequence.iter().filter_map(yaml_value_to_string).collect())
|
||||
}
|
||||
532
product-source/hololake-platform/src-tauri/src/vault/views.rs
Normal file
532
product-source/hololake-platform/src-tauri/src/vault/views.rs
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
use chrono::Utc;
|
||||
use regex::{Regex, RegexBuilder};
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use super::view_date_filters::parse_date_filter_timestamp;
|
||||
use super::view_migration::{is_view_definition_file, migrate_views};
|
||||
use super::view_relationships::{evaluate_relationship_op, relationship_candidates};
|
||||
use super::view_value_conversions::{
|
||||
json_scalar_array_to_strings, json_scalar_to_string, yaml_value_to_string,
|
||||
yaml_value_to_string_vec,
|
||||
};
|
||||
use super::VaultEntry;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ViewDefinition {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
rename = "listPropertiesDisplay",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub list_properties_display: Vec<String>,
|
||||
pub filters: FilterGroup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterGroup {
|
||||
All(Vec<FilterNode>),
|
||||
Any(Vec<FilterNode>),
|
||||
}
|
||||
|
||||
impl Serialize for FilterGroup {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut map = serializer.serialize_map(Some(1))?;
|
||||
match self {
|
||||
FilterGroup::All(nodes) => map.serialize_entry("all", nodes)?,
|
||||
FilterGroup::Any(nodes) => map.serialize_entry("any", nodes)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FilterGroup {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct FilterGroupVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for FilterGroupVisitor {
|
||||
type Value = FilterGroup;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a map with key 'all' or 'any'")
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<FilterGroup, M::Error> {
|
||||
let key: String = map
|
||||
.next_key()?
|
||||
.ok_or_else(|| de::Error::custom("expected 'all' or 'any' key"))?;
|
||||
match key.as_str() {
|
||||
"all" => {
|
||||
let nodes: Vec<FilterNode> = map.next_value()?;
|
||||
Ok(FilterGroup::All(nodes))
|
||||
}
|
||||
"any" => {
|
||||
let nodes: Vec<FilterNode> = map.next_value()?;
|
||||
Ok(FilterGroup::Any(nodes))
|
||||
}
|
||||
other => Err(de::Error::unknown_field(other, &["all", "any"])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(FilterGroupVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterNode {
|
||||
Condition(FilterCondition),
|
||||
Group(FilterGroup),
|
||||
}
|
||||
|
||||
impl Serialize for FilterNode {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
FilterNode::Condition(c) => c.serialize(serializer),
|
||||
FilterNode::Group(g) => g.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FilterNode {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Deserialize into a generic YAML value, then try group first, then condition
|
||||
let value = serde_yaml::Value::deserialize(deserializer)?;
|
||||
if let serde_yaml::Value::Mapping(ref m) = value {
|
||||
// If the map has an "all" or "any" key, it's a group
|
||||
let all_key = serde_yaml::Value::String("all".to_string());
|
||||
let any_key = serde_yaml::Value::String("any".to_string());
|
||||
if m.contains_key(&all_key) || m.contains_key(&any_key) {
|
||||
let group: FilterGroup =
|
||||
serde_yaml::from_value(value).map_err(de::Error::custom)?;
|
||||
return Ok(FilterNode::Group(group));
|
||||
}
|
||||
}
|
||||
let cond: FilterCondition = serde_yaml::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(FilterNode::Condition(cond))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FilterCondition {
|
||||
pub field: String,
|
||||
pub op: FilterOp,
|
||||
#[serde(default)]
|
||||
pub value: Option<serde_yaml::Value>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub regex: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum FilterOp {
|
||||
#[serde(rename = "equals")]
|
||||
Equals,
|
||||
#[serde(rename = "not_equals")]
|
||||
NotEquals,
|
||||
#[serde(rename = "contains")]
|
||||
Contains,
|
||||
#[serde(rename = "not_contains")]
|
||||
NotContains,
|
||||
#[serde(rename = "any_of")]
|
||||
AnyOf,
|
||||
#[serde(rename = "none_of")]
|
||||
NoneOf,
|
||||
#[serde(rename = "is_empty")]
|
||||
IsEmpty,
|
||||
#[serde(rename = "is_not_empty")]
|
||||
IsNotEmpty,
|
||||
#[serde(rename = "before")]
|
||||
Before,
|
||||
#[serde(rename = "after")]
|
||||
After,
|
||||
}
|
||||
|
||||
/// A view file on disk: filename + parsed definition.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ViewFile {
|
||||
pub filename: String,
|
||||
pub definition: ViewDefinition,
|
||||
}
|
||||
|
||||
fn read_view_file(path: &Path) -> Option<ViewFile> {
|
||||
if !is_view_definition_file(path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let filename = path.file_name()?.to_string_lossy().to_string();
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(error) => {
|
||||
log::warn!("Failed to read view file {}: {}", filename, error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let definition = match serde_yaml::from_str::<ViewDefinition>(&content) {
|
||||
Ok(definition) => definition,
|
||||
Err(error) => {
|
||||
log::warn!("Failed to parse view {}: {}", filename, error);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(ViewFile {
|
||||
filename,
|
||||
definition,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
|
||||
migrate_views(vault_path);
|
||||
let views_dir = vault_path.join("views");
|
||||
if !views_dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut views = Vec::new();
|
||||
let entries = match fs::read_dir(&views_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read views directory: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if let Some(view) = read_view_file(&entry.path()) {
|
||||
views.push(view);
|
||||
}
|
||||
}
|
||||
|
||||
views.sort_by(compare_views);
|
||||
views
|
||||
}
|
||||
|
||||
fn compare_views(left: &ViewFile, right: &ViewFile) -> Ordering {
|
||||
let order = left
|
||||
.definition
|
||||
.order
|
||||
.unwrap_or(i64::MAX)
|
||||
.cmp(&right.definition.order.unwrap_or(i64::MAX));
|
||||
order.then_with(|| left.filename.cmp(&right.filename))
|
||||
}
|
||||
|
||||
/// Save a view definition as YAML to `vault_path/views/{filename}`.
|
||||
pub fn save_view(
|
||||
vault_path: &Path,
|
||||
filename: &str,
|
||||
definition: &ViewDefinition,
|
||||
) -> Result<(), String> {
|
||||
if !filename.ends_with(".yml") {
|
||||
return Err("Filename must end with .yml".to_string());
|
||||
}
|
||||
let views_dir = vault_path.join("views");
|
||||
fs::create_dir_all(&views_dir)
|
||||
.map_err(|e| format!("Failed to create views directory: {}", e))?;
|
||||
let yaml = serde_yaml::to_string(definition)
|
||||
.map_err(|e| format!("Failed to serialize view: {}", e))?;
|
||||
fs::write(views_dir.join(filename), yaml)
|
||||
.map_err(|e| format!("Failed to write view file: {}", e))
|
||||
}
|
||||
|
||||
/// Delete a view file at `vault_path/views/{filename}`.
|
||||
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
|
||||
let path = vault_path.join("views").join(filename);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!("Failed to delete view: {}", error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a view definition against vault entries, returning indices of matching entries.
|
||||
pub fn evaluate_view(definition: &ViewDefinition, entries: &[VaultEntry]) -> Vec<usize> {
|
||||
entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| evaluate_group(&definition.filters, entry))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn evaluate_group(group: &FilterGroup, entry: &VaultEntry) -> bool {
|
||||
match group {
|
||||
FilterGroup::All(nodes) => nodes.iter().all(|n| evaluate_node(n, entry)),
|
||||
FilterGroup::Any(nodes) => nodes.iter().any(|n| evaluate_node(n, entry)),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_node(node: &FilterNode, entry: &VaultEntry) -> bool {
|
||||
match node {
|
||||
FilterNode::Condition(cond) => evaluate_condition(cond, entry),
|
||||
FilterNode::Group(group) => evaluate_group(group, entry),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_regex(pattern: &str) -> Option<regex::Regex> {
|
||||
RegexBuilder::new(pattern)
|
||||
.case_insensitive(true)
|
||||
.build()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn supports_regex(op: &FilterOp) -> bool {
|
||||
matches!(
|
||||
op,
|
||||
FilterOp::Contains | FilterOp::Equals | FilterOp::NotContains | FilterOp::NotEquals
|
||||
)
|
||||
}
|
||||
|
||||
enum ConditionField<'a> {
|
||||
Scalar(Option<String>),
|
||||
PropertyArray(Vec<String>),
|
||||
Relationship(&'a [String]),
|
||||
}
|
||||
|
||||
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||
let field = cond.field.as_str();
|
||||
if let Some(result) = evaluate_condition_bool_field(field, entry, &cond.op, &cond.value) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let field_value = resolve_condition_field(field, entry);
|
||||
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
|
||||
let regex = condition_regex(cond, cond_value.as_deref());
|
||||
|
||||
if invalid_regex_requested(cond, regex.as_ref()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(re) = regex.as_ref() {
|
||||
return evaluate_regex_condition(&cond.op, &field_value, re);
|
||||
}
|
||||
|
||||
match field_value {
|
||||
ConditionField::PropertyArray(values) => {
|
||||
evaluate_property_array_op(&cond.op, &values, &cond.value)
|
||||
}
|
||||
ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value),
|
||||
ConditionField::Scalar(value) => evaluate_scalar_op(
|
||||
&cond.op,
|
||||
value.as_deref(),
|
||||
cond_value.as_deref(),
|
||||
&cond.value,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_condition_bool_field(
|
||||
field: &str,
|
||||
entry: &VaultEntry,
|
||||
op: &FilterOp,
|
||||
value: &Option<serde_yaml::Value>,
|
||||
) -> Option<bool> {
|
||||
match field {
|
||||
"archived" => Some(evaluate_bool_field(entry.archived, op, value)),
|
||||
"favorite" => Some(evaluate_bool_field(entry.favorite, op, value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
|
||||
match field {
|
||||
"type" | "isA" => ConditionField::Scalar(entry.is_a.clone()),
|
||||
"status" => ConditionField::Scalar(entry.status.clone()),
|
||||
"title" => ConditionField::Scalar(Some(entry.title.clone())),
|
||||
"body" => ConditionField::Scalar(Some(entry.snippet.clone())),
|
||||
_ => resolve_dynamic_condition_field(field, entry),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
|
||||
if let Some(prop) = entry.properties.get(field) {
|
||||
if let Some(values) = json_scalar_array_to_strings(prop) {
|
||||
return ConditionField::PropertyArray(values);
|
||||
}
|
||||
return ConditionField::Scalar(json_scalar_to_string(prop));
|
||||
}
|
||||
if let Some(relationships) = entry.relationships.get(field) {
|
||||
return ConditionField::Relationship(relationships);
|
||||
}
|
||||
ConditionField::Scalar(None)
|
||||
}
|
||||
|
||||
fn condition_regex(cond: &FilterCondition, cond_value: Option<&str>) -> Option<Regex> {
|
||||
if !cond.regex {
|
||||
return None;
|
||||
}
|
||||
if !supports_regex(&cond.op) {
|
||||
return None;
|
||||
}
|
||||
cond_value.and_then(build_regex)
|
||||
}
|
||||
|
||||
fn invalid_regex_requested(cond: &FilterCondition, regex: Option<&Regex>) -> bool {
|
||||
if !cond.regex {
|
||||
return false;
|
||||
}
|
||||
if !supports_regex(&cond.op) {
|
||||
return false;
|
||||
}
|
||||
regex.is_none()
|
||||
}
|
||||
|
||||
fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool {
|
||||
let matched = match field {
|
||||
ConditionField::Scalar(Some(value)) => regex.is_match(value),
|
||||
ConditionField::PropertyArray(values) => values.iter().any(|value| regex.is_match(value)),
|
||||
ConditionField::Relationship(values) => values.iter().any(|item| {
|
||||
relationship_candidates(item)
|
||||
.into_iter()
|
||||
.any(|candidate| regex.is_match(&candidate))
|
||||
}),
|
||||
ConditionField::Scalar(None) => false,
|
||||
};
|
||||
|
||||
match op {
|
||||
FilterOp::Contains | FilterOp::Equals => matched,
|
||||
FilterOp::NotContains | FilterOp::NotEquals => !matched,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_property_array_op(
|
||||
op: &FilterOp,
|
||||
values: &[String],
|
||||
raw_value: &Option<serde_yaml::Value>,
|
||||
) -> bool {
|
||||
match op {
|
||||
FilterOp::Contains => property_array_matches_value(values, raw_value),
|
||||
FilterOp::NotContains => !property_array_matches_value(values, raw_value),
|
||||
FilterOp::AnyOf => property_array_matches_any(values, raw_value),
|
||||
FilterOp::NoneOf => !property_array_matches_any(values, raw_value),
|
||||
FilterOp::Equals => values.len() == 1 && property_array_matches_value(values, raw_value),
|
||||
FilterOp::NotEquals => {
|
||||
!(values.len() == 1 && property_array_matches_value(values, raw_value))
|
||||
}
|
||||
FilterOp::IsEmpty => values.is_empty(),
|
||||
FilterOp::IsNotEmpty => !values.is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn property_array_matches_value(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
|
||||
raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string)
|
||||
.is_some_and(|target| property_array_contains(values, &target))
|
||||
}
|
||||
|
||||
fn property_array_matches_any(values: &[String], raw_value: &Option<serde_yaml::Value>) -> bool {
|
||||
raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|target| property_array_contains(values, target))
|
||||
}
|
||||
|
||||
fn property_array_contains(values: &[String], target: &str) -> bool {
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.eq_ignore_ascii_case(target))
|
||||
}
|
||||
|
||||
fn evaluate_scalar_op(
|
||||
op: &FilterOp,
|
||||
field_value: Option<&str>,
|
||||
cond_value: Option<&str>,
|
||||
raw_value: &Option<serde_yaml::Value>,
|
||||
) -> bool {
|
||||
match op {
|
||||
FilterOp::Equals => scalar_equals(field_value, cond_value),
|
||||
FilterOp::NotEquals => !scalar_equals(field_value, cond_value),
|
||||
FilterOp::Contains => scalar_contains(field_value, cond_value),
|
||||
FilterOp::NotContains => !scalar_contains(field_value, cond_value),
|
||||
FilterOp::AnyOf => scalar_matches_any(field_value, raw_value),
|
||||
FilterOp::NoneOf => !scalar_matches_any(field_value, raw_value),
|
||||
FilterOp::IsEmpty => field_value.map_or(true, str::is_empty),
|
||||
FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()),
|
||||
FilterOp::Before => {
|
||||
scalar_date_compare(field_value, cond_value, |field, target| field < target)
|
||||
}
|
||||
FilterOp::After => {
|
||||
scalar_date_compare(field_value, cond_value, |field, target| field > target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_equals(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
|
||||
match (field_value, cond_value) {
|
||||
(Some(field), Some(value)) => field.eq_ignore_ascii_case(value),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_contains(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
|
||||
match (field_value, cond_value) {
|
||||
(Some(field), Some(value)) => field.to_lowercase().contains(&value.to_lowercase()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_matches_any(field_value: Option<&str>, raw_value: &Option<serde_yaml::Value>) -> bool {
|
||||
let Some(field) = field_value else {
|
||||
return false;
|
||||
};
|
||||
raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|value| field.eq_ignore_ascii_case(value))
|
||||
}
|
||||
|
||||
fn scalar_date_compare(
|
||||
field_value: Option<&str>,
|
||||
cond_value: Option<&str>,
|
||||
predicate: impl FnOnce(i64, i64) -> bool,
|
||||
) -> bool {
|
||||
let (Some(field), Some(value)) = (field_value, cond_value) else {
|
||||
return false;
|
||||
};
|
||||
let reference = Utc::now();
|
||||
match (
|
||||
parse_date_filter_timestamp(field, reference),
|
||||
parse_date_filter_timestamp(value, reference),
|
||||
) {
|
||||
(Some(field_ts), Some(target_ts)) => predicate(field_ts, target_ts),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_bool_field(field_val: bool, op: &FilterOp, value: &Option<serde_yaml::Value>) -> bool {
|
||||
match op {
|
||||
FilterOp::Equals => {
|
||||
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
field_val == expected
|
||||
}
|
||||
FilterOp::NotEquals => {
|
||||
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
field_val != expected
|
||||
}
|
||||
FilterOp::IsEmpty => !field_val,
|
||||
FilterOp::IsNotEmpty => field_val,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue