fix(views): ignore stale saved-view deletes

This commit is contained in:
lucaronin
2026-05-02 03:25:02 +02:00
parent 1e5d83d4a3
commit db16f2ff5e
2 changed files with 92 additions and 60 deletions

View File

@@ -81,4 +81,14 @@ mod tests {
assert!(list_views(vault_path).unwrap().is_empty());
}
#[test]
fn delete_view_command_treats_missing_backing_file_as_deleted() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_string_lossy().to_string();
delete_view_cmd(vault_path.clone(), "stale-view.yml".to_string()).unwrap();
assert!(list_views(vault_path).unwrap().is_empty());
}
}

View File

@@ -5,6 +5,7 @@ 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::VaultEntry;
@@ -291,7 +292,11 @@ pub fn save_view(
/// 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);
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
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.
@@ -543,67 +548,65 @@ fn evaluate_scalar_op(
raw_value: &Option<serde_yaml::Value>,
) -> bool {
match 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 = raw_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 = raw_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::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 => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
) {
(Some(field_ts), Some(target_ts)) => field_ts < target_ts,
_ => false,
},
_ => false,
},
FilterOp::After => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
) {
(Some(field_ts), Some(target_ts)) => field_ts > target_ts,
_ => false,
},
_ => false,
},
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,
}
}
@@ -1067,6 +1070,25 @@ filters:
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();