diff --git a/src/launcher.rs b/src/launcher.rs index 1c2f5f0e..e95005b4 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -94,6 +94,56 @@ mod tests { ActionKind::PowerPlanSet { guid: "balanced" } ); } + + #[test] + fn parse_todo_add_payload_with_delimiters_and_whitespace() { + let payload = crate::plugins::todo::TodoAddActionPayload { + text: "ship | release, notes now".into(), + priority: 9, + tags: vec!["team|alpha,beta".into(), "has space".into()], + }; + let encoded = crate::plugins::todo::encode_todo_add_action_payload(&payload) + .expect("encode todo add payload"); + let action = Action { + label: String::new(), + desc: String::new(), + action: format!("todo:add:{encoded}"), + args: None, + }; + + assert_eq!( + parse_action_kind(&action), + ActionKind::TodoAdd { + text: "ship | release, notes now".into(), + priority: 9, + tags: vec!["team|alpha,beta".into(), "has space".into()], + } + ); + } + + #[test] + fn parse_todo_tag_payload_with_delimiters_and_whitespace() { + let payload = crate::plugins::todo::TodoTagActionPayload { + idx: 12, + tags: vec!["owner|dev,ops".into(), "needs review".into()], + }; + let encoded = crate::plugins::todo::encode_todo_tag_action_payload(&payload) + .expect("encode todo tag payload"); + let action = Action { + label: String::new(), + desc: String::new(), + action: format!("todo:tag:{encoded}"), + args: None, + }; + + assert_eq!( + parse_action_kind(&action), + ActionKind::TodoSetTags { + idx: 12, + tags: vec!["owner|dev,ops".into(), "needs review".into()], + } + ); + } } pub(crate) fn mute_active_window() { @@ -383,7 +433,7 @@ enum ActionKind<'a> { }, StopwatchShow(u64), TodoAdd { - text: &'a str, + text: String, priority: u8, tags: Vec, }, @@ -629,24 +679,13 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> { } } if let Some(rest) = s.strip_prefix("todo:add:") { - let mut parts = rest.splitn(3, '|'); - let text = parts.next().unwrap_or(""); - let priority = parts.next().and_then(|p| p.parse::().ok()).unwrap_or(0); - let tags: Vec = parts - .next() - .map(|t| { - if t.is_empty() { - Vec::new() - } else { - t.split(',').map(|s| s.to_string()).collect() - } - }) - .unwrap_or_default(); - return ActionKind::TodoAdd { - text, - priority, - tags, - }; + if let Some(payload) = crate::plugins::todo::decode_todo_add_action_payload(rest) { + return ActionKind::TodoAdd { + text: payload.text, + priority: payload.priority, + tags: payload.tags, + }; + } } if let Some(rest) = s.strip_prefix("todo:pset:") { if let Some((idx, p)) = rest.split_once('|') { @@ -659,15 +698,11 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> { } } if let Some(rest) = s.strip_prefix("todo:tag:") { - if let Some((idx, tags_str)) = rest.split_once('|') { - if let Ok(i) = idx.parse::() { - let tags: Vec = if tags_str.is_empty() { - Vec::new() - } else { - tags_str.split(',').map(|s| s.to_string()).collect() - }; - return ActionKind::TodoSetTags { idx: i, tags }; - } + if let Some(payload) = crate::plugins::todo::decode_todo_tag_action_payload(rest) { + return ActionKind::TodoSetTags { + idx: payload.idx, + tags: payload.tags, + }; } } if let Some(idx) = s.strip_prefix("todo:remove:") { @@ -970,7 +1005,7 @@ pub fn launch_action(action: &Action) -> anyhow::Result<()> { text, priority, tags, - } => todo::add(text, priority, &tags), + } => todo::add(&text, priority, &tags), ActionKind::TodoSetPriority { idx, priority } => todo::set_priority(idx, priority), ActionKind::TodoSetTags { idx, tags } => todo::set_tags(idx, &tags), ActionKind::TodoRemove(i) => todo::remove(i), diff --git a/src/plugins/todo.rs b/src/plugins/todo.rs index b663e281..fdc362bd 100644 --- a/src/plugins/todo.rs +++ b/src/plugins/todo.rs @@ -11,6 +11,8 @@ use crate::common::json_watch::{watch_json, JsonWatcher}; use crate::common::lru::LruCache; use crate::common::query::parse_query_filters; use crate::plugin::Plugin; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use once_cell::sync::Lazy; @@ -52,6 +54,45 @@ pub struct TodoEntry { pub tags: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TodoAddActionPayload { + pub text: String, + pub priority: u8, + pub tags: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct TodoTagActionPayload { + pub idx: usize, + pub tags: Vec, +} + +fn encode_action_payload(payload: &T) -> Option { + let json = serde_json::to_vec(payload).ok()?; + Some(URL_SAFE_NO_PAD.encode(json)) +} + +fn decode_action_payload Deserialize<'de>>(payload: &str) -> Option { + let json = URL_SAFE_NO_PAD.decode(payload).ok()?; + serde_json::from_slice(&json).ok() +} + +pub(crate) fn encode_todo_add_action_payload(payload: &TodoAddActionPayload) -> Option { + encode_action_payload(payload) +} + +pub(crate) fn decode_todo_add_action_payload(payload: &str) -> Option { + decode_action_payload(payload) +} + +pub(crate) fn encode_todo_tag_action_payload(payload: &TodoTagActionPayload) -> Option { + encode_action_payload(payload) +} + +pub(crate) fn decode_todo_tag_action_payload(payload: &str) -> Option { + decode_action_payload(payload) +} + /// Shared in-memory todo cache kept in sync with `todo.json`. /// Disk writes and the [`JsonWatcher`] ensure updates are visible immediately /// to all plugin instances and tests. @@ -407,7 +448,6 @@ impl TodoPlugin { Some((text, priority, tags)) }) { ParseArgsResult::Parsed((text, priority, tags)) => { - let tag_str = tags.join(","); let mut label_suffix_parts: Vec = Vec::new(); if !tags.is_empty() { label_suffix_parts.push(format!("Tag: {}", tags.join(", "))); @@ -420,10 +460,18 @@ impl TodoPlugin { } else { format!("Add todo {text} {}", label_suffix_parts.join("; ")) }; + let payload = TodoAddActionPayload { + text, + priority, + tags, + }; + let Some(encoded_payload) = encode_todo_add_action_payload(&payload) else { + return Vec::new(); + }; return vec![Action { label, desc: "Todo".into(), - action: format!("todo:add:{text}|{priority}|{tag_str}"), + action: format!("todo:add:{encoded_payload}"), args: None, }]; } @@ -473,11 +521,14 @@ impl TodoPlugin { } } } - let tag_str = tags.join(","); + let payload = TodoTagActionPayload { idx, tags }; + let Some(encoded_payload) = encode_todo_tag_action_payload(&payload) else { + return Vec::new(); + }; return vec![Action { label: format!("Set tags for todo {idx}"), desc: "Todo".into(), - action: format!("todo:tag:{idx}|{tag_str}"), + action: format!("todo:tag:{encoded_payload}"), args: None, }]; } @@ -930,4 +981,46 @@ mod tests { ); assert_eq!(actions_list[0], "todo:dialog"); } + + #[test] + fn todo_add_and_tag_actions_encode_payload_for_round_trip() { + let plugin = TodoPlugin { + matcher: SkimMatcherV2::default(), + data: TODO_DATA.clone(), + cache: TODO_CACHE.clone(), + watcher: None, + }; + + let add_actions = + plugin.search_internal("todo add finish|draft, now p=7 #core|team,ops #has space"); + assert_eq!(add_actions.len(), 1); + let add_encoded = add_actions[0] + .action + .strip_prefix("todo:add:") + .expect("todo:add: prefix"); + let add_payload = decode_todo_add_action_payload(add_encoded).expect("decode add payload"); + assert_eq!( + add_payload, + TodoAddActionPayload { + text: "finish|draft, now space".into(), + priority: 7, + tags: vec!["core|team,ops".into(), "has".into()], + } + ); + + let tag_actions = plugin.search_internal("todo tag 4 #alpha|beta,gamma #needs space"); + assert_eq!(tag_actions.len(), 1); + let tag_encoded = tag_actions[0] + .action + .strip_prefix("todo:tag:") + .expect("todo:tag: prefix"); + let tag_payload = decode_todo_tag_action_payload(tag_encoded).expect("decode tag payload"); + assert_eq!( + tag_payload, + TodoTagActionPayload { + idx: 4, + tags: vec!["alpha|beta,gamma".into(), "needs".into()], + } + ); + } } diff --git a/tests/todo_plugin.rs b/tests/todo_plugin.rs index 5d03cd70..83f39067 100644 --- a/tests/todo_plugin.rs +++ b/tests/todo_plugin.rs @@ -1,3 +1,5 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; use eframe::egui; use multi_launcher::gui::{ todo_view_layout_sizes, todo_view_window_constraints, LauncherApp, TodoDialog, TodoViewDialog, @@ -5,8 +7,8 @@ use multi_launcher::gui::{ use multi_launcher::plugin::Plugin; use multi_launcher::plugin::PluginManager; use multi_launcher::plugins::todo::{ - append_todo, load_todos, mark_done, remove_todo, set_priority, set_tags, TodoEntry, TodoPlugin, - TODO_FILE, + append_todo, load_todos, mark_done, remove_todo, set_priority, set_tags, TodoAddActionPayload, + TodoEntry, TodoPlugin, TodoTagActionPayload, TODO_FILE, }; use multi_launcher::settings::Settings; use once_cell::sync::Lazy; @@ -15,6 +17,11 @@ use tempfile::tempdir; static TEST_MUTEX: Lazy> = Lazy::new(|| Mutex::new(())); +fn decode_payload(encoded: &str) -> T { + let json = URL_SAFE_NO_PAD.decode(encoded).unwrap(); + serde_json::from_slice(&json).unwrap() +} + fn new_app(ctx: &egui::Context) -> LauncherApp { LauncherApp::new( ctx, @@ -40,7 +47,16 @@ fn search_add_returns_action() { let plugin = TodoPlugin::default(); let results = plugin.search("todo add task "); assert_eq!(results.len(), 1); - assert_eq!(results[0].action, "todo:add:task|0|"); + let encoded = results[0].action.strip_prefix("todo:add:").unwrap(); + let payload: TodoAddActionPayload = decode_payload(encoded); + assert_eq!( + payload, + TodoAddActionPayload { + text: "task".into(), + priority: 0, + tags: vec![], + } + ); assert_eq!(results[0].label, "Add todo task"); } @@ -50,7 +66,16 @@ fn search_add_with_priority_and_tags() { let plugin = TodoPlugin::default(); let results = plugin.search("todo add task p=3 #a #b"); assert_eq!(results.len(), 1); - assert_eq!(results[0].action, "todo:add:task|3|a,b"); + let encoded = results[0].action.strip_prefix("todo:add:").unwrap(); + let payload: TodoAddActionPayload = decode_payload(encoded); + assert_eq!( + payload, + TodoAddActionPayload { + text: "task".into(), + priority: 3, + tags: vec!["a".into(), "b".into()], + } + ); assert_eq!(results[0].label, "Add todo task Tag: a, b; priority: 3"); } @@ -60,7 +85,16 @@ fn search_add_with_at_tags() { let plugin = TodoPlugin::default(); let results = plugin.search("todo add task @a @b"); assert_eq!(results.len(), 1); - assert_eq!(results[0].action, "todo:add:task|0|a,b"); + let encoded = results[0].action.strip_prefix("todo:add:").unwrap(); + let payload: TodoAddActionPayload = decode_payload(encoded); + assert_eq!( + payload, + TodoAddActionPayload { + text: "task".into(), + priority: 0, + tags: vec!["a".into(), "b".into()], + } + ); assert_eq!(results[0].label, "Add todo task Tag: a, b"); } @@ -235,7 +269,15 @@ fn search_pset_and_tag_actions() { assert_eq!(res[0].action, "todo:pset:1|4"); let res = plugin.search("todo tag 2 #x #y"); assert_eq!(res.len(), 1); - assert_eq!(res[0].action, "todo:tag:2|x,y"); + let encoded = res[0].action.strip_prefix("todo:tag:").unwrap(); + let payload: TodoTagActionPayload = decode_payload(encoded); + assert_eq!( + payload, + TodoTagActionPayload { + idx: 2, + tags: vec!["x".into(), "y".into()], + } + ); } #[test] @@ -325,16 +367,19 @@ fn tag_command_without_filter_lists_all_tags() { append_todo(TODO_FILE, "alpha task", 1, &["alpha".into(), "beta".into()]).unwrap(); append_todo(TODO_FILE, "beta task", 1, &["beta".into()]).unwrap(); - append_todo(TODO_FILE, "gamma task", 1, &["gamma".into(), "alpha".into()]).unwrap(); + append_todo( + TODO_FILE, + "gamma task", + 1, + &["gamma".into(), "alpha".into()], + ) + .unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo tag"); let labels: Vec<&str> = results.iter().map(|action| action.label.as_str()).collect(); - assert_eq!( - labels, - vec!["#alpha (2)", "#beta (2)", "#gamma (1)"] - ); + assert_eq!(labels, vec!["#alpha (2)", "#beta (2)", "#gamma (1)"]); } #[test] fn search_view_opens_dialog() {