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#[cfg(unix)]
20const SHELL_PROGRAM: &str = "/bin/sh";
21#[cfg(unix)]
23const SHELL_FLAG: &str = "-c";
24
25#[derive(clap::Args)]
31pub struct RunArgs {
32 #[arg(short, long, add = ArgValueCandidates::new(project_candidates))]
35 project: Option<String>,
36 #[arg(short, long)]
38 name: Option<String>,
39 #[arg(short, long, value_enum, default_value_t = ProcessKindArg::Command)]
41 kind: ProcessKindArg,
42 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
44 command: Vec<String>,
45}
46
47#[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#[derive(Debug, Error)]
67pub enum CliError {
68 #[error("unknown project '{0}'")]
70 UnknownProject(String),
71 #[error("'{0}' is not a valid process name")]
73 InvalidName(String),
74 #[error("the command is empty")]
76 EmptyCommand,
77 #[error("the command cannot be represented as a shell command")]
79 InvalidCommand,
80 #[error("muster run is only supported on Unix")]
82 Unsupported,
83 #[error(transparent)]
85 Config(#[from] ConfigError),
86 #[error("failed to run the command: {0}")]
88 Exec(#[source] std::io::Error),
89}
90
91pub fn run(args: RunArgs, config: PathBuf, registry: &dyn ProjectRegistry) -> Result<(), CliError> {
99 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 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 Err(exec(command_line.as_ref()))
120}
121
122fn 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
137fn 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
152fn 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
164fn 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
175fn 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
182fn 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 .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
199fn 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#[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#[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 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(®istry, 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(®istry, 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 ®istry,
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(®istry, 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 ®istry,
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 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 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}