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#[cfg(unix)]
27const SHELL_PROGRAM: &str = "/bin/sh";
28#[cfg(unix)]
30const SHELL_FLAG: &str = "-c";
31
32#[derive(clap::Args)]
38pub struct RunArgs {
39 #[arg(short, long, add = ArgValueCandidates::new(project_candidates))]
42 project: Option<String>,
43 #[arg(short, long)]
45 name: Option<String>,
46 #[arg(short, long, value_enum, default_value_t = ProcessKindArg::Command)]
48 kind: ProcessKindArg,
49 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
51 command: Vec<String>,
52}
53
54#[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#[derive(Debug, Error)]
74pub enum CliError {
75 #[error("unknown project '{0}'")]
77 UnknownProject(String),
78 #[error("'{0}' is not a valid process name")]
80 InvalidName(String),
81 #[error("the command is empty")]
83 EmptyCommand,
84 #[error("the command cannot be represented as a shell command")]
86 InvalidCommand,
87 #[error("muster run is only supported on Unix")]
89 Unsupported,
90 #[error(transparent)]
92 Config(#[from] ConfigError),
93 #[error("failed to run the command: {0}")]
95 Exec(#[source] std::io::Error),
96}
97
98pub fn run(args: RunArgs, config: PathBuf, registry: &dyn ProjectRegistry) -> Result<(), CliError> {
106 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 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 Err(exec(command_line.as_ref()))
132}
133
134pub 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
159fn 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
168fn 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
183fn 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
198fn 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
210fn 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
221pub 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
228fn 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 .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
245fn 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#[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#[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 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(®istry, 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(®istry, 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 ®istry,
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(®istry, 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 #[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 ®istry,
416 )
417 .unwrap(),
418 absolutize(Path::new("explicit.yml"))
419 );
420 assert_eq!(
421 resolve_tui_config(None, Some(Path::new("current.yml")), local, ®istry).unwrap(),
422 absolutize(Path::new("current.yml"))
423 );
424 }
425
426 #[cfg(unix)]
427 #[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"), ®istry).unwrap();
443
444 assert_eq!(resolved, link);
445 fs::remove_dir_all(dir).unwrap();
446 }
447
448 #[cfg(unix)]
449 #[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, ®istry).unwrap();
462
463 assert_eq!(resolved, local);
464 fs::remove_dir_all(dir).unwrap();
465 }
466
467 #[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, ®istry).unwrap(),
476 local
477 );
478 }
479
480 #[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"), ®istry).unwrap(),
493 first
494 );
495 }
496
497 #[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"), ®istry).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 #[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, ®istry).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 ®istry,
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 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 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}