1pub mod manifest;
18
19use anyhow::{Context, Result, bail};
20use std::path::{Path, PathBuf};
21
22use crate::config::Config;
23use crate::{colors, path_display};
24use manifest::TaskManifest;
25
26const NOT_FOUND_HINT: &str = "Run `shine task list` to see saved tasks.";
27
28pub async fn handle_save(
29 config: &Config,
30 name: &str,
31 force: bool,
32 cwd: Option<&Path>,
33 command: Vec<String>,
34) -> Result<()> {
35 validate_task_name(name)?;
36 if command.is_empty() {
37 bail!(
38 "No command provided.\n\nUsage:\n shine task save <name> [--cwd <dir>] -- <command...>"
39 );
40 }
41
42 let mut manifest = TaskManifest::load(config.shine_dir()).await?;
43 if !force && manifest.get(name).is_some() {
44 bail!("Task already exists: {name}\n\nUse `--force` to replace it.");
45 }
46
47 let cwd = resolve_task_cwd(config, cwd)?;
48 let rendered = render_command(&command);
49 manifest.upsert(name, command, cwd.clone());
50 manifest.save(config.shine_dir()).await?;
51
52 println!("{}", colors::green(&format!("Saved task {name}")));
53 println!("{rendered}");
54 if let Some(cwd) = cwd {
55 println!(
56 "Working dir: {}",
57 path_display::format_home(&cwd, &config.home_dir)
58 );
59 }
60 Ok(())
61}
62
63pub async fn handle_run(config: &Config, name: &str, extra: &[String]) -> Result<()> {
64 let manifest = TaskManifest::load(config.shine_dir()).await?;
65 let Some(entry) = manifest.get(name) else {
66 bail!("Task not found: {name}\n\n{NOT_FOUND_HINT}");
67 };
68
69 let mut argv = entry.command.clone();
70 argv.extend_from_slice(extra);
71 if let Some(cwd) = entry.cwd.as_deref() {
72 validate_run_cwd(name, cwd)?;
73 }
74
75 let cwd_note = entry.cwd.as_deref().map_or_else(String::new, |cwd| {
77 format!(
78 " (cwd: {})",
79 path_display::format_home(cwd, &config.home_dir)
80 )
81 });
82 eprintln!(
83 "{}{cwd_note}: {}",
84 colors::bold(&format!("Running {name}")),
85 render_command(&argv)
86 );
87
88 run_task_command(name, &argv, entry.cwd.as_deref())
89}
90
91pub async fn handle_list(config: &Config) -> Result<()> {
92 let manifest = TaskManifest::load(config.shine_dir()).await?;
93 if manifest.tasks.is_empty() {
94 println!("No saved tasks yet. Run `shine task save <name> -- <command...>`.");
95 return Ok(());
96 }
97
98 println!("{}", colors::bold("Saved Tasks"));
99 let width = manifest.tasks.keys().map(|k| k.len()).max().unwrap_or(0);
100 for (name, entry) in &manifest.tasks {
101 println!(
102 "{name:<width$} {}",
103 render_command(&entry.command),
104 width = width
105 );
106 }
107 Ok(())
108}
109
110pub async fn handle_info(config: &Config, name: &str) -> Result<()> {
111 let manifest = TaskManifest::load(config.shine_dir()).await?;
112 let Some(entry) = manifest.get(name) else {
113 bail!("Task not found: {name}\n\n{NOT_FOUND_HINT}");
114 };
115
116 println!("{:<10} {}", "Task", name);
117 println!("{:<10} {}", "Command", render_command(&entry.command));
118 if let Some(cwd) = entry.cwd.as_deref() {
119 println!(
120 "{:<10} {}",
121 "Working dir",
122 path_display::format_home(cwd, &config.home_dir)
123 );
124 }
125 Ok(())
126}
127
128pub async fn handle_delete(config: &Config, name: &str) -> Result<()> {
129 let mut manifest = TaskManifest::load(config.shine_dir()).await?;
130 if !manifest.remove(name) {
131 bail!("Task not found: {name}\n\n{NOT_FOUND_HINT}");
132 }
133 manifest.save(config.shine_dir()).await?;
134 println!("{}", colors::green(&format!("Deleted task {name}")));
135 Ok(())
136}
137
138fn run_task_command(name: &str, argv: &[String], cwd: Option<&Path>) -> Result<()> {
142 let Some((program, args)) = argv.split_first() else {
143 bail!("Task {name} has no command to run.");
144 };
145
146 let mut command = std::process::Command::new(program);
147 command.args(args);
148 if let Some(cwd) = cwd {
149 command.current_dir(cwd);
150 }
151
152 let status = match command.status() {
153 Ok(status) => status,
154 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
155 if let Some(cwd) = cwd
156 && !cwd.is_dir()
157 {
158 bail!(
159 "Failed to run task {name}: working directory is unavailable: {}",
160 path_display::format(cwd)
161 );
162 }
163 bail!("Failed to run task {name}: command not found: {program}");
164 }
165 Err(e) => bail!("Failed to run task {name}: {program}: {e}"),
166 };
167
168 if status.success() {
169 return Ok(());
170 }
171 if let Some(code) = status.code() {
172 std::process::exit(code);
173 }
174 #[cfg(unix)]
175 {
176 use std::os::unix::process::ExitStatusExt;
177 std::process::exit(128 + status.signal().unwrap_or(1));
178 }
179 #[cfg(not(unix))]
180 std::process::exit(1);
181}
182
183fn resolve_task_cwd(config: &Config, cwd: Option<&Path>) -> Result<Option<PathBuf>> {
184 let Some(cwd) = cwd else {
185 return Ok(None);
186 };
187
188 let raw = cwd.to_string_lossy();
189 let home = config.home_dir.to_string_lossy().into_owned();
190 let expanded = shellexpand::tilde_with_context(&raw, || Some(home)).into_owned();
191 let expanded = PathBuf::from(expanded);
192 let absolute = if expanded.is_absolute() {
193 expanded
194 } else {
195 std::env::current_dir()
196 .context("resolving current directory for task cwd")?
197 .join(expanded)
198 };
199 let canonical = match std::fs::canonicalize(&absolute) {
200 Ok(canonical) => canonical,
201 Err(error) if error.kind() == std::io::ErrorKind::NotFound => bail!(
202 "Failed to save task: working directory does not exist: {}",
203 path_display::format(&absolute)
204 ),
205 Err(error) => bail!(
206 "Failed to save task: cannot resolve working directory: {}: {error}",
207 path_display::format(&absolute)
208 ),
209 };
210 if !canonical.is_dir() {
211 bail!(
212 "Failed to save task: working directory is not a directory: {}",
213 path_display::format(&canonical)
214 );
215 }
216 Ok(Some(canonical))
217}
218
219fn validate_run_cwd(name: &str, cwd: &Path) -> Result<()> {
220 match std::fs::metadata(cwd) {
221 Ok(metadata) if metadata.is_dir() => Ok(()),
222 Ok(_) => bail!(
223 "Failed to run task {name}: working directory is not a directory: {}",
224 path_display::format(cwd)
225 ),
226 Err(error) => bail!(
227 "Failed to run task {name}: working directory is unavailable: {}: {error}",
228 path_display::format(cwd)
229 ),
230 }
231}
232
233fn render_command(argv: &[String]) -> String {
236 argv.iter()
237 .map(|arg| shell_quote(arg))
238 .collect::<Vec<_>>()
239 .join(" ")
240}
241
242fn shell_quote(arg: &str) -> String {
243 crate::shell_quote::quote_if_needed(arg)
244}
245
246fn validate_task_name(name: &str) -> Result<()> {
247 let invalid = || {
248 anyhow::anyhow!(
249 "Invalid task name: {name}\n\nTask names may contain letters, numbers, dots, dashes, and underscores, and must start with a letter or number."
250 )
251 };
252
253 let Some(first) = name.chars().next() else {
254 return Err(invalid());
255 };
256 if !first.is_ascii_alphanumeric() {
257 return Err(invalid());
258 }
259 if !name
260 .chars()
261 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
262 {
263 return Err(invalid());
264 }
265 Ok(())
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn config_in(dir: &std::path::Path) -> Config {
273 crate::test_support::test_config(dir)
274 }
275
276 async fn temp_dir() -> std::path::PathBuf {
277 crate::test_support::make_temp_dir("shine-task-test").await
278 }
279
280 #[test]
281 fn validate_task_name_accepts_allowed_charset() {
282 assert!(validate_task_name("deploy-keystone").is_ok());
283 assert!(validate_task_name("a.b-c_1").is_ok());
284 assert!(validate_task_name("3000").is_ok());
285 }
286
287 #[test]
288 fn validate_task_name_rejects_spaces_and_bad_start() {
289 assert!(validate_task_name("deploy docs").is_err());
290 assert!(validate_task_name(".hidden").is_err());
291 assert!(validate_task_name("-lead").is_err());
292 assert!(validate_task_name("").is_err());
293 }
294
295 #[test]
296 fn render_command_leaves_plain_argv_unquoted() {
297 let argv = [
298 "rsync".to_string(),
299 "-avz".to_string(),
300 "dist/".to_string(),
301 "marqueeio.develop:/var/www/keystone/alex/".to_string(),
302 ];
303 assert_eq!(
304 render_command(&argv),
305 "rsync -avz dist/ marqueeio.develop:/var/www/keystone/alex/"
306 );
307 }
308
309 #[test]
310 fn render_command_quotes_shell_syntax_arguments() {
311 let argv = [
312 "sh".to_string(),
313 "-c".to_string(),
314 "lsof -ti :3000 | xargs kill".to_string(),
315 ];
316 assert_eq!(render_command(&argv), "sh -c 'lsof -ti :3000 | xargs kill'");
317 }
318
319 #[test]
320 fn shell_quote_escapes_embedded_single_quotes() {
321 assert_eq!(shell_quote("it's"), "'it'\\''s'");
322 assert_eq!(shell_quote(""), "''");
323 }
324
325 #[tokio::test]
326 async fn save_then_info_and_list_show_command() {
327 let dir = temp_dir().await;
328 let config = config_in(&dir);
329
330 handle_save(
331 &config,
332 "port-3000",
333 false,
334 None,
335 vec!["lsof".to_string(), "-i".to_string(), ":3000".to_string()],
336 )
337 .await
338 .unwrap();
339
340 let manifest = TaskManifest::load(config.shine_dir()).await.unwrap();
341 assert_eq!(
342 manifest.get("port-3000").unwrap().command,
343 ["lsof", "-i", ":3000"]
344 );
345
346 handle_list(&config).await.unwrap();
348 handle_info(&config, "port-3000").await.unwrap();
349
350 tokio::fs::remove_dir_all(&dir).await.unwrap();
351 }
352
353 #[tokio::test]
354 async fn save_resolves_and_persists_fixed_cwd() {
355 let dir = temp_dir().await;
356 let config = config_in(&dir);
357 let project = config.home_dir.join("project");
358 tokio::fs::create_dir_all(&project).await.unwrap();
359
360 handle_save(
361 &config,
362 "build",
363 false,
364 Some(Path::new("~/project")),
365 vec!["cargo".to_string(), "build".to_string()],
366 )
367 .await
368 .unwrap();
369
370 let manifest = TaskManifest::load(config.shine_dir()).await.unwrap();
371 assert_eq!(
372 manifest.get("build").unwrap().cwd.as_ref(),
373 Some(&std::fs::canonicalize(&project).unwrap())
374 );
375 handle_info(&config, "build").await.unwrap();
376 tokio::fs::remove_dir_all(&dir).await.unwrap();
377 }
378
379 #[tokio::test]
380 async fn save_resolves_relative_cwd_from_current_directory() {
381 let dir = temp_dir().await;
382 let config = config_in(&dir);
383 let expected = std::fs::canonicalize(".").unwrap();
384
385 handle_save(
386 &config,
387 "here",
388 false,
389 Some(Path::new(".")),
390 vec!["echo".to_string()],
391 )
392 .await
393 .unwrap();
394
395 let manifest = TaskManifest::load(config.shine_dir()).await.unwrap();
396 assert_eq!(manifest.get("here").unwrap().cwd.as_ref(), Some(&expected));
397 tokio::fs::remove_dir_all(&dir).await.unwrap();
398 }
399
400 #[tokio::test]
401 async fn save_rejects_missing_or_non_directory_cwd() {
402 let dir = temp_dir().await;
403 let config = config_in(&dir);
404 let file = dir.join("not-a-directory");
405 tokio::fs::write(&file, "x").await.unwrap();
406
407 let missing = handle_save(
408 &config,
409 "missing-cwd",
410 false,
411 Some(&dir.join("missing")),
412 vec!["echo".to_string()],
413 )
414 .await
415 .unwrap_err();
416 assert!(missing.to_string().contains("does not exist"));
417
418 let not_dir = handle_save(
419 &config,
420 "file-cwd",
421 false,
422 Some(&file),
423 vec!["echo".to_string()],
424 )
425 .await
426 .unwrap_err();
427 assert!(not_dir.to_string().contains("not a directory"));
428 tokio::fs::remove_dir_all(&dir).await.unwrap();
429 }
430
431 #[tokio::test]
432 async fn save_rejects_duplicate_without_force_and_overwrites_with_force() {
433 let dir = temp_dir().await;
434 let config = config_in(&dir);
435
436 handle_save(
437 &config,
438 "t",
439 false,
440 Some(&dir),
441 vec!["echo".to_string(), "one".to_string()],
442 )
443 .await
444 .unwrap();
445
446 let err = handle_save(
447 &config,
448 "t",
449 false,
450 None,
451 vec!["echo".to_string(), "two".to_string()],
452 )
453 .await
454 .unwrap_err();
455 assert!(err.to_string().contains("Task already exists"));
456
457 handle_save(
458 &config,
459 "t",
460 true,
461 None,
462 vec!["echo".to_string(), "two".to_string()],
463 )
464 .await
465 .unwrap();
466 let manifest = TaskManifest::load(config.shine_dir()).await.unwrap();
467 assert_eq!(manifest.get("t").unwrap().command, ["echo", "two"]);
468 assert_eq!(manifest.get("t").unwrap().cwd, None);
469
470 tokio::fs::remove_dir_all(&dir).await.unwrap();
471 }
472
473 #[tokio::test]
474 async fn save_rejects_empty_command_and_invalid_name() {
475 let dir = temp_dir().await;
476 let config = config_in(&dir);
477
478 let err = handle_save(&config, "t", false, None, vec![])
479 .await
480 .unwrap_err();
481 assert!(err.to_string().contains("No command provided"));
482
483 let err = handle_save(&config, "bad name", false, None, vec!["echo".to_string()])
484 .await
485 .unwrap_err();
486 assert!(err.to_string().contains("Invalid task name"));
487
488 tokio::fs::remove_dir_all(&dir).await.unwrap();
489 }
490
491 #[tokio::test]
492 async fn delete_removes_task_and_errors_when_missing() {
493 let dir = temp_dir().await;
494 let config = config_in(&dir);
495
496 handle_save(&config, "t", false, None, vec!["echo".to_string()])
497 .await
498 .unwrap();
499 handle_delete(&config, "t").await.unwrap();
500
501 let err = handle_delete(&config, "t").await.unwrap_err();
502 assert!(err.to_string().contains("Task not found"));
503
504 tokio::fs::remove_dir_all(&dir).await.unwrap();
505 }
506
507 #[tokio::test]
508 async fn run_missing_task_reports_clear_error() {
509 let dir = temp_dir().await;
510 let config = config_in(&dir);
511
512 let err = handle_run(&config, "nope", &[]).await.unwrap_err();
513 assert!(err.to_string().contains("Task not found: nope"));
514 assert!(err.to_string().contains("shine task list"));
515
516 tokio::fs::remove_dir_all(&dir).await.unwrap();
517 }
518
519 #[cfg(unix)]
520 #[tokio::test]
521 async fn run_executes_saved_command_and_appends_extra() {
522 let dir = temp_dir().await;
523 let config = config_in(&dir);
524
525 handle_save(&config, "ok", false, None, vec!["true".to_string()])
528 .await
529 .unwrap();
530 handle_run(&config, "ok", &["ignored".to_string()])
531 .await
532 .unwrap();
533
534 tokio::fs::remove_dir_all(&dir).await.unwrap();
535 }
536
537 #[cfg(unix)]
538 #[tokio::test]
539 async fn run_executes_in_saved_working_directory() {
540 let dir = temp_dir().await;
541 let config = config_in(&dir);
542 let project = dir.join("project");
543 tokio::fs::create_dir_all(&project).await.unwrap();
544
545 handle_save(
546 &config,
547 "mark",
548 false,
549 Some(&project),
550 vec!["touch".to_string(), "ran-here".to_string()],
551 )
552 .await
553 .unwrap();
554 handle_run(&config, "mark", &[]).await.unwrap();
555
556 assert!(project.join("ran-here").exists());
557 assert!(!dir.join("ran-here").exists());
558 tokio::fs::remove_dir_all(&dir).await.unwrap();
559 }
560
561 #[tokio::test]
562 async fn run_reports_saved_working_directory_that_disappeared() {
563 let dir = temp_dir().await;
564 let config = config_in(&dir);
565 let project = dir.join("project");
566 tokio::fs::create_dir_all(&project).await.unwrap();
567
568 handle_save(
569 &config,
570 "gone",
571 false,
572 Some(&project),
573 vec!["echo".to_string()],
574 )
575 .await
576 .unwrap();
577 tokio::fs::remove_dir(&project).await.unwrap();
578
579 let error = handle_run(&config, "gone", &[]).await.unwrap_err();
580 assert!(
581 error
582 .to_string()
583 .contains("working directory is unavailable")
584 );
585 tokio::fs::remove_dir_all(&dir).await.unwrap();
586 }
587
588 #[cfg(unix)]
589 #[tokio::test]
590 async fn run_reports_command_not_found() {
591 let dir = temp_dir().await;
592 let config = config_in(&dir);
593
594 handle_save(
595 &config,
596 "missing-bin",
597 false,
598 None,
599 vec!["shine-no-such-binary-xyz".to_string()],
600 )
601 .await
602 .unwrap();
603 let err = handle_run(&config, "missing-bin", &[]).await.unwrap_err();
604 assert!(err.to_string().contains("command not found"));
605
606 tokio::fs::remove_dir_all(&dir).await.unwrap();
607 }
608}