feat: add custom views backend — YAML parser, filter engine, Tauri commands

Introduce `.laputa/views/*.yml` for user-defined filtered note lists.
Rust backend: serde_yaml parsing, recursive AND/OR filter evaluation
with 10 operators (equals, contains, any_of, is_empty, before/after,
etc.), wikilink stem matching, and file CRUD. Three Tauri commands:
list_views, save_view_cmd, delete_view_cmd.

ADR 0040 documents the architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-04-02 15:57:22 +02:00
parent 927e0e11e7
commit a34274db25
7 changed files with 790 additions and 2 deletions

View File

@@ -0,0 +1,59 @@
---
type: ADR
id: "0040"
title: "Custom views as .yml files with client-side filter engine"
status: active
date: 2026-04-02
---
## Context
Users want to save reusable filtered note lists (e.g., "Active Projects", "This Week's Events") as named sidebar items. These views need to persist across sessions, sync via git, and support arbitrary frontmatter conditions.
## Decision
**Custom views are stored as `.yml` files in `.laputa/views/` within the vault root.** Each file defines a named view with filter conditions, optional icon/color, and sort preferences.
### File format
```yaml
name: Active Projects
icon: rocket
color: blue
sort: "modified:desc"
filters:
all:
- field: type
op: equals
value: Project
- field: status
op: not_equals
value: done
```
### Filter engine
Filters use a tree of AND/OR groups (`all`/`any`) containing conditions. Each condition specifies a `field`, `op` (operator), and optional `value`. Supported operators: `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, `after`.
Field resolution: built-in fields (`type`, `status`, `title`, `archived`, `trashed`, `favorite`) map to VaultEntry struct fields. Unknown fields fall back to `entry.properties`, then `entry.relationships`.
Wikilink values like `[[target|Alias]]` are matched by stem (stripping brackets and pipe+alias).
### Architecture
- **Rust backend** (`vault/views.rs`): YAML parsing via `serde_yaml`, filter evaluation, file CRUD. Three Tauri commands: `list_views`, `save_view_cmd`, `delete_view_cmd`.
- **Frontend**: Client-side filter evaluation against the already-loaded `VaultEntry[]` array. The Rust `evaluate_view` exists for MCP/CLI access but is not the primary UI path.
- **Sidebar**: VIEWS section between Favorites and Types, hidden when no views exist.
## Options considered
- **SQLite views table**: Fast queries, but not portable via git and diverges from the file-first data model.
- **Frontmatter on a special `.md` file**: Overloads the note format for a non-note concept.
- **Standalone `.yml` files (chosen)**: Portable (synced via git), editable by hand or UI, naturally separated from note content.
## Consequences
- New dependency: `serde_yaml` crate for YAML parsing.
- `.laputa/views/` directory auto-created on first view save. Already excluded from vault scanning via `HIDDEN_DIRS`.
- Views sync across devices via git. Conflicts resolved by standard git merge (YAML is line-based, merges well).
- Sort persistence: changing sort while a view is selected writes `sort` back to the `.yml` file.

20
src-tauri/Cargo.lock generated
View File

@@ -2272,6 +2272,7 @@ dependencies = [
"sentry",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -4294,6 +4295,19 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.13.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -5509,6 +5523,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"

View File

@@ -18,6 +18,7 @@ tauri-build = { version = "2.5.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
log = "0.4"
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
tauri-plugin-log = "2"

View File

@@ -1,6 +1,8 @@
use crate::frontmatter::FrontmatterValue;
use crate::search::SearchResponse;
use crate::vault::{DetectedRename, FolderNode, RenameResult, VaultEntry};
use crate::vault::{
DetectedRename, FolderNode, RenameResult, VaultEntry, ViewDefinition, ViewFile,
};
use crate::{frontmatter, git, search, vault};
use super::expand_tilde;
@@ -158,6 +160,30 @@ pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<St
vault::copy_image_to_vault(&vault_path, &source_path)
}
// ── View commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_views(vault_path: String) -> Vec<ViewFile> {
let path = expand_tilde(&vault_path);
vault::scan_views(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::save_view(std::path::Path::new(path.as_ref()), &filename, &definition)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::delete_view(std::path::Path::new(path.as_ref()), &filename)
}
// ── Frontmatter commands ────────────────────────────────────────────────────
#[tauri::command]

View File

@@ -176,7 +176,10 @@ pub fn run() {
commands::register_mcp_tools,
commands::check_mcp_status,
commands::repair_vault,
commands::reinit_telemetry
commands::reinit_telemetry,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -10,6 +10,7 @@ mod parsing;
mod rename;
mod title_sync;
mod trash;
mod views;
pub use cache::{invalidate_cache, scan_vault_cached};
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
@@ -23,6 +24,10 @@ pub use rename::{
};
pub use title_sync::{sync_title_on_open, SyncAction};
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
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};

View File

@@ -0,0 +1,674 @@
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::fs;
use std::path::Path;
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)]
pub sort: Option<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>,
}
#[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,
}
/// Scan all `.yml` files from `vault_path/.laputa/views/` and return parsed views.
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
let views_dir = vault_path.join(".laputa").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() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
continue;
}
let filename = entry.file_name().to_string_lossy().to_string();
match fs::read_to_string(&path) {
Ok(content) => match serde_yaml::from_str::<ViewDefinition>(&content) {
Ok(definition) => views.push(ViewFile {
filename,
definition,
}),
Err(e) => log::warn!("Failed to parse view {}: {}", filename, e),
},
Err(e) => log::warn!("Failed to read view file {}: {}", filename, e),
}
}
views.sort_by(|a, b| a.filename.cmp(&b.filename));
views
}
/// Save a view definition as YAML to `vault_path/.laputa/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(".laputa").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/.laputa/views/{filename}`.
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
let path = vault_path.join(".laputa").join("views").join(filename);
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
}
/// 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),
}
}
/// Extract the stem from a wikilink: `[[target|Alias]]` -> `target`, `[[target]]` -> `target`.
fn wikilink_stem(link: &str) -> &str {
let s = link
.strip_prefix("[[")
.unwrap_or(link)
.strip_suffix("]]")
.unwrap_or(link);
match s.split_once('|') {
Some((stem, _)) => stem,
None => s,
}
}
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
let field = cond.field.as_str();
// Boolean fields
match field {
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
"trashed" => return evaluate_bool_field(entry.trashed, &cond.op, &cond.value),
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
_ => {}
}
// String/option fields
let field_value: Option<String> = match field {
"type" | "isA" => entry.is_a.clone(),
"status" => entry.status.clone(),
"title" => Some(entry.title.clone()),
_ => {
// Check properties first, then relationships
if let Some(prop) = entry.properties.get(field) {
match prop {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
} else if let Some(rels) = entry.relationships.get(field) {
// For relationship fields, handle specially in contains/any_of etc.
return evaluate_relationship_op(&cond.op, rels, &cond.value);
} else {
None
}
}
};
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
match cond.op {
FilterOp::Equals => match (&field_value, &cond_value) {
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
(None, None) => true,
_ => false,
},
FilterOp::NotEquals => match (&field_value, &cond_value) {
(Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
(None, None) => false,
_ => true,
},
FilterOp::Contains => match (&field_value, &cond_value) {
(Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
_ => false,
},
FilterOp::NotContains => match (&field_value, &cond_value) {
(Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
(None, _) => true,
_ => true,
},
FilterOp::AnyOf => {
let values = cond
.value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match &field_value {
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => false,
}
}
FilterOp::NoneOf => {
let values = cond
.value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match &field_value {
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => true,
}
}
FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()),
FilterOp::IsNotEmpty => field_value.as_deref().map_or(false, |s| !s.is_empty()),
FilterOp::Before => match (&field_value, &cond_value) {
(Some(f), Some(v)) => f < v,
_ => false,
},
FilterOp::After => match (&field_value, &cond_value) {
(Some(f), Some(v)) => f > v,
_ => 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,
}
}
fn evaluate_relationship_op(
op: &FilterOp,
rels: &[String],
value: &Option<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Contains => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
let t_stem = wikilink_stem(&t).to_lowercase();
rels.iter()
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
}
None => false,
}
}
FilterOp::NotContains => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
let t_stem = wikilink_stem(&t).to_lowercase();
!rels
.iter()
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
}
None => true,
}
}
FilterOp::AnyOf => {
let values = value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
rels.iter().any(|r| {
let r_stem = wikilink_stem(r).to_lowercase();
values
.iter()
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
})
}
FilterOp::NoneOf => {
let values = value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
!rels.iter().any(|r| {
let r_stem = wikilink_stem(r).to_lowercase();
values
.iter()
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
})
}
FilterOp::IsEmpty => rels.is_empty(),
FilterOp::IsNotEmpty => !rels.is_empty(),
FilterOp::Equals => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
rels.len() == 1
&& wikilink_stem(&rels[0]).to_lowercase()
== wikilink_stem(&t).to_lowercase()
}
None => rels.is_empty(),
}
}
FilterOp::NotEquals => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
rels.len() != 1
|| wikilink_stem(&rels[0]).to_lowercase()
!= wikilink_stem(&t).to_lowercase()
}
None => !rels.is_empty(),
}
}
_ => false,
}
}
fn yaml_value_to_string(v: &serde_yaml::Value) -> Option<String> {
match v {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn yaml_value_to_string_vec(v: &serde_yaml::Value) -> Option<Vec<String>> {
v.as_sequence()
.map(|seq| seq.iter().filter_map(yaml_value_to_string).collect())
}
#[cfg(test)]
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
}
#[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"));
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_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(".laputa").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();
// Non-yml file should be ignored
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_save_and_read_view() {
let dir = tempfile::TempDir::new().unwrap();
let def = ViewDefinition {
name: "Test View".to_string(),
icon: Some("star".to_string()),
color: None,
sort: Some("modified:desc".to_string()),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".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"));
delete_view(dir.path(), "test.yml").unwrap();
let views = scan_views(dir.path());
assert_eq!(views.len(), 0);
}
#[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();
// Entry with aliased wikilink
let mut rels = HashMap::new();
rels.insert("Topics".to_string(), vec!["[[target|Alias]]".to_string()]);
let matching = make_entry(|e| e.relationships = rels);
// Entry with different target
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]);
}
}