Skip to main content

cli/task/
manifest.rs

1//! Persisted registry of personal shortcut tasks (`<shine_dir>/tasks.toml`),
2//! keyed by task name. Read/written by the `shine task` handlers. Machine-managed
3//! runtime state (not an embedded preset), so it follows `SHINE_CONFIG_DIR` via
4//! `Config::shine_dir()` and does not preserve user-authored comments.
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11pub(crate) const TASKS_FILE: &str = "tasks.toml";
12
13#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
14pub struct TaskManifest {
15    /// `BTreeMap` keeps `tasks.toml` and `shine task list` in stable name order.
16    #[serde(default)]
17    pub tasks: BTreeMap<String, TaskEntry>,
18}
19
20#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
21pub struct TaskEntry {
22    /// Command and arguments stored as an argv array, preserving argument
23    /// boundaries. Executed directly (no shell) unless the user saved an
24    /// explicit `sh -c ...` invocation.
25    pub command: Vec<String>,
26    /// Optional fixed working directory. Missing for legacy and dynamic-cwd
27    /// tasks, which continue to run in the caller's current directory.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub cwd: Option<PathBuf>,
30}
31
32impl TaskManifest {
33    pub async fn load(shine_dir: &Path) -> Result<Self> {
34        crate::persist::load_toml_or_default(&shine_dir.join(TASKS_FILE), "tasks.toml").await
35    }
36
37    pub async fn save(&self, shine_dir: &Path) -> Result<()> {
38        crate::persist::save_toml_atomic(self, &shine_dir.join(TASKS_FILE), "tasks.toml").await
39    }
40
41    pub fn get(&self, name: &str) -> Option<&TaskEntry> {
42        self.tasks.get(name)
43    }
44
45    pub fn upsert(&mut self, name: impl Into<String>, command: Vec<String>, cwd: Option<PathBuf>) {
46        self.tasks.insert(name.into(), TaskEntry { command, cwd });
47    }
48
49    pub fn remove(&mut self, name: &str) -> bool {
50        self.tasks.remove(name).is_some()
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    async fn temp_dir() -> std::path::PathBuf {
59        crate::test_support::make_temp_dir("shine-task-manifest").await
60    }
61
62    #[tokio::test]
63    async fn load_returns_default_when_file_missing() {
64        let dir = temp_dir().await;
65        let manifest = TaskManifest::load(&dir).await.unwrap();
66        assert!(manifest.tasks.is_empty());
67        tokio::fs::remove_dir_all(&dir).await.unwrap();
68    }
69
70    #[tokio::test]
71    async fn save_then_load_roundtrips_argv_boundaries() {
72        let dir = temp_dir().await;
73        let mut manifest = TaskManifest::default();
74        manifest.upsert(
75            "kill-port",
76            vec![
77                "sh".to_string(),
78                "-c".to_string(),
79                "lsof -ti :3000 | xargs kill".to_string(),
80            ],
81            None,
82        );
83        manifest.save(&dir).await.unwrap();
84
85        let reloaded = TaskManifest::load(&dir).await.unwrap();
86        assert_eq!(manifest, reloaded);
87        assert_eq!(
88            reloaded.get("kill-port").unwrap().command,
89            ["sh", "-c", "lsof -ti :3000 | xargs kill"]
90        );
91        tokio::fs::remove_dir_all(&dir).await.unwrap();
92    }
93
94    #[test]
95    fn legacy_entry_without_cwd_deserializes_as_dynamic() {
96        let manifest: TaskManifest =
97            toml::from_str("[tasks.build]\ncommand = [\"cargo\", \"build\"]\n").unwrap();
98        assert_eq!(manifest.get("build").unwrap().cwd, None);
99    }
100
101    #[tokio::test]
102    async fn save_then_load_roundtrips_fixed_cwd() {
103        let dir = temp_dir().await;
104        let cwd = dir.join("project");
105        let mut manifest = TaskManifest::default();
106        manifest.upsert(
107            "build",
108            vec!["cargo".to_string(), "build".to_string()],
109            Some(cwd.clone()),
110        );
111        manifest.save(&dir).await.unwrap();
112
113        let reloaded = TaskManifest::load(&dir).await.unwrap();
114        assert_eq!(reloaded.get("build").unwrap().cwd.as_ref(), Some(&cwd));
115        tokio::fs::remove_dir_all(&dir).await.unwrap();
116    }
117
118    #[tokio::test]
119    async fn upsert_replaces_and_remove_reports_presence() {
120        let dir = temp_dir().await;
121        let mut manifest = TaskManifest::default();
122        manifest.upsert("t", vec!["echo".to_string(), "one".to_string()], None);
123        manifest.upsert("t", vec!["echo".to_string(), "two".to_string()], None);
124        assert_eq!(manifest.get("t").unwrap().command, ["echo", "two"]);
125        assert!(manifest.remove("t"));
126        assert!(!manifest.remove("t"));
127        tokio::fs::remove_dir_all(&dir).await.unwrap();
128    }
129}