Skip to main content

muster/adapter/
cli.rs

1use std::path::{Path, PathBuf};
2
3use clap_complete::{ArgValueCandidates, CompletionCandidate};
4use thiserror::Error;
5
6use crate::{
7    adapter::config::YamlProjectRegistry,
8    constants::MUSTER_PROJECT_ENV,
9    domain::{
10        config::{ConfigError, ProcessSpec, WorkspaceConfig},
11        port::ProjectRegistry,
12        process::ProcessKind,
13        value::{CommandLine, ProcessName, ProjectName},
14    },
15};
16
17/// Shell used to run a captured command, matching how the PTY runner launches
18/// processes so "runs now" behaves the same as "runs later under muster".
19#[cfg(unix)]
20const SHELL_PROGRAM: &str = "/bin/sh";
21/// Flag passing the command string to [`SHELL_PROGRAM`].
22#[cfg(unix)]
23const SHELL_FLAG: &str = "-c";
24
25/// `muster run`: register a command as a process in a project, then run it.
26///
27/// The command is added to the project's workspace file (so it persists and is
28/// managed on the next launch) and then executed in place, exactly as if it had
29/// been typed without the `muster run` prefix.
30#[derive(clap::Args)]
31pub struct RunArgs {
32    /// Project to add the command to (a registered project name). Defaults to
33    /// the current project when run inside a muster pane.
34    #[arg(short, long, add = ArgValueCandidates::new(project_candidates))]
35    project: Option<String>,
36    /// Sidebar name for the process. Defaults to the command's first word.
37    #[arg(short, long)]
38    name: Option<String>,
39    /// How the process is grouped and managed.
40    #[arg(short, long, value_enum, default_value_t = ProcessKindArg::Command)]
41    kind: ProcessKindArg,
42    /// The command to register and run, e.g. `-- npm run dev`.
43    #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
44    command: Vec<String>,
45}
46
47/// The process kind, as a CLI-facing value enum.
48#[derive(Clone, Copy, clap::ValueEnum)]
49enum ProcessKindArg {
50    Agent,
51    Terminal,
52    Command,
53}
54
55impl From<ProcessKindArg> for ProcessKind {
56    fn from(kind: ProcessKindArg) -> Self {
57        match kind {
58            ProcessKindArg::Agent => Self::Agent,
59            ProcessKindArg::Terminal => Self::Terminal,
60            ProcessKindArg::Command => Self::Command,
61        }
62    }
63}
64
65/// A failure while capturing or running a command.
66#[derive(Debug, Error)]
67pub enum CliError {
68    /// No registered project matched the requested name.
69    #[error("unknown project '{0}'")]
70    UnknownProject(String),
71    /// The derived or given process name was empty.
72    #[error("'{0}' is not a valid process name")]
73    InvalidName(String),
74    /// The command was blank.
75    #[error("the command is empty")]
76    EmptyCommand,
77    /// The command could not be reassembled into a shell string.
78    #[error("the command cannot be represented as a shell command")]
79    InvalidCommand,
80    /// `muster run` is not available on this platform.
81    #[error("muster run is only supported on Unix")]
82    Unsupported,
83    /// The workspace file could not be read or written.
84    #[error(transparent)]
85    Config(#[from] ConfigError),
86    /// The command could not be executed.
87    #[error("failed to run the command: {0}")]
88    Exec(#[source] std::io::Error),
89}
90
91/// Registers the command in the resolved project, then runs it in place. On
92/// success this replaces the current process, so it does not return; any error
93/// short-circuits before the command runs.
94///
95/// # Errors
96/// Returns [`CliError`] if the project cannot be resolved, the workspace file
97/// cannot be updated, or the command fails to start.
98pub fn run(args: RunArgs, config: PathBuf, registry: &dyn ProjectRegistry) -> Result<(), CliError> {
99    // Running the command replaces this process in place, which only works on
100    // Unix. Refuse up front so a failed run never leaves a persisted-but-unrun
101    // entry in the config on other platforms.
102    if cfg!(not(unix)) {
103        return Err(CliError::Unsupported);
104    }
105    let config_path = resolve(registry, args.project.as_deref(), env_project(), config)?;
106    let command = command_string(&args.command)?;
107    let command_line = CommandLine::try_new(command).map_err(|_| CliError::EmptyCommand)?;
108    let name = process_name(args.name.as_deref(), &args.command)?;
109    // The command runs in the caller's directory now; record it so a later start
110    // from the sidebar runs there too instead of muster's launch directory.
111    let spec = ProcessSpec::builder()
112        .name(name)
113        .command(Some(command_line.clone()))
114        .working_dir(std::env::current_dir().ok())
115        .build();
116    register(registry, &config_path, spec, args.kind.into())?;
117    // Run the stored form verbatim, so the immediate run matches what muster
118    // will run later.
119    Err(exec(command_line.as_ref()))
120}
121
122/// Turns the parsed argument vector into the command string run by `sh -c`.
123///
124/// A single argument is already a shell expression the user quoted (e.g. `'npm
125/// test && npm run build'`), so it is passed through verbatim to preserve
126/// pipelines, redirects, `&&`, and variable expansion. Multiple arguments came
127/// from the shell's own tokenization, so each is re-escaped to preserve its
128/// boundary (e.g. `printf '%s\n' 'hello world'`).
129fn command_string(command: &[String]) -> Result<String, CliError> {
130    match command {
131        [expression] => Ok(expression.clone()),
132        _ => shlex::try_join(command.iter().map(String::as_str))
133            .map_err(|_| CliError::InvalidCommand),
134    }
135}
136
137/// Resolves the target workspace path: an explicit project name looked up in the
138/// registry, else the current-project environment variable, else the top-level
139/// `--config` path (so `muster --config X run ...` targets X).
140fn resolve(
141    registry: &dyn ProjectRegistry,
142    project: Option<&str>,
143    env: Option<PathBuf>,
144    config: PathBuf,
145) -> Result<PathBuf, CliError> {
146    match project {
147        Some(name) => resolve_named(registry, name),
148        None => Ok(env.unwrap_or(config)),
149    }
150}
151
152/// Looks up a registered project's config path by name.
153fn resolve_named(registry: &dyn ProjectRegistry, name: &str) -> Result<PathBuf, CliError> {
154    let wanted =
155        ProjectName::try_new(name).map_err(|_| CliError::UnknownProject(name.to_string()))?;
156    registry
157        .projects()?
158        .into_iter()
159        .find(|project| project.name().as_ref() == wanted.as_ref())
160        .map(|project| project.config().clone())
161        .ok_or_else(|| CliError::UnknownProject(name.to_string()))
162}
163
164/// Shell-completion candidates for `--project`: the names of registered
165/// projects, read fresh each time so newly-created projects complete at once.
166fn project_candidates() -> Vec<CompletionCandidate> {
167    YamlProjectRegistry
168        .projects()
169        .unwrap_or_default()
170        .iter()
171        .map(|project| CompletionCandidate::new(project.name().as_ref()))
172        .collect()
173}
174
175/// The current-project path from the environment, if muster exported one.
176fn env_project() -> Option<PathBuf> {
177    std::env::var_os(MUSTER_PROJECT_ENV)
178        .map(PathBuf::from)
179        .filter(|path| !path.as_os_str().is_empty())
180}
181
182/// The process name to record: an explicit non-blank name, else the command's
183/// first word.
184fn process_name(explicit: Option<&str>, command: &[String]) -> Result<ProcessName, CliError> {
185    let candidate = explicit
186        .map(str::trim)
187        .filter(|value| !value.is_empty())
188        // The first word of the first argument: for a single quoted expression
189        // like `npm test && npm run build` this is `npm`, not the whole string.
190        .or_else(|| {
191            command
192                .first()
193                .and_then(|first| first.split_whitespace().next())
194        })
195        .unwrap_or_default();
196    ProcessName::try_new(candidate).map_err(|_| CliError::InvalidName(candidate.to_string()))
197}
198
199/// Appends `spec` to the section for `kind`, under the registry's locked
200/// read-modify-write so concurrent `muster run` invocations do not clobber each
201/// other.
202fn register(
203    registry: &dyn ProjectRegistry,
204    config_path: &Path,
205    spec: ProcessSpec,
206    kind: ProcessKind,
207) -> Result<(), CliError> {
208    let mut append = |config: WorkspaceConfig| {
209        let spec = spec.clone();
210        match kind {
211            ProcessKind::Agent => {
212                let mut specs = config.agents().clone();
213                specs.push(spec);
214                config.with_agents(specs)
215            },
216            ProcessKind::Terminal => {
217                let mut specs = config.terminals().clone();
218                specs.push(spec);
219                config.with_terminals(specs)
220            },
221            ProcessKind::Command => {
222                let mut specs = config.commands().clone();
223                specs.push(spec);
224                config.with_commands(specs)
225            },
226        }
227    };
228    registry.update_workspace(config_path, &mut append)?;
229    Ok(())
230}
231
232/// Replaces the current process with the command via the shell. Returns the
233/// error only if the command could not be started (on success it never returns).
234#[cfg(unix)]
235fn exec(command: &str) -> CliError {
236    use std::os::unix::process::CommandExt;
237
238    let error = std::process::Command::new(SHELL_PROGRAM)
239        .arg(SHELL_FLAG)
240        .arg(command)
241        .exec();
242    CliError::Exec(error)
243}
244
245/// Unreachable on non-Unix: [`run`] refuses before persisting, so the platform's
246/// missing in-place exec never reaches a shell.
247#[cfg(not(unix))]
248fn exec(_command: &str) -> CliError {
249    CliError::Unsupported
250}
251
252#[cfg(test)]
253mod tests {
254    use std::cell::RefCell;
255
256    use super::*;
257    use crate::domain::{config::WorkspaceConfig, project::Project};
258
259    /// A registry with a fixed project list and workspace, recording any save.
260    struct FakeRegistry {
261        projects: Vec<Project>,
262        workspace: WorkspaceConfig,
263        saved: RefCell<Option<(PathBuf, WorkspaceConfig)>>,
264    }
265
266    impl ProjectRegistry for FakeRegistry {
267        fn projects(&self) -> Result<Vec<Project>, ConfigError> {
268            Ok(self.projects.clone())
269        }
270
271        fn workspace(&self, _config_path: &Path) -> Result<WorkspaceConfig, ConfigError> {
272            Ok(self.workspace.clone())
273        }
274
275        fn workspace_exists(&self, _config_path: &Path) -> bool {
276            false
277        }
278
279        fn save(&self, _projects: &[Project]) -> Result<(), ConfigError> {
280            Ok(())
281        }
282
283        fn save_workspace(
284            &self,
285            config_path: &Path,
286            config: &WorkspaceConfig,
287        ) -> Result<(), ConfigError> {
288            *self.saved.borrow_mut() = Some((config_path.to_path_buf(), config.clone()));
289            Ok(())
290        }
291    }
292
293    fn empty_workspace() -> WorkspaceConfig {
294        WorkspaceConfig::builder()
295            .agents(vec![])
296            .terminals(vec![])
297            .commands(vec![])
298            .build()
299    }
300
301    fn registry_with(projects: Vec<Project>) -> FakeRegistry {
302        FakeRegistry {
303            projects,
304            workspace: empty_workspace(),
305            saved: RefCell::new(None),
306        }
307    }
308
309    fn project(name: &str, config: &str) -> Project {
310        Project::builder()
311            .name(ProjectName::try_new(name).unwrap())
312            .config(PathBuf::from(config))
313            .build()
314    }
315
316    #[test]
317    fn resolves_a_named_project_to_its_config_path() {
318        let registry = registry_with(vec![
319            project("web", "~/web/muster.yml"),
320            project("api", "~/api/muster.yml"),
321        ]);
322        assert_eq!(
323            resolve(&registry, Some("api"), None, PathBuf::from("muster.yml")).unwrap(),
324            PathBuf::from("~/api/muster.yml")
325        );
326    }
327
328    #[test]
329    fn an_unknown_project_name_errors() {
330        let registry = registry_with(vec![project("web", "~/web/muster.yml")]);
331        assert!(matches!(
332            resolve(&registry, Some("nope"), None, PathBuf::from("muster.yml")),
333            Err(CliError::UnknownProject(_))
334        ));
335    }
336
337    #[test]
338    fn without_a_name_it_falls_back_to_environment_then_config() {
339        let registry = registry_with(vec![]);
340        assert_eq!(
341            resolve(
342                &registry,
343                None,
344                Some(PathBuf::from("/env/muster.yml")),
345                PathBuf::from("/cfg/muster.yml"),
346            )
347            .unwrap(),
348            PathBuf::from("/env/muster.yml"),
349            "the environment path wins over --config"
350        );
351        assert_eq!(
352            resolve(&registry, None, None, PathBuf::from("/cfg/muster.yml")).unwrap(),
353            PathBuf::from("/cfg/muster.yml"),
354            "with no name or environment, the --config path is the target"
355        );
356    }
357
358    #[test]
359    fn register_appends_the_command_to_its_section() {
360        let registry = registry_with(vec![]);
361        let spec = ProcessSpec::builder()
362            .name(ProcessName::try_new("web").unwrap())
363            .command(Some(CommandLine::try_new("npm run dev").unwrap()))
364            .build();
365
366        register(
367            &registry,
368            Path::new("/here/muster.yml"),
369            spec,
370            ProcessKind::Command,
371        )
372        .unwrap();
373
374        let saved = registry.saved.borrow();
375        let (path, config) = saved.as_ref().unwrap();
376        assert_eq!(path, Path::new("/here/muster.yml"));
377        assert_eq!(config.commands().len(), 1);
378        assert_eq!(config.commands()[0].name().as_ref(), "web");
379        assert!(
380            config.agents().is_empty() && config.terminals().is_empty(),
381            "only the command section grew"
382        );
383    }
384
385    #[test]
386    fn command_reconstruction_preserves_argument_boundaries() {
387        // A plain-space join would flatten these; escaping must round-trip back
388        // to the exact argument vector.
389        let argv = vec![
390            "printf".to_string(),
391            "%s\n".to_string(),
392            "hello world".to_string(),
393        ];
394        let rebuilt = command_string(&argv).unwrap();
395        assert_eq!(
396            shlex::split(&rebuilt).unwrap(),
397            argv,
398            "the escaped command re-splits into the original arguments"
399        );
400        assert_ne!(
401            rebuilt,
402            argv.join(" "),
403            "escaping actually changed something"
404        );
405    }
406
407    #[test]
408    fn a_single_argument_is_kept_as_a_shell_expression() {
409        // One quoted argument is a shell expression; it must reach `sh -c`
410        // unescaped so `&&`, pipes, and redirects still work.
411        let argv = vec!["npm test && npm run build".to_string()];
412        assert_eq!(
413            command_string(&argv).unwrap(),
414            "npm test && npm run build",
415            "a single argument passes through verbatim"
416        );
417    }
418
419    #[test]
420    fn the_process_name_defaults_to_the_first_word() {
421        let command = vec!["npm".to_string(), "run".to_string(), "dev".to_string()];
422        assert_eq!(
423            process_name(None, &command).unwrap().as_ref(),
424            "npm",
425            "no explicit name uses the command's first word"
426        );
427        assert_eq!(process_name(Some("web"), &command).unwrap().as_ref(), "web");
428        assert_eq!(
429            process_name(Some("   "), &command).unwrap().as_ref(),
430            "npm",
431            "a blank explicit name falls back to the first word"
432        );
433        assert_eq!(
434            process_name(None, &["npm test && npm run build".to_string()])
435                .unwrap()
436                .as_ref(),
437            "npm",
438            "a single quoted expression uses its first word, not the whole string"
439        );
440    }
441}