From be7b0147d2f4d7b96e3bed4be38a2f14239b2048 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:45:15 -0500 Subject: [PATCH] Update tag filters for partial matching --- src/plugins/note.rs | 64 ++++++++++++++++++++++++++++++++++++++++---- src/plugins/todo.rs | 24 +++++++++++++---- tests/todo_plugin.rs | 18 +++++++++++-- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/src/plugins/note.rs b/src/plugins/note.rs index ba40e71b..68b1a1f2 100644 --- a/src/plugins/note.rs +++ b/src/plugins/note.rs @@ -732,15 +732,14 @@ impl Plugin for NotePlugin { let tag_ok = if filters.include_tags.is_empty() { true } else { - filters - .include_tags - .iter() - .all(|tag| n.tags.iter().any(|t| t == tag)) + filters.include_tags.iter().all(|tag| { + n.tags.iter().any(|t| t.contains(tag)) + }) }; let exclude_ok = !filters .exclude_tags .iter() - .any(|tag| n.tags.iter().any(|t| t == tag)); + .any(|tag| n.tags.iter().any(|t| t.contains(tag))); let text_ok = if text_filter.is_empty() { true } else { @@ -1182,6 +1181,61 @@ mod tests { restore_cache(original); } + #[test] + fn note_list_supports_partial_tag_filters() { + let original = set_notes(vec![ + Note { + title: "Alpha".into(), + path: PathBuf::new(), + content: "Review #testing and #ui-kit changes.".into(), + tags: Vec::new(), + links: Vec::new(), + slug: "alpha".into(), + alias: None, + }, + Note { + title: "Beta".into(), + path: PathBuf::new(), + content: "Follow up on #testing checklist.".into(), + tags: Vec::new(), + links: Vec::new(), + slug: "beta".into(), + alias: None, + }, + Note { + title: "Gamma".into(), + path: PathBuf::new(), + content: "Finalize #ui rollout.".into(), + tags: Vec::new(), + links: Vec::new(), + slug: "gamma".into(), + alias: None, + }, + ]); + + let plugin = NotePlugin { + matcher: SkimMatcherV2::default(), + data: CACHE.clone(), + templates: TEMPLATE_CACHE.clone(), + external_open: NoteExternalOpen::Wezterm, + watcher: None, + }; + + let list_test = plugin.search("note list #test"); + let labels_test: Vec<&str> = list_test.iter().map(|a| a.label.as_str()).collect(); + assert_eq!(labels_test, vec!["Alpha", "Beta"]); + + let list_ui = plugin.search("note list @ui"); + let labels_ui: Vec<&str> = list_ui.iter().map(|a| a.label.as_str()).collect(); + assert_eq!(labels_ui, vec!["Alpha", "Gamma"]); + + let list_not_ui = plugin.search("note list !#ui"); + let labels_not_ui: Vec<&str> = list_not_ui.iter().map(|a| a.label.as_str()).collect(); + assert_eq!(labels_not_ui, vec!["Beta"]); + + restore_cache(original); + } + #[test] fn note_tag_lists_tags_and_drills_into_list() { let original = set_notes(vec![ diff --git a/src/plugins/todo.rs b/src/plugins/todo.rs index 2fd7a124..1fef5978 100644 --- a/src/plugins/todo.rs +++ b/src/plugins/todo.rs @@ -301,10 +301,16 @@ impl TodoPlugin { }; let mut entries: Vec<(usize, &TodoEntry)> = guard.iter().enumerate().collect(); - let tag_filter = filter.starts_with('#'); + let tag_filter = filter.starts_with('#') || filter.starts_with('@'); if tag_filter { - let tag = filter.trim_start_matches('#'); - entries.retain(|(_, t)| t.tags.iter().any(|tg| tg.eq_ignore_ascii_case(tag))); + let tag = filter.trim_start_matches(['#', '@']); + let requested = tag.to_lowercase(); + entries.retain(|(_, t)| { + !requested.is_empty() + && t.tags + .iter() + .any(|tg| tg.to_lowercase().contains(&requested)) + }); } else if !filter.is_empty() { entries.retain(|(_, t)| self.matcher.fuzzy_match(&t.text, filter).is_some()); } @@ -567,7 +573,10 @@ impl TodoPlugin { if !filters.include_tags.is_empty() { entries.retain(|(_, t)| { filters.include_tags.iter().all(|requested| { - t.tags.iter().any(|tag| tag.eq_ignore_ascii_case(requested)) + let requested = requested.to_lowercase(); + t.tags + .iter() + .any(|tag| tag.to_lowercase().contains(&requested)) }) }); } @@ -577,7 +586,12 @@ impl TodoPlugin { !filters .exclude_tags .iter() - .any(|excluded| t.tags.iter().any(|tag| tag.eq_ignore_ascii_case(excluded))) + .any(|excluded| { + let excluded = excluded.to_lowercase(); + t.tags + .iter() + .any(|tag| tag.to_lowercase().contains(&excluded)) + }) }); } diff --git a/tests/todo_plugin.rs b/tests/todo_plugin.rs index 72783df6..8f5041da 100644 --- a/tests/todo_plugin.rs +++ b/tests/todo_plugin.rs @@ -256,12 +256,26 @@ fn list_filters_by_tag() { std::env::set_current_dir(dir.path()).unwrap(); append_todo(TODO_FILE, "alpha", 1, &["rs3".into()]).unwrap(); - append_todo(TODO_FILE, "beta", 1, &["other".into()]).unwrap(); + append_todo(TODO_FILE, "beta", 1, &["work".into()]).unwrap(); + append_todo(TODO_FILE, "gamma", 1, &["workshop".into()]).unwrap(); + append_todo(TODO_FILE, "delta", 1, &["ui-kit".into()]).unwrap(); + append_todo(TODO_FILE, "epsilon", 1, &["backend".into()]).unwrap(); let plugin = TodoPlugin::default(); - let results = plugin.search("todo list #rs3"); + let results = plugin.search("todo list #rs"); assert_eq!(results.len(), 1); assert!(results[0].label.contains("alpha")); + + let results = plugin.search("todo list @wor"); + let labels: Vec<&str> = results.iter().map(|action| action.label.as_str()).collect(); + assert_eq!(results.len(), 2); + assert!(labels.iter().any(|label| label.contains("beta"))); + assert!(labels.iter().any(|label| label.contains("gamma"))); + + let results = plugin.search("todo list !#ui"); + let labels: Vec<&str> = results.iter().map(|action| action.label.as_str()).collect(); + assert_eq!(results.len(), 4); + assert!(!labels.iter().any(|label| label.contains("delta"))); } #[test]