Skip to main content

cli/task/
mod.rs

1//! `shine task` — a lightweight personal shortcut-command registry.
2//!
3//! Tasks are user runtime state (stored in `<shine_dir>/tasks.toml`, see
4//! [`manifest`]), not embedded presets. Commands are saved as an argv array and
5//! executed directly with no shell (`std::process::Command`), inheriting the
6//! caller's stdio and environment. A task may store an optional fixed working
7//! directory; otherwise it runs in the caller's current directory. Users who
8//! need shell syntax (pipes, redirects, globbing) save an explicit
9//! `sh -c '...'` invocation.
10//!
11//! `shine run <name>` is a top-level alias for `shine task run <name>`; it has
12//! no independent semantics or storage.
13//!
14//! Platform note: direct execution runs any real executable on every platform,
15//! but the `sh -c '...'` escape hatch is Unix-only (Windows has no `sh`).
16
17pub 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    // Announce on stderr so a task's own stdout stays clean for piping.
76    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
138/// Execute a saved task's argv directly, inheriting stdio and environment, and
139/// propagate the child's exit code verbatim (never wrapping it in an anyhow
140/// error) so the task's own exit semantics survive `shine` in between.
141fn 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
233/// Render an argv array back into a copy-paste-safe shell command line by
234/// single-quoting any argument that contains characters a shell would interpret.
235fn 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        // list/info should not error with a saved task present.
347        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        // `true` exits 0 regardless of extra args, so run_task_command returns Ok
526        // without calling process::exit.
527        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}