Skip to main content

krypt_core/
dispatch.rs

1//! Generic `krypt <group> <name>` dispatcher.
2//!
3//! This module is the core logic behind both `krypt menu` and any arbitrary
4//! user-defined group (`krypt battery report`, `krypt kanata toggle`, etc.).
5//! It keeps the CLI thin by handling config loading, platform filtering,
6//! dry-run formatting, and runner dispatch from here.
7//!
8//! # Step interpolation note
9//!
10//! Steps use `{name}` for named captures and `{0}`..`{9}` for positional args
11//! forwarded via `krypt <group> <name> -- arg0 arg1 ...`. The `${VAR}` syntax
12//! (e.g. `${HOME}`) is **not** expanded inside step args — that is a
13//! `.krypt.toml`-level path-variable syntax resolved by [`crate::paths::Resolver`]
14//! at config-load time, not at step-execution time. If a step needs `$HOME`,
15//! use `run = ["sh", "-c", "echo $HOME"]` or capture it into a named variable
16//! first.
17//!
18//! The resolver **is** used by predicate evaluation (`file_exists:${HOME}/.bashrc`
19//! etc.) because `predicate.rs` calls `Resolver::resolve` internally.
20
21#![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// ─── Public types ─────────────────────────────────────────────────────────────
36
37/// Options shared by both listing and running dispatch groups.
38#[derive(Debug)]
39pub struct DispatchOpts {
40    /// Resolved path to `.krypt.toml`.
41    pub config_path: PathBuf,
42    /// Positional args forwarded to step `{0}`..`{9}` placeholders.
43    pub args: Vec<String>,
44    /// When `true`, print the step plan without executing any process.
45    pub dry_run: bool,
46}
47
48/// One entry returned by [`list_in_group`].
49#[derive(Debug)]
50pub struct DispatchListEntry {
51    /// Command name (`[[command]] name = ...`).
52    pub name: String,
53    /// Short description (`[[command]] description = ...`).
54    pub description: String,
55    /// `true` when the command's `platform` field doesn't match the current OS
56    /// and it was included only because `show_all = true` was requested.
57    pub platform_filtered: bool,
58    /// The declared platform restriction, if any.
59    pub platform: Option<String>,
60}
61
62/// Summary of a completed dispatch run.
63#[derive(Debug)]
64pub struct DispatchReport {
65    /// Steps that were executed (including those with ignored failures).
66    pub steps_run: usize,
67    /// Steps skipped because their `if` predicate returned false.
68    pub steps_skipped: usize,
69    /// Steps that failed but were ignored via `on_fail = "ignore"` or
70    /// `ignore_failure = true`.
71    pub steps_failed_ignored: usize,
72    /// Rendered dry-run plan, populated when [`DispatchOpts::dry_run`] is `true`.
73    pub dry_run_plan: Option<String>,
74}
75
76/// Everything that can go wrong in the dispatch subsystem.
77///
78/// Boxed large variants to stay ≤ 128 bytes on all targets.
79#[derive(Debug, Error)]
80pub enum DispatchError {
81    /// The config file could not be loaded or parsed.
82    #[error("loading config: {0}")]
83    ConfigLoad(#[from] Box<IncludeError>),
84
85    /// No `[[command]]` entries exist for the requested group.
86    #[error(
87        "unknown group {name:?} — no [[command]] entries with group = {name:?}\n\navailable groups:\n{}",
88        format_groups(available)
89    )]
90    GroupNotFound {
91        /// The group name that was looked up.
92        name: String,
93        /// All groups defined in the config.
94        available: Vec<String>,
95    },
96
97    /// No `[[command]] group = "<group>"` entry matched the requested name.
98    #[error(
99        "command {name:?} not found in group {group:?}; available: {}",
100        available_in_group.join(", ")
101    )]
102    CommandNotFound {
103        /// The group being searched.
104        group: String,
105        /// The name that was looked up.
106        name: String,
107        /// All command names defined in the group (regardless of platform).
108        available_in_group: Vec<String>,
109    },
110
111    /// The requested command exists but is restricted to a different platform.
112    #[error(
113        "command {name:?} in group {group:?} is restricted to {required}; current platform is {current}"
114    )]
115    PlatformMismatch {
116        /// Group name.
117        group: String,
118        /// Command name.
119        name: String,
120        /// Platform the command requires.
121        required: String,
122        /// Platform the binary is running on.
123        current: String,
124    },
125
126    /// The step runner returned an error.
127    #[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
154// ─── Group listing ────────────────────────────────────────────────────────────
155
156/// Return all distinct group names present in the config, sorted.
157pub 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
169// ─── Listing ──────────────────────────────────────────────────────────────────
170
171/// Return all commands defined in the given group.
172///
173/// When `show_all` is `false`, commands whose `platform` field doesn't match
174/// [`Platform::current`] are excluded. When `show_all` is `true` they are
175/// included with `platform_filtered = true`.
176///
177/// Returns [`DispatchError::GroupNotFound`] when the group has no entries.
178/// Results are sorted alphabetically by name.
179pub 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
234// ─── Running (production) ─────────────────────────────────────────────────────
235
236/// Run the named command in the given group using production process/notify/prompt implementations.
237pub 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        &notifier,
253        &mut prompter,
254    )
255}
256
257// ─── Running (injectable) ─────────────────────────────────────────────────────
258
259/// Run the named command in the given group with injected dependencies (used by tests and dry-run).
260pub 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    // Platform gate.
302    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
332// ─── Dry-run ──────────────────────────────────────────────────────────────────
333
334fn 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// ─── Tests ────────────────────────────────────────────────────────────────────
412
413#[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    // ── 1. list_in_group: platform filter ────────────────────────────────────
462
463    #[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    // ── 2. run_in_group: not found ────────────────────────────────────────────
497
498    #[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            &notifier,
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    // ── 3. run_in_group: platform mismatch ───────────────────────────────────
535
536    #[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, &notifier, &mut prompter)
561            .unwrap_err();
562        assert!(
563            matches!(err, DispatchError::PlatformMismatch { .. }),
564            "expected PlatformMismatch"
565        );
566    }
567
568    // ── 4. run_in_group: steps execute, arg forwarding works ─────────────────
569
570    #[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, &notifier, &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    // ── 5. dry-run: no process calls, plan is non-empty ──────────────────────
597
598    #[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, &notifier, &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    // ── 6. list_groups: returns distinct sorted group names ──────────────────
630
631    #[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    // ── 7. run_in_group: GroupNotFound for nonexistent group ─────────────────
651
652    #[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, &notifier, &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    // ── 8. list_in_group: GroupNotFound for nonexistent group ────────────────
677
678    #[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    // ── 9. mixed groups: battery/kanata/menu each listable and runnable ───────
698
699    #[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, &notifier, &mut prompter).unwrap();
727        }
728    }
729}