From 001639dd3b3cd37a0d6322d07e6e877285387d35 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:12:58 -0500 Subject: [PATCH] Add usage parsing helpers for bright and todo --- src/common/command.rs | 19 ++++ src/common/mod.rs | 1 + src/plugins/brightness.rs | 32 +++++- src/plugins/todo.rs | 207 ++++++++++++++++++++++++------------- tests/brightness_plugin.rs | 13 ++- tests/todo_plugin.rs | 18 +++- 6 files changed, 208 insertions(+), 82 deletions(-) create mode 100644 src/common/command.rs diff --git a/src/common/command.rs b/src/common/command.rs new file mode 100644 index 00000000..57641777 --- /dev/null +++ b/src/common/command.rs @@ -0,0 +1,19 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParseArgsResult { + Parsed(T), + Usage(String), +} + +pub fn parse_args( + args: &[&str], + usage: &str, + parser: impl FnOnce(&[&str]) -> Option, +) -> ParseArgsResult { + if args.is_empty() { + return ParseArgsResult::Usage(usage.to_string()); + } + match parser(args) { + Some(parsed) => ParseArgsResult::Parsed(parsed), + None => ParseArgsResult::Usage(usage.to_string()), + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index f4293b8c..50a5c923 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -6,6 +6,7 @@ pub fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { } } +pub mod command; pub mod json_watch; pub mod slug; pub mod lru; diff --git a/src/plugins/brightness.rs b/src/plugins/brightness.rs index b288a20c..71a073e1 100644 --- a/src/plugins/brightness.rs +++ b/src/plugins/brightness.rs @@ -1,11 +1,27 @@ use crate::actions::Action; +use crate::common::command::{parse_args, ParseArgsResult}; use crate::plugin::Plugin; pub struct BrightnessPlugin; +const BRIGHT_USAGE: &str = "Usage: bright <0-100>"; + +fn usage_action(usage: &str) -> Action { + Action { + label: usage.into(), + desc: "Brightness".into(), + action: "query:bright ".into(), + args: None, + } +} + impl Plugin for BrightnessPlugin { fn search(&self, query: &str) -> Vec { let trimmed = query.trim(); + let lowered = trimmed.to_ascii_lowercase(); + if lowered.len() >= 2 && "bright".starts_with(&lowered) && lowered != "bright" { + return vec![usage_action(BRIGHT_USAGE)]; + } if let Some(rest) = crate::common::strip_prefix_ci(trimmed, "bright") { if rest.trim().is_empty() { return vec![Action { @@ -13,11 +29,18 @@ impl Plugin for BrightnessPlugin { desc: "Brightness".into(), action: "brightness:dialog".into(), args: None, - }]; + }, usage_action(BRIGHT_USAGE)]; } let rest = rest.trim(); - if let Ok(val) = rest.parse::() { - if val <= 100 { + let args: Vec<&str> = rest.split_whitespace().collect(); + match parse_args(&args, BRIGHT_USAGE, |args| { + if args.len() == 1 { + args[0].parse::().ok().filter(|val| *val <= 100) + } else { + None + } + }) { + ParseArgsResult::Parsed(val) => { return vec![Action { label: format!("Set brightness to {val}%"), desc: "Brightness".into(), @@ -25,6 +48,9 @@ impl Plugin for BrightnessPlugin { args: None, }]; } + ParseArgsResult::Usage(usage) => { + return vec![usage_action(&usage)]; + } } } Vec::new() diff --git a/src/plugins/todo.rs b/src/plugins/todo.rs index f4428ce7..e7144d0d 100644 --- a/src/plugins/todo.rs +++ b/src/plugins/todo.rs @@ -6,6 +6,7 @@ //! tests synchronized with the latest on-disk data. use crate::actions::Action; +use crate::common::command::{parse_args, ParseArgsResult}; use crate::common::json_watch::{watch_json, JsonWatcher}; use crate::common::lru::LruCache; use crate::common::query::parse_query_filters; @@ -23,6 +24,25 @@ pub const TODO_FILE: &str = "todo.json"; static TODO_VERSION: AtomicU64 = AtomicU64::new(0); +const TODO_USAGE: &str = + "Usage: todo ..."; +const TODO_ADD_USAGE: &str = "Usage: todo add [p=] [#tag ...]"; +const TODO_RM_USAGE: &str = "Usage: todo rm "; +const TODO_PSET_USAGE: &str = "Usage: todo pset "; +const TODO_TAG_USAGE: &str = "Usage: todo tag [#tag ...] | todo tag "; +const TODO_CLEAR_USAGE: &str = "Usage: todo clear"; +const TODO_VIEW_USAGE: &str = "Usage: todo view"; +const TODO_EXPORT_USAGE: &str = "Usage: todo export"; + +fn usage_action(usage: &str, query: &str) -> Action { + Action { + label: usage.into(), + desc: "Todo".into(), + action: format!("query:{query}"), + args: None, + } +} + #[derive(Serialize, Deserialize, Clone)] pub struct TodoEntry { pub text: String, @@ -197,13 +217,13 @@ impl TodoPlugin { fn search_internal(&self, trimmed: &str) -> Vec { if let Some(rest) = crate::common::strip_prefix_ci(trimmed, "todo") { - if rest.is_empty() { + if rest.trim().is_empty() { return vec![Action { label: "todo: edit todos".into(), desc: "Todo".into(), action: "todo:dialog".into(), args: None, - }]; + }, usage_action(TODO_USAGE, "todo ")]; } } @@ -239,50 +259,63 @@ impl TodoPlugin { .collect(); } - if trimmed.eq_ignore_ascii_case("todo view") { - return vec![Action { - label: "todo: view list".into(), - desc: "Todo".into(), - action: "todo:view".into(), - args: None, - }]; + if let Some(rest) = crate::common::strip_prefix_ci(trimmed, "todo view") { + if rest.trim().is_empty() { + return vec![Action { + label: "todo: view list".into(), + desc: "Todo".into(), + action: "todo:view".into(), + args: None, + }]; + } + return vec![usage_action(TODO_VIEW_USAGE, "todo view")]; } - if trimmed.eq_ignore_ascii_case("todo export") { - return vec![Action { - label: "Export todo list".into(), - desc: "Todo".into(), - action: "todo:export".into(), - args: None, - }]; + if let Some(rest) = crate::common::strip_prefix_ci(trimmed, "todo export") { + if rest.trim().is_empty() { + return vec![Action { + label: "Export todo list".into(), + desc: "Todo".into(), + action: "todo:export".into(), + args: None, + }]; + } + return vec![usage_action(TODO_EXPORT_USAGE, "todo export")]; } - if trimmed.eq_ignore_ascii_case("todo clear") { - return vec![Action { - label: "Clear completed todos".into(), - desc: "Todo".into(), - action: "todo:clear".into(), - args: None, - }]; + if let Some(rest) = crate::common::strip_prefix_ci(trimmed, "todo clear") { + if rest.trim().is_empty() { + return vec![Action { + label: "Clear completed todos".into(), + desc: "Todo".into(), + action: "todo:clear".into(), + args: None, + }]; + } + return vec![usage_action(TODO_CLEAR_USAGE, "todo clear")]; } if trimmed.eq_ignore_ascii_case("todo add") { - return vec![Action { - label: "todo: edit todos".into(), - desc: "Todo".into(), - action: "todo:dialog".into(), - args: None, - }]; + return vec![ + Action { + label: "todo: edit todos".into(), + desc: "Todo".into(), + action: "todo:dialog".into(), + args: None, + }, + usage_action(TODO_ADD_USAGE, "todo add "), + ]; } const ADD_PREFIX: &str = "todo add "; if let Some(rest) = crate::common::strip_prefix_ci(trimmed, ADD_PREFIX) { let rest = rest.trim(); - if !rest.is_empty() { + let args: Vec<&str> = rest.split_whitespace().collect(); + match parse_args(&args, TODO_ADD_USAGE, |args| { let mut priority: u8 = 0; let mut tags: Vec = Vec::new(); let mut words: Vec = Vec::new(); - for part in rest.split_whitespace() { + for part in args { if let Some(p) = part.strip_prefix("p=") { if let Ok(n) = p.parse::() { priority = n; @@ -293,11 +326,16 @@ impl TodoPlugin { tags.push(tag.to_string()); } } else { - words.push(part.to_string()); + words.push((*part).to_string()); } } let text = words.join(" "); - if !text.is_empty() { + if text.is_empty() { + return None; + } + Some((text, priority, tags)) + }) { + ParseArgsResult::Parsed((text, priority, tags)) => { let tag_str = tags.join(","); return vec![Action { label: format!("Add todo {text}"), @@ -306,17 +344,23 @@ impl TodoPlugin { args: None, }]; } + ParseArgsResult::Usage(usage) => { + return vec![usage_action(&usage, "todo add ")]; + } } } const PSET_PREFIX: &str = "todo pset "; if let Some(rest) = crate::common::strip_prefix_ci(trimmed, PSET_PREFIX) { let rest = rest.trim(); - let mut parts = rest.split_whitespace(); - if let (Some(idx_str), Some(priority_str)) = (parts.next(), parts.next()) { - if let (Ok(idx), Ok(priority)) = - (idx_str.parse::(), priority_str.parse::()) - { + let args: Vec<&str> = rest.split_whitespace().collect(); + match parse_args(&args, TODO_PSET_USAGE, |args| { + let (idx_str, priority_str) = (args.get(0)?, args.get(1)?); + let idx = idx_str.parse::().ok()?; + let priority = priority_str.parse::().ok()?; + Some((idx, priority)) + }) { + ParseArgsResult::Parsed((idx, priority)) => { return vec![Action { label: format!("Set priority {priority} for todo {idx}"), desc: "Todo".into(), @@ -324,60 +368,73 @@ impl TodoPlugin { args: None, }]; } + ParseArgsResult::Usage(usage) => { + return vec![usage_action(&usage, "todo pset ")]; + } } } const TAG_PREFIX: &str = "todo tag "; if let Some(rest) = crate::common::strip_prefix_ci(trimmed, TAG_PREFIX) { let rest = rest.trim(); - let mut parts = rest.split_whitespace(); - if let Some(first) = parts.next() { - if let Ok(idx) = first.parse::() { + let args: Vec<&str> = rest.split_whitespace().collect(); + if let ParseArgsResult::Parsed((idx, tags)) = + parse_args(&args, TODO_TAG_USAGE, |args| { + let first = args.get(0)?; + let idx = first.parse::().ok()?; let mut tags: Vec = Vec::new(); - for t in parts { + for t in args.iter().skip(1) { if let Some(tag) = t.strip_prefix('#') { if !tag.is_empty() { tags.push(tag.to_string()); } } } - let tag_str = tags.join(","); - return vec![Action { - label: format!("Set tags for todo {idx}"), - desc: "Todo".into(), - action: format!("todo:tag:{idx}|{tag_str}"), - args: None, - }]; - } else { - let filter = rest; - let guard = match self.data.read() { - Ok(g) => g, - Err(_) => return Vec::new(), - }; - let mut entries: Vec<(usize, &TodoEntry)> = guard.iter().enumerate().collect(); - entries - .retain(|(_, t)| t.tags.iter().any(|tg| tg.eq_ignore_ascii_case(filter))); - entries.sort_by(|a, b| b.1.priority.cmp(&a.1.priority)); - return entries - .into_iter() - .map(|(idx, t)| Action { - label: format!( - "{} {}", - if t.done { "[x]" } else { "[ ]" }, - t.text.clone() - ), - desc: "Todo".into(), - action: format!("query:todo tag {idx} "), - args: None, - }) - .collect(); - } + Some((idx, tags)) + }) + { + let tag_str = tags.join(","); + return vec![Action { + label: format!("Set tags for todo {idx}"), + desc: "Todo".into(), + action: format!("todo:tag:{idx}|{tag_str}"), + args: None, + }]; + } + + if rest.is_empty() { + return vec![usage_action(TODO_TAG_USAGE, "todo tag ")]; } + + let filter = rest; + let guard = match self.data.read() { + Ok(g) => g, + Err(_) => return Vec::new(), + }; + let mut entries: Vec<(usize, &TodoEntry)> = guard.iter().enumerate().collect(); + entries.retain(|(_, t)| t.tags.iter().any(|tg| tg.eq_ignore_ascii_case(filter))); + entries.sort_by(|a, b| b.1.priority.cmp(&a.1.priority)); + return entries + .into_iter() + .map(|(idx, t)| Action { + label: format!( + "{} {}", + if t.done { "[x]" } else { "[ ]" }, + t.text.clone() + ), + desc: "Todo".into(), + action: format!("query:todo tag {idx} "), + args: None, + }) + .collect(); } const RM_PREFIX: &str = "todo rm "; if let Some(rest) = crate::common::strip_prefix_ci(trimmed, RM_PREFIX) { let filter = rest.trim(); + if filter.is_empty() { + return vec![usage_action(TODO_RM_USAGE, "todo rm ")]; + } let guard = match self.data.read() { Ok(g) => g, Err(_) => return Vec::new(), @@ -455,6 +512,10 @@ impl TodoPlugin { .collect(); } + if crate::common::strip_prefix_ci(trimmed, "todo").is_some() { + return vec![usage_action(TODO_USAGE, "todo ")]; + } + Vec::new() } } diff --git a/tests/brightness_plugin.rs b/tests/brightness_plugin.rs index eba49cd8..048ac030 100644 --- a/tests/brightness_plugin.rs +++ b/tests/brightness_plugin.rs @@ -15,8 +15,9 @@ fn search_set_numeric() { fn search_plain_bright() { let plugin = BrightnessPlugin; let results = plugin.search("bright"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].action, "brightness:dialog"); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|action| action.action == "brightness:dialog")); + assert!(results.iter().any(|action| action.label.starts_with("Usage: bright"))); } #[test] @@ -26,3 +27,11 @@ fn search_bright_no_hardware_calls() { let _ = plugin.search("bright"); assert_eq!(BRIGHTNESS_QUERIES.load(Ordering::SeqCst), 0); } + +#[test] +fn fuzzish_partial_inputs_are_helpful() { + let plugin = BrightnessPlugin; + let results = plugin.search("br"); + assert_eq!(results.len(), 1); + assert!(results[0].label.starts_with("Usage: bright")); +} diff --git a/tests/todo_plugin.rs b/tests/todo_plugin.rs index 5756840e..5ca80a74 100644 --- a/tests/todo_plugin.rs +++ b/tests/todo_plugin.rs @@ -64,8 +64,9 @@ fn search_add_without_text_opens_dialog() { let _lock = TEST_MUTEX.lock().unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo add"); - assert_eq!(results.len(), 1); - assert_eq!(results[0].action, "todo:dialog"); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|action| action.action == "todo:dialog")); + assert!(results.iter().any(|action| action.label.starts_with("Usage: todo add"))); } #[test] @@ -114,9 +115,18 @@ fn search_plain_todo_opens_dialog() { let _lock = TEST_MUTEX.lock().unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo"); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|action| action.action == "todo:dialog")); + assert!(results.iter().any(|action| action.label.starts_with("Usage: todo"))); +} + +#[test] +fn fuzzish_partial_todo_queries() { + let _lock = TEST_MUTEX.lock().unwrap(); + let plugin = TodoPlugin::default(); + let results = plugin.search("todo !"); assert_eq!(results.len(), 1); - assert_eq!(results[0].action, "todo:dialog"); - assert_eq!(results[0].label, "todo: edit todos"); + assert!(results[0].label.starts_with("Usage: todo")); } #[test]