1use 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 #[serde(default)]
17 pub tasks: BTreeMap<String, TaskEntry>,
18}
19
20#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
21pub struct TaskEntry {
22 pub command: Vec<String>,
26 #[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}