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#[cfg(unix)]
28const SHELL_PROGRAM: &str = "/bin/sh";
29#[cfg(unix)]
31const SHELL_FLAG: &str = "-c";
32
33#[derive(clap::Args)]
39pub struct RunArgs {
40 #[arg(short, long, add = ArgValueCandidates::new(project_candidates))]
43 project: Option<String>,
44 #[arg(short, long)]
46 name: Option<String>,
47 #[arg(short, long, value_enum, default_value_t = ProcessKindArg::Command)]
49 kind: ProcessKindArg,
50 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
52 command: Vec<String>,
53}
54
55#[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
73pub fn run(args: RunArgs, config: PathBuf, registry: &dyn ProjectRegistry) -> Result<(), CliError> {
81 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 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 Err(exec(command_line.as_ref()))
107}
108
109pub 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
134fn 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
143fn 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
158fn 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
173fn 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 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
202fn 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
213pub 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
220fn 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 .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
237fn 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#[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#[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 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(®istry, 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(®istry, 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(®istry, 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 ®istry,
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(®istry, 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 #[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 ®istry,
423 )
424 .unwrap(),
425 absolutize(Path::new("explicit.yml"))
426 );
427 assert_eq!(
428 resolve_tui_config(None, Some(Path::new("current.yml")), local, ®istry).unwrap(),
429 absolutize(Path::new("current.yml"))
430 );
431 }
432
433 #[cfg(unix)]
434 #[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"), ®istry).unwrap();
450
451 assert_eq!(resolved, link);
452 fs::remove_dir_all(dir).unwrap();
453 }
454
455 #[cfg(unix)]
456 #[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, ®istry).unwrap();
469
470 assert_eq!(resolved, local);
471 fs::remove_dir_all(dir).unwrap();
472 }
473
474 #[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, ®istry).unwrap(),
483 local
484 );
485 }
486
487 #[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"), ®istry).unwrap(),
500 first
501 );
502 }
503
504 #[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"), ®istry).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 #[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, ®istry).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 ®istry,
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 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 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}