Skip to main content

muster/adapter/
cli.rs

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