1#![allow(clippy::result_large_err)]
22
23use std::path::PathBuf;
24
25use thiserror::Error;
26
27use crate::config::Command as KryptCommand;
28use crate::include::{IncludeError, load_with_includes};
29use crate::paths::Platform;
30use crate::predicate::{DefaultPredicateEnv, default_predicate_evaluator};
31use crate::runner::{
32 Context, Notifier, ProcessExec, Prompter, RunReport, RunnerError, execute_command,
33};
34
35#[derive(Debug)]
39pub struct DispatchOpts {
40 pub config_path: PathBuf,
42 pub args: Vec<String>,
44 pub dry_run: bool,
46}
47
48#[derive(Debug)]
50pub struct DispatchListEntry {
51 pub name: String,
53 pub description: String,
55 pub platform_filtered: bool,
58 pub platform: Option<String>,
60}
61
62#[derive(Debug)]
64pub struct DispatchReport {
65 pub steps_run: usize,
67 pub steps_skipped: usize,
69 pub steps_failed_ignored: usize,
72 pub dry_run_plan: Option<String>,
74}
75
76#[derive(Debug, Error)]
80pub enum DispatchError {
81 #[error("loading config: {0}")]
83 ConfigLoad(#[from] Box<IncludeError>),
84
85 #[error(
87 "unknown group {name:?} — no [[command]] entries with group = {name:?}\n\navailable groups:\n{}",
88 format_groups(available)
89 )]
90 GroupNotFound {
91 name: String,
93 available: Vec<String>,
95 },
96
97 #[error(
99 "command {name:?} not found in group {group:?}; available: {}",
100 available_in_group.join(", ")
101 )]
102 CommandNotFound {
103 group: String,
105 name: String,
107 available_in_group: Vec<String>,
109 },
110
111 #[error(
113 "command {name:?} in group {group:?} is restricted to {required}; current platform is {current}"
114 )]
115 PlatformMismatch {
116 group: String,
118 name: String,
120 required: String,
122 current: String,
124 },
125
126 #[error("runner error: {0}")]
128 Runner(#[from] Box<RunnerError>),
129}
130
131fn format_groups(groups: &[String]) -> String {
132 if groups.is_empty() {
133 return " (none)".to_owned();
134 }
135 groups
136 .iter()
137 .map(|g| format!(" {g}"))
138 .collect::<Vec<_>>()
139 .join("\n")
140}
141
142impl From<IncludeError> for DispatchError {
143 fn from(e: IncludeError) -> Self {
144 DispatchError::ConfigLoad(Box::new(e))
145 }
146}
147
148impl From<RunnerError> for DispatchError {
149 fn from(e: RunnerError) -> Self {
150 DispatchError::Runner(Box::new(e))
151 }
152}
153
154pub fn list_groups(opts: &DispatchOpts) -> Result<Vec<String>, DispatchError> {
158 let cfg = load_with_includes(&opts.config_path)?;
159 let groups: Vec<String> = cfg
160 .commands
161 .iter()
162 .map(|c| c.group.clone())
163 .collect::<std::collections::BTreeSet<_>>()
164 .into_iter()
165 .collect();
166 Ok(groups)
167}
168
169pub fn list_in_group(
180 group: &str,
181 opts: &DispatchOpts,
182 show_all: bool,
183) -> Result<Vec<DispatchListEntry>, DispatchError> {
184 let cfg = load_with_includes(&opts.config_path)?;
185 let current = Platform::current();
186
187 let in_group: Vec<KryptCommand> = cfg
188 .commands
189 .into_iter()
190 .filter(|cmd| cmd.group == group)
191 .collect();
192
193 if in_group.is_empty() {
194 let cfg2 = load_with_includes(&opts.config_path)?;
195 let available: Vec<String> = cfg2
196 .commands
197 .iter()
198 .map(|c| c.group.clone())
199 .collect::<std::collections::BTreeSet<_>>()
200 .into_iter()
201 .collect();
202 return Err(DispatchError::GroupNotFound {
203 name: group.to_owned(),
204 available,
205 });
206 }
207
208 let mut entries: Vec<DispatchListEntry> = in_group
209 .into_iter()
210 .filter_map(|cmd| {
211 let filtered = cmd
212 .platform
213 .as_deref()
214 .map(|p| p != current.as_str())
215 .unwrap_or(false);
216
217 if filtered && !show_all {
218 return None;
219 }
220
221 Some(DispatchListEntry {
222 name: cmd.name,
223 description: cmd.description,
224 platform_filtered: filtered,
225 platform: cmd.platform,
226 })
227 })
228 .collect();
229
230 entries.sort_by(|a, b| a.name.cmp(&b.name));
231 Ok(entries)
232}
233
234pub fn run_in_group(
238 group: &str,
239 name: &str,
240 opts: &DispatchOpts,
241) -> Result<DispatchReport, DispatchError> {
242 use crate::notify::{AutoNotifier, NotifyBackend};
243 use crate::runner::RealPrompter;
244
245 let notifier = AutoNotifier::with_backend(NotifyBackend::Stderr);
246 let mut prompter = RealPrompter;
247 run_in_group_with(
248 group,
249 name,
250 opts,
251 &crate::runner::RealProcessExec,
252 ¬ifier,
253 &mut prompter,
254 )
255}
256
257pub fn run_in_group_with(
261 group: &str,
262 name: &str,
263 opts: &DispatchOpts,
264 process: &dyn ProcessExec,
265 notifier: &dyn Notifier,
266 prompter: &mut dyn Prompter,
267) -> Result<DispatchReport, DispatchError> {
268 let cfg = load_with_includes(&opts.config_path)?;
269
270 let all_in_group: Vec<String> = cfg
271 .commands
272 .iter()
273 .filter(|c| c.group == group)
274 .map(|c| c.name.clone())
275 .collect();
276
277 if all_in_group.is_empty() {
278 let available: Vec<String> = cfg
279 .commands
280 .iter()
281 .map(|c| c.group.clone())
282 .collect::<std::collections::BTreeSet<_>>()
283 .into_iter()
284 .collect();
285 return Err(DispatchError::GroupNotFound {
286 name: group.to_owned(),
287 available,
288 });
289 }
290
291 let cmd: &KryptCommand = cfg
292 .commands
293 .iter()
294 .find(|c| c.group == group && c.name == name)
295 .ok_or_else(|| DispatchError::CommandNotFound {
296 group: group.to_owned(),
297 name: name.to_owned(),
298 available_in_group: all_in_group.clone(),
299 })?;
300
301 let current = Platform::current();
303 if let Some(ref required) = cmd.platform
304 && required.as_str() != current.as_str()
305 {
306 return Err(DispatchError::PlatformMismatch {
307 group: group.to_owned(),
308 name: name.to_owned(),
309 required: required.clone(),
310 current: current.to_string(),
311 });
312 }
313
314 if opts.dry_run {
315 return dry_run_plan(group, cmd, opts);
316 }
317
318 let env = DefaultPredicateEnv::new();
319 let eval = default_predicate_evaluator(env);
320
321 let report: RunReport =
322 execute_command(cmd, opts.args.clone(), process, notifier, prompter, &eval)?;
323
324 Ok(DispatchReport {
325 steps_run: report.steps_run,
326 steps_skipped: report.steps_skipped_by_predicate,
327 steps_failed_ignored: report.steps_failed_ignored,
328 dry_run_plan: None,
329 })
330}
331
332fn dry_run_plan(
335 group: &str,
336 cmd: &KryptCommand,
337 opts: &DispatchOpts,
338) -> Result<DispatchReport, DispatchError> {
339 use std::collections::BTreeMap;
340
341 use crate::predicate::{DefaultPredicateEnv, eval};
342 use crate::runner::interpolate;
343
344 let env = DefaultPredicateEnv::new();
345
346 let ctx = Context {
347 captures: BTreeMap::new(),
348 args: opts.args.clone(),
349 stdin: None,
350 };
351
352 let n = cmd.steps.len();
353 let mut plan = format!("dry-run: {group} {:?} ({n} steps)\n", cmd.name);
354
355 for (i, step) in cmd.steps.iter().enumerate() {
356 let num = i + 1;
357
358 let skipped = if let Some(ref pred) = step.r#if {
359 match eval(pred, &env) {
360 Ok(true) => false,
361 Ok(false) => true,
362 Err(e) => {
363 plan.push_str(&format!("\n [{num}] (skipped — predicate error: {e})\n"));
364 continue;
365 }
366 }
367 } else {
368 false
369 };
370
371 if skipped {
372 plan.push_str(&format!(
373 "\n [{num}] (skipped — predicate {:?} failed)\n",
374 step.r#if.as_deref().unwrap_or("")
375 ));
376 continue;
377 }
378
379 if let Some(ref args) = step.run {
380 let interp: Vec<String> = args.iter().map(|a| interpolate(a, &ctx)).collect();
381 plan.push_str(&format!("\n [{num}] run: {}\n", interp.join(" ")));
382 } else if let Some(ref args) = step.pipe {
383 let interp: Vec<String> = args.iter().map(|a| interpolate(a, &ctx)).collect();
384 let input_display = step.input.as_deref().unwrap_or("{stdin}");
385 plan.push_str(&format!(
386 "\n [{num}] pipe: {} input: {}\n",
387 interp.join(" "),
388 input_display
389 ));
390 } else if let Some(ref parts) = step.notify {
391 let title = parts.first().map(String::as_str).unwrap_or("");
392 let body = parts.get(1).map(String::as_str).unwrap_or("");
393 plan.push_str(&format!("\n [{num}] notify: {:?} -> {:?}\n", title, body));
394 } else {
395 plan.push_str(&format!("\n [{num}] (unknown step kind)\n"));
396 }
397
398 if let Some(ref var) = step.capture {
399 plan.push_str(&format!(" capture -> {var}\n"));
400 }
401 }
402
403 Ok(DispatchReport {
404 steps_run: 0,
405 steps_skipped: 0,
406 steps_failed_ignored: 0,
407 dry_run_plan: Some(plan),
408 })
409}
410
411#[cfg(test)]
414mod tests {
415 use std::io;
416
417 use tempfile::TempDir;
418
419 use super::*;
420 use crate::runner::{MockNotifier, MockProcessExec, MockPrompter, ProcessResult};
421
422 fn ok_result(stdout: &str) -> Result<ProcessResult, io::Error> {
423 Ok(ProcessResult {
424 status: 0,
425 stdout: stdout.to_owned(),
426 stderr: String::new(),
427 })
428 }
429
430 fn write_config(contents: &str) -> (TempDir, PathBuf) {
431 let dir = TempDir::new().unwrap();
432 let path = dir.path().join(".krypt.toml");
433 std::fs::write(&path, contents).unwrap();
434 (dir, path)
435 }
436
437 fn opts(config_path: PathBuf) -> DispatchOpts {
438 DispatchOpts {
439 config_path,
440 args: Vec::new(),
441 dry_run: false,
442 }
443 }
444
445 fn opts_with_args(config_path: PathBuf, args: Vec<String>) -> DispatchOpts {
446 DispatchOpts {
447 config_path,
448 args,
449 dry_run: false,
450 }
451 }
452
453 fn opts_dry(config_path: PathBuf) -> DispatchOpts {
454 DispatchOpts {
455 config_path,
456 args: Vec::new(),
457 dry_run: true,
458 }
459 }
460
461 #[test]
464 fn list_in_group_filters_by_platform() {
465 let current = Platform::current();
466 let other = match current {
467 Platform::Linux => "macos",
468 Platform::Macos => "linux",
469 Platform::Windows => "linux",
470 };
471
472 let toml = format!(
473 concat!(
474 "[[command]]\ngroup = \"menu\"\nname = \"native\"\ndescription = \"native\"\n",
475 "steps = [{{ run = [\"echo\", \"hi\"] }}]\n\n",
476 "[[command]]\ngroup = \"menu\"\nname = \"foreign\"\ndescription = \"other\"\n",
477 "platform = \"{other}\"\n",
478 "steps = [{{ run = [\"echo\", \"hi\"] }}]\n",
479 ),
480 other = other
481 );
482
483 let (_dir, path) = write_config(&toml);
484 let o = opts(path);
485
486 let listed = list_in_group("menu", &o, false).unwrap();
487 assert_eq!(listed.len(), 1, "only native should be listed");
488 assert_eq!(listed[0].name, "native");
489
490 let all = list_in_group("menu", &o, true).unwrap();
491 assert_eq!(all.len(), 2, "show_all should return both");
492 let foreign = all.iter().find(|e| e.name == "foreign").unwrap();
493 assert!(foreign.platform_filtered, "foreign should be flagged");
494 }
495
496 #[test]
499 fn run_in_group_not_found_returns_command_not_found_error() {
500 let toml = concat!(
501 "[[command]]\ngroup = \"menu\"\nname = \"wifi\"\ndescription = \"WiFi\"\n",
502 "steps = [{ run = [\"echo\", \"hi\"] }]\n",
503 );
504 let (_dir, path) = write_config(toml);
505 let o = opts(path);
506
507 let process = MockProcessExec::new([]);
508 let notifier = MockNotifier::default();
509 let mut prompter = MockPrompter::default();
510
511 let err = run_in_group_with(
512 "menu",
513 "nonexistent",
514 &o,
515 &process,
516 ¬ifier,
517 &mut prompter,
518 )
519 .unwrap_err();
520 match err {
521 DispatchError::CommandNotFound {
522 group,
523 name,
524 available_in_group,
525 } => {
526 assert_eq!(group, "menu");
527 assert_eq!(name, "nonexistent");
528 assert!(available_in_group.contains(&"wifi".to_owned()));
529 }
530 other => panic!("expected CommandNotFound, got {other:?}"),
531 }
532 }
533
534 #[test]
537 fn run_in_group_platform_mismatch_on_wrong_platform() {
538 let current = Platform::current();
539 let other = match current {
540 Platform::Linux => "macos",
541 Platform::Macos => "linux",
542 Platform::Windows => "linux",
543 };
544
545 let toml = format!(
546 concat!(
547 "[[command]]\ngroup = \"menu\"\nname = \"mac-only\"\n",
548 "platform = \"{other}\"\n",
549 "steps = [{{ run = [\"echo\"] }}]\n",
550 ),
551 other = other
552 );
553 let (_dir, path) = write_config(&toml);
554 let o = opts(path);
555
556 let process = MockProcessExec::new([]);
557 let notifier = MockNotifier::default();
558 let mut prompter = MockPrompter::default();
559
560 let err = run_in_group_with("menu", "mac-only", &o, &process, ¬ifier, &mut prompter)
561 .unwrap_err();
562 assert!(
563 matches!(err, DispatchError::PlatformMismatch { .. }),
564 "expected PlatformMismatch"
565 );
566 }
567
568 #[test]
571 fn run_in_group_executes_steps_and_forwards_args() {
572 let toml = concat!(
573 "[[command]]\ngroup = \"menu\"\nname = \"pass\"\n",
574 "steps = [\n",
575 " { run = [\"echo\", \"{0}\"] },\n",
576 " { run = [\"echo\", \"step2\"] },\n",
577 "]\n",
578 );
579 let (_dir, path) = write_config(toml);
580
581 let mut o = opts_with_args(path, vec!["argzero".to_owned()]);
582 o.dry_run = false;
583
584 let process = MockProcessExec::new([ok_result("argzero\n"), ok_result("step2\n")]);
585 let notifier = MockNotifier::default();
586 let mut prompter = MockPrompter::default();
587
588 let report =
589 run_in_group_with("menu", "pass", &o, &process, ¬ifier, &mut prompter).unwrap();
590 assert_eq!(report.steps_run, 2);
591
592 let calls = process.calls.borrow();
593 assert_eq!(calls[0].1, vec!["argzero".to_owned()]);
594 }
595
596 #[test]
599 fn dry_run_produces_plan_without_spawning() {
600 let toml = concat!(
601 "[[command]]\ngroup = \"menu\"\nname = \"demo\"\n",
602 "steps = [\n",
603 " { run = [\"echo\", \"hello\"] },\n",
604 " { notify = [\"Title\", \"Body\"] },\n",
605 "]\n",
606 );
607 let (_dir, path) = write_config(toml);
608 let o = opts_dry(path);
609
610 let process = MockProcessExec::new([]);
611 let notifier = MockNotifier::default();
612 let mut prompter = MockPrompter::default();
613
614 let report =
615 run_in_group_with("menu", "demo", &o, &process, ¬ifier, &mut prompter).unwrap();
616 assert!(
617 report.dry_run_plan.is_some(),
618 "dry-run should produce a plan"
619 );
620 let plan = report.dry_run_plan.unwrap();
621 assert!(plan.contains("echo"), "plan should mention the command");
622 assert!(plan.contains("notify"), "plan should mention notify step");
623 assert!(
624 process.calls.borrow().is_empty(),
625 "dry-run must not invoke ProcessExec"
626 );
627 }
628
629 #[test]
632 fn list_groups_returns_sorted_distinct_groups() {
633 let toml = concat!(
634 "[[command]]\ngroup = \"battery\"\nname = \"report\"\n",
635 "steps = [{ run = [\"echo\"] }]\n\n",
636 "[[command]]\ngroup = \"menu\"\nname = \"wifi\"\n",
637 "steps = [{ run = [\"echo\"] }]\n\n",
638 "[[command]]\ngroup = \"menu\"\nname = \"bluetooth\"\n",
639 "steps = [{ run = [\"echo\"] }]\n\n",
640 "[[command]]\ngroup = \"kanata\"\nname = \"toggle\"\n",
641 "steps = [{ run = [\"echo\"] }]\n",
642 );
643 let (_dir, path) = write_config(toml);
644 let o = opts(path);
645
646 let groups = list_groups(&o).unwrap();
647 assert_eq!(groups, vec!["battery", "kanata", "menu"]);
648 }
649
650 #[test]
653 fn run_in_group_group_not_found() {
654 let toml = concat!(
655 "[[command]]\ngroup = \"menu\"\nname = \"wifi\"\n",
656 "steps = [{ run = [\"echo\"] }]\n",
657 );
658 let (_dir, path) = write_config(toml);
659 let o = opts(path);
660
661 let process = MockProcessExec::new([]);
662 let notifier = MockNotifier::default();
663 let mut prompter = MockPrompter::default();
664
665 let err = run_in_group_with("nonexistent", "foo", &o, &process, ¬ifier, &mut prompter)
666 .unwrap_err();
667 match err {
668 DispatchError::GroupNotFound { name, available } => {
669 assert_eq!(name, "nonexistent");
670 assert!(available.contains(&"menu".to_owned()));
671 }
672 other => panic!("expected GroupNotFound, got {other:?}"),
673 }
674 }
675
676 #[test]
679 fn list_in_group_group_not_found() {
680 let toml = concat!(
681 "[[command]]\ngroup = \"menu\"\nname = \"wifi\"\n",
682 "steps = [{ run = [\"echo\"] }]\n",
683 );
684 let (_dir, path) = write_config(toml);
685 let o = opts(path);
686
687 let err = list_in_group("nonexistent", &o, false).unwrap_err();
688 match err {
689 DispatchError::GroupNotFound { name, available } => {
690 assert_eq!(name, "nonexistent");
691 assert!(available.contains(&"menu".to_owned()));
692 }
693 other => panic!("expected GroupNotFound, got {other:?}"),
694 }
695 }
696
697 #[test]
700 fn mixed_groups_each_listable_and_runnable() {
701 let toml = concat!(
702 "[[command]]\ngroup = \"battery\"\nname = \"report\"\n",
703 "steps = [{ run = [\"echo\", \"battery\"] }]\n\n",
704 "[[command]]\ngroup = \"kanata\"\nname = \"toggle\"\n",
705 "steps = [{ run = [\"echo\", \"kanata\"] }]\n\n",
706 "[[command]]\ngroup = \"menu\"\nname = \"wifi\"\n",
707 "steps = [{ run = [\"echo\", \"wifi\"] }]\n",
708 );
709 let (_dir, path) = write_config(toml);
710
711 for group in &["battery", "kanata", "menu"] {
712 let o = opts(path.clone());
713 let entries = list_in_group(group, &o, false).unwrap();
714 assert_eq!(entries.len(), 1, "group {group} should have 1 entry");
715 }
716
717 for (group, name) in &[
718 ("battery", "report"),
719 ("kanata", "toggle"),
720 ("menu", "wifi"),
721 ] {
722 let o = opts(path.clone());
723 let process = MockProcessExec::new([ok_result("")]);
724 let notifier = MockNotifier::default();
725 let mut prompter = MockPrompter::default();
726 run_in_group_with(group, name, &o, &process, ¬ifier, &mut prompter).unwrap();
727 }
728 }
729}