Skip to main content

muster/adapter/cli/
run.rs

1use std::{
2    fs,
3    io::ErrorKind,
4    path::{Path, PathBuf},
5};
6
7use clap_complete::{ArgValueCandidates, CompletionCandidate};
8
9use super::error::CliError;
10use crate::{
11    adapter::{
12        config::YamlProjectRegistry,
13        path::{absolutize, registered_config_path},
14    },
15    constants::MUSTER_PROJECT_ENV,
16    domain::{
17        config::{ConfigError, ProcessSpec, WorkspaceConfig},
18        port::ProjectRegistry,
19        process::ProcessKind,
20        project::Project,
21        value::{CommandLine, ProcessName, ProjectName},
22    },
23};
24
25/// Shell used to run a captured command, matching how the PTY runner launches
26/// processes so "runs now" behaves the same as "runs later under muster".
27#[cfg(unix)]
28const SHELL_PROGRAM: &str = "/bin/sh";
29/// Flag passing the command string to [`SHELL_PROGRAM`].
30#[cfg(unix)]
31const SHELL_FLAG: &str = "-c";
32
33/// `muster run`: register a command as a process in a project, then run it.
34///
35/// The command is added to the project's workspace file (so it persists and is
36/// managed on the next launch) and then executed in place, exactly as if it had
37/// been typed without the `muster run` prefix.
38#[derive(clap::Args)]
39pub struct RunArgs {
40    /// Project to add the command to (a registered project name). Defaults to
41    /// the current project when run inside a muster pane.
42    #[arg(short, long, add = ArgValueCandidates::new(project_candidates))]
43    project: Option<String>,
44    /// Sidebar name for the process. Defaults to the command's first word.
45    #[arg(short, long)]
46    name: Option<String>,
47    /// How the process is grouped and managed.
48    #[arg(short, long, value_enum, default_value_t = ProcessKindArg::Command)]
49    kind: ProcessKindArg,
50    /// The command to register and run, e.g. `-- npm run dev`.
51    #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
52    command: Vec<String>,
53}
54
55/// The process kind, as a CLI-facing value enum.
56#[derive(Clone, Copy, clap::ValueEnum)]
57enum ProcessKindArg {
58    Agent,
59    Terminal,
60    Command,
61}
62
63impl From<ProcessKindArg> for ProcessKind {
64    fn from(kind: ProcessKindArg) -> Self {
65        match kind {
66            ProcessKindArg::Agent => Self::Agent,
67            ProcessKindArg::Terminal => Self::Terminal,
68            ProcessKindArg::Command => Self::Command,
69        }
70    }
71}
72
73/// Registers the command in the resolved project, then runs it in place. On
74/// success this replaces the current process, so it does not return; any error
75/// short-circuits before the command runs.
76///
77/// # Errors
78/// Returns [`CliError`] if the project cannot be resolved, the workspace file
79/// cannot be updated, or the command fails to start.
80pub fn run(args: RunArgs, config: PathBuf, registry: &dyn ProjectRegistry) -> Result<(), CliError> {
81    // Running the command replaces this process in place, which only works on
82    // Unix. Refuse up front so a failed run never leaves a persisted-but-unrun
83    // entry in the config on other platforms.
84    if cfg!(not(unix)) {
85        return Err(CliError::Unsupported);
86    }
87    let config_path = resolve(
88        registry,
89        args.project.as_deref(),
90        current_project_from_env(),
91        config,
92    )?;
93    let command = command_string(&args.command)?;
94    let command_line = CommandLine::try_new(command).map_err(|_| CliError::EmptyCommand)?;
95    let name = process_name(args.name.as_deref(), &args.command)?;
96    // The command runs in the caller's directory now; record it so a later start
97    // from the sidebar runs there too instead of muster's launch directory.
98    let spec = ProcessSpec::builder()
99        .name(name)
100        .command(Some(command_line.clone()))
101        .working_dir(std::env::current_dir().ok())
102        .build();
103    register(registry, &config_path, spec, args.kind.into())?;
104    // Run the stored form verbatim, so the immediate run matches what muster
105    // will run later.
106    Err(exec(command_line.as_ref()))
107}
108
109/// Resolves an absolute workspace path for a bare TUI launch, preferring
110/// explicit and contextual paths before local and globally registered ones.
111///
112/// # Errors
113/// Returns [`ConfigError`] when the registry cannot be read or its fallback
114/// project has an ambiguous legacy relative path.
115pub fn resolve_tui_config(
116    explicit: Option<&Path>,
117    current_project: Option<&Path>,
118    local_config: &Path,
119    registry: &dyn ProjectRegistry,
120) -> Result<PathBuf, ConfigError> {
121    if let Some(path) = explicit.or(current_project) {
122        return Ok(absolutize(path));
123    }
124    if should_select_local_config(local_config) {
125        return Ok(absolutize(local_config));
126    }
127    let projects = registry.projects()?;
128    match projects.first() {
129        Some(project) => registered_config_path(project),
130        None => Ok(absolutize(local_config)),
131    }
132}
133
134/// Whether local discovery should select this config entry. Errors other than a
135/// missing entry stay local so an unreadable path cannot launch another project.
136fn should_select_local_config(path: &Path) -> bool {
137    match fs::symlink_metadata(path) {
138        Ok(_) => true,
139        Err(error) => error.kind() != ErrorKind::NotFound,
140    }
141}
142
143/// Turns the parsed argument vector into the command string run by `sh -c`.
144///
145/// A single argument is already a shell expression the user quoted (e.g. `'npm
146/// test && npm run build'`), so it is passed through verbatim to preserve
147/// pipelines, redirects, `&&`, and variable expansion. Multiple arguments came
148/// from the shell's own tokenization, so each is re-escaped to preserve its
149/// boundary (e.g. `printf '%s\n' 'hello world'`).
150fn command_string(command: &[String]) -> Result<String, CliError> {
151    match command {
152        [expression] => Ok(expression.clone()),
153        _ => shlex::try_join(command.iter().map(String::as_str))
154            .map_err(|_| CliError::InvalidCommand),
155    }
156}
157
158/// Resolves the target workspace path: an explicit project name looked up in the
159/// registry, else the current-project environment variable, else the top-level
160/// `--config` path (so `muster --config X run ...` targets X).
161fn resolve(
162    registry: &dyn ProjectRegistry,
163    project: Option<&str>,
164    env: Option<PathBuf>,
165    config: PathBuf,
166) -> Result<PathBuf, CliError> {
167    match project {
168        Some(name) => resolve_named(registry, name),
169        None => Ok(absolutize(&env.unwrap_or(config))),
170    }
171}
172
173/// Looks up a registered project's config path by name.
174fn resolve_named(registry: &dyn ProjectRegistry, name: &str) -> Result<PathBuf, CliError> {
175    let wanted =
176        ProjectName::try_new(name).map_err(|_| CliError::UnknownProject(name.to_string()))?;
177    let matches: Vec<Project> = registry
178        .projects()?
179        .into_iter()
180        .filter(|project| project.name().as_ref() == wanted.as_ref())
181        .collect();
182    // Mirror `projects remove`: a duplicated name must never silently pick
183    // one workspace to mutate.
184    if matches.len() > 1 {
185        return Err(CliError::AmbiguousProject {
186            name: name.to_string(),
187            count: matches.len(),
188            paths: matches
189                .iter()
190                .map(|project| project.config().display().to_string())
191                .collect::<Vec<_>>()
192                .join(", "),
193        });
194    }
195    let project = matches
196        .into_iter()
197        .next()
198        .ok_or_else(|| CliError::UnknownProject(name.to_string()))?;
199    Ok(registered_config_path(&project)?)
200}
201
202/// Shell-completion candidates for `--project`: the names of registered
203/// projects, read fresh each time so newly-created projects complete at once.
204fn project_candidates() -> Vec<CompletionCandidate> {
205    YamlProjectRegistry
206        .projects()
207        .unwrap_or_default()
208        .iter()
209        .map(|project| CompletionCandidate::new(project.name().as_ref()))
210        .collect()
211}
212
213/// Returns the current-project path exported into child panes, if present.
214pub fn current_project_from_env() -> Option<PathBuf> {
215    std::env::var_os(MUSTER_PROJECT_ENV)
216        .map(PathBuf::from)
217        .filter(|path| !path.as_os_str().is_empty())
218}
219
220/// The process name to record: an explicit non-blank name, else the command's
221/// first word.
222fn process_name(explicit: Option<&str>, command: &[String]) -> Result<ProcessName, CliError> {
223    let candidate = explicit
224        .map(str::trim)
225        .filter(|value| !value.is_empty())
226        // The first word of the first argument: for a single quoted expression
227        // like `npm test && npm run build` this is `npm`, not the whole string.
228        .or_else(|| {
229            command
230                .first()
231                .and_then(|first| first.split_whitespace().next())
232        })
233        .unwrap_or_default();
234    ProcessName::try_new(candidate).map_err(|_| CliError::InvalidName(candidate.to_string()))
235}
236
237/// Appends `spec` to the section for `kind`, under the registry's locked
238/// read-modify-write so concurrent `muster run` invocations do not clobber each
239/// other.
240fn register(
241    registry: &dyn ProjectRegistry,
242    config_path: &Path,
243    spec: ProcessSpec,
244    kind: ProcessKind,
245) -> Result<(), CliError> {
246    let mut append = |config: WorkspaceConfig| {
247        let spec = spec.clone();
248        match kind {
249            ProcessKind::Agent => {
250                let mut specs = config.agents().clone();
251                specs.push(spec);
252                config.with_agents(specs)
253            },
254            ProcessKind::Terminal => {
255                let mut specs = config.terminals().clone();
256                specs.push(spec);
257                config.with_terminals(specs)
258            },
259            ProcessKind::Command => {
260                let mut specs = config.commands().clone();
261                specs.push(spec);
262                config.with_commands(specs)
263            },
264        }
265    };
266    registry.update_workspace(config_path, &mut append)?;
267    Ok(())
268}
269
270/// Replaces the current process with the command via the shell. Returns the
271/// error only if the command could not be started (on success it never returns).
272#[cfg(unix)]
273fn exec(command: &str) -> CliError {
274    use std::os::unix::process::CommandExt;
275
276    let error = std::process::Command::new(SHELL_PROGRAM)
277        .arg(SHELL_FLAG)
278        .arg(command)
279        .exec();
280    CliError::Exec(error)
281}
282
283/// Unreachable on non-Unix: [`run`] refuses before persisting, so the platform's
284/// missing in-place exec never reaches a shell.
285#[cfg(not(unix))]
286fn exec(_command: &str) -> CliError {
287    CliError::Unsupported
288}
289
290#[cfg(test)]
291mod tests {
292    use std::cell::RefCell;
293
294    use super::*;
295    use crate::domain::{config::WorkspaceConfig, project::Project};
296
297    /// A registry with a fixed project list and workspace, recording any save.
298    struct FakeRegistry {
299        projects: Vec<Project>,
300        workspace: WorkspaceConfig,
301        saved: RefCell<Option<(PathBuf, WorkspaceConfig)>>,
302    }
303
304    impl ProjectRegistry for FakeRegistry {
305        fn projects(&self) -> Result<Vec<Project>, ConfigError> {
306            Ok(self.projects.clone())
307        }
308
309        fn workspace(&self, _config_path: &Path) -> Result<WorkspaceConfig, ConfigError> {
310            Ok(self.workspace.clone())
311        }
312
313        fn workspace_exists(&self, _config_path: &Path) -> bool {
314            false
315        }
316
317        fn save(&self, _projects: &[Project]) -> Result<(), ConfigError> {
318            Ok(())
319        }
320
321        fn save_workspace(
322            &self,
323            config_path: &Path,
324            config: &WorkspaceConfig,
325        ) -> Result<(), ConfigError> {
326            *self.saved.borrow_mut() = Some((config_path.to_path_buf(), config.clone()));
327            Ok(())
328        }
329    }
330
331    fn empty_workspace() -> WorkspaceConfig {
332        WorkspaceConfig::builder()
333            .agents(vec![])
334            .terminals(vec![])
335            .commands(vec![])
336            .build()
337    }
338
339    fn registry_with(projects: Vec<Project>) -> FakeRegistry {
340        FakeRegistry {
341            projects,
342            workspace: empty_workspace(),
343            saved: RefCell::new(None),
344        }
345    }
346
347    fn project(name: &str, config: &str) -> Project {
348        Project::builder()
349            .name(ProjectName::try_new(name).unwrap())
350            .config(PathBuf::from(config))
351            .build()
352    }
353
354    #[test]
355    fn resolves_a_named_project_to_its_config_path() {
356        let registry = registry_with(vec![
357            project("web", "~/web/muster.yml"),
358            project("api", "~/api/muster.yml"),
359        ]);
360        assert_eq!(
361            resolve(&registry, Some("api"), None, PathBuf::from("muster.yml")).unwrap(),
362            absolutize(Path::new("~/api/muster.yml"))
363        );
364    }
365
366    #[test]
367    fn a_duplicated_project_name_is_ambiguous() {
368        let registry = registry_with(vec![
369            project("web", "~/site/muster.yml"),
370            project("web", "~/app/muster.yml"),
371        ]);
372        assert!(
373            matches!(
374                resolve(&registry, Some("web"), None, PathBuf::from("muster.yml")),
375                Err(CliError::AmbiguousProject { count: 2, .. })
376            ),
377            "run -p must refuse to pick one of two same-named projects"
378        );
379    }
380
381    #[test]
382    fn an_unknown_project_name_errors() {
383        let registry = registry_with(vec![project("web", "~/web/muster.yml")]);
384        assert!(matches!(
385            resolve(&registry, Some("nope"), None, PathBuf::from("muster.yml")),
386            Err(CliError::UnknownProject(_))
387        ));
388    }
389
390    #[test]
391    fn without_a_name_it_falls_back_to_environment_then_config() {
392        let registry = registry_with(vec![]);
393        assert_eq!(
394            resolve(
395                &registry,
396                None,
397                Some(PathBuf::from("/env/muster.yml")),
398                PathBuf::from("/cfg/muster.yml"),
399            )
400            .unwrap(),
401            PathBuf::from("/env/muster.yml"),
402            "the environment path wins over --config"
403        );
404        assert_eq!(
405            resolve(&registry, None, None, PathBuf::from("/cfg/muster.yml")).unwrap(),
406            PathBuf::from("/cfg/muster.yml"),
407            "with no name or environment, the --config path is the target"
408        );
409    }
410
411    /// Verifies explicit and pane-context paths outrank filesystem discovery.
412    #[test]
413    fn tui_config_prefers_explicit_then_current_project() {
414        let registry = registry_with(vec![project("registered", "registered.yml")]);
415        let local = Path::new("missing-local.yml");
416
417        assert_eq!(
418            resolve_tui_config(
419                Some(Path::new("explicit.yml")),
420                Some(Path::new("current.yml")),
421                local,
422                &registry,
423            )
424            .unwrap(),
425            absolutize(Path::new("explicit.yml"))
426        );
427        assert_eq!(
428            resolve_tui_config(None, Some(Path::new("current.yml")), local, &registry).unwrap(),
429            absolutize(Path::new("current.yml"))
430        );
431    }
432
433    #[cfg(unix)]
434    /// Verifies workspace resolution makes a symlink path absolute without
435    /// replacing the selected alias with its target.
436    #[test]
437    fn tui_config_preserves_an_explicit_symlink_path() {
438        use std::{fs, os::unix::fs::symlink};
439
440        let dir = std::env::temp_dir().join(format!("muster-cli-link-{}", std::process::id()));
441        let target = dir.join("shared.yml");
442        let link = dir.join("muster.yml");
443        fs::create_dir_all(&dir).unwrap();
444        fs::write(&target, "").unwrap();
445        symlink(&target, &link).unwrap();
446        let registry = registry_with(Vec::new());
447
448        let resolved =
449            resolve_tui_config(Some(&link), None, Path::new("unused.yml"), &registry).unwrap();
450
451        assert_eq!(resolved, link);
452        fs::remove_dir_all(dir).unwrap();
453    }
454
455    #[cfg(unix)]
456    /// Verifies a dangling local config alias is selected and left for the
457    /// loader to report instead of falling through to a registered workspace.
458    #[test]
459    fn tui_config_prefers_a_dangling_local_symlink() {
460        use std::os::unix::fs::symlink;
461
462        let dir = std::env::temp_dir().join(format!("muster-cli-dangling-{}", std::process::id()));
463        let local = dir.join("muster.yml");
464        fs::create_dir_all(&dir).unwrap();
465        symlink(dir.join("missing.yml"), &local).unwrap();
466        let registry = registry_with(vec![project("registered", "/other/muster.yml")]);
467
468        let resolved = resolve_tui_config(None, None, &local, &registry).unwrap();
469
470        assert_eq!(resolved, local);
471        fs::remove_dir_all(dir).unwrap();
472    }
473
474    /// Verifies an existing local workspace preserves the original bare-launch
475    /// behavior even when global projects are registered.
476    #[test]
477    fn tui_config_prefers_an_existing_local_workspace() {
478        let registry = registry_with(vec![project("registered", "registered.yml")]);
479        let local = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("muster.yml");
480
481        assert_eq!(
482            resolve_tui_config(None, None, &local, &registry).unwrap(),
483            local
484        );
485    }
486
487    /// Verifies a bare launch outside a workspace uses the first global project.
488    #[test]
489    fn tui_config_falls_back_to_the_global_registry() {
490        let first = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("first.yml");
491        let registry = registry_with(vec![
492            Project::builder()
493                .name(ProjectName::try_new("first").unwrap())
494                .config(first.clone())
495                .build(),
496        ]);
497
498        assert_eq!(
499            resolve_tui_config(None, None, Path::new("missing-local.yml"), &registry).unwrap(),
500            first
501        );
502    }
503
504    /// Verifies an ambiguous legacy registry entry fails closed rather than
505    /// resolving against the directory of a later launch.
506    #[test]
507    fn tui_config_rejects_a_legacy_relative_registry_path() {
508        let registry = registry_with(vec![project("legacy", "muster.yml")]);
509
510        let error =
511            resolve_tui_config(None, None, Path::new("missing-local.yml"), &registry).unwrap_err();
512
513        match error {
514            ConfigError::RelativeProjectConfig { name, path } => {
515                assert_eq!(name.as_ref(), "legacy");
516                assert_eq!(path, PathBuf::from("muster.yml"));
517            },
518            other => panic!("unexpected error: {other}"),
519        }
520    }
521
522    /// Verifies an empty registry retains the conventional path so its existing
523    /// load error remains actionable.
524    #[test]
525    fn tui_config_keeps_the_local_path_when_the_registry_is_empty() {
526        let registry = registry_with(vec![]);
527        let local = Path::new("missing-local.yml");
528
529        assert_eq!(
530            resolve_tui_config(None, None, local, &registry).unwrap(),
531            absolutize(local)
532        );
533    }
534
535    #[test]
536    fn register_appends_the_command_to_its_section() {
537        let registry = registry_with(vec![]);
538        let spec = ProcessSpec::builder()
539            .name(ProcessName::try_new("web").unwrap())
540            .command(Some(CommandLine::try_new("npm run dev").unwrap()))
541            .build();
542
543        register(
544            &registry,
545            Path::new("/here/muster.yml"),
546            spec,
547            ProcessKind::Command,
548        )
549        .unwrap();
550
551        let saved = registry.saved.borrow();
552        let (path, config) = saved.as_ref().unwrap();
553        assert_eq!(path, Path::new("/here/muster.yml"));
554        assert_eq!(config.commands().len(), 1);
555        assert_eq!(config.commands()[0].name().as_ref(), "web");
556        assert!(
557            config.agents().is_empty() && config.terminals().is_empty(),
558            "only the command section grew"
559        );
560    }
561
562    #[test]
563    fn command_reconstruction_preserves_argument_boundaries() {
564        // A plain-space join would flatten these; escaping must round-trip back
565        // to the exact argument vector.
566        let argv = vec![
567            "printf".to_string(),
568            "%s\n".to_string(),
569            "hello world".to_string(),
570        ];
571        let rebuilt = command_string(&argv).unwrap();
572        assert_eq!(
573            shlex::split(&rebuilt).unwrap(),
574            argv,
575            "the escaped command re-splits into the original arguments"
576        );
577        assert_ne!(
578            rebuilt,
579            argv.join(" "),
580            "escaping actually changed something"
581        );
582    }
583
584    #[test]
585    fn a_single_argument_is_kept_as_a_shell_expression() {
586        // One quoted argument is a shell expression; it must reach `sh -c`
587        // unescaped so `&&`, pipes, and redirects still work.
588        let argv = vec!["npm test && npm run build".to_string()];
589        assert_eq!(
590            command_string(&argv).unwrap(),
591            "npm test && npm run build",
592            "a single argument passes through verbatim"
593        );
594    }
595
596    #[test]
597    fn the_process_name_defaults_to_the_first_word() {
598        let command = vec!["npm".to_string(), "run".to_string(), "dev".to_string()];
599        assert_eq!(
600            process_name(None, &command).unwrap().as_ref(),
601            "npm",
602            "no explicit name uses the command's first word"
603        );
604        assert_eq!(process_name(Some("web"), &command).unwrap().as_ref(), "web");
605        assert_eq!(
606            process_name(Some("   "), &command).unwrap().as_ref(),
607            "npm",
608            "a blank explicit name falls back to the first word"
609        );
610        assert_eq!(
611            process_name(None, &["npm test && npm run build".to_string()])
612                .unwrap()
613                .as_ref(),
614            "npm",
615            "a single quoted expression uses its first word, not the whole string"
616        );
617    }
618}