1use kiss_workflow::Script;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SavedWorkflow {
14 pub name: String,
15 pub description: String,
16 pub path: PathBuf,
17 pub from_project: bool,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SaveLocation {
24 Project,
26 Personal,
28}
29
30pub fn user_workflow_dir() -> Option<PathBuf> {
31 dirs::home_dir().map(|home| home.join(".kiss/agent/workflows"))
32}
33
34pub fn project_workflow_dir(cwd: &Path) -> PathBuf {
35 cwd.join(".kiss/workflows")
36}
37
38impl SaveLocation {
39 pub fn directory(self, cwd: &Path) -> Option<PathBuf> {
40 match self {
41 SaveLocation::Project => Some(project_workflow_dir(cwd)),
42 SaveLocation::Personal => user_workflow_dir(),
43 }
44 }
45}
46
47fn scan(dir: &Path, from_project: bool, out: &mut Vec<SavedWorkflow>) {
48 let Ok(entries) = std::fs::read_dir(dir) else {
49 return;
50 };
51 for entry in entries.flatten() {
52 let path = entry.path();
53 if path.extension().and_then(|extension| extension.to_str()) != Some("js") {
54 continue;
55 }
56 let Ok(text) = std::fs::read_to_string(&path) else {
57 continue;
58 };
59 let Ok(script) = Script::parse(&text) else {
63 continue;
64 };
65 out.push(SavedWorkflow {
66 name: script.meta().name.clone(),
67 description: script.meta().description.clone(),
68 path,
69 from_project,
70 });
71 }
72}
73
74pub fn discover(cwd: &Path, project_trusted: bool) -> Vec<SavedWorkflow> {
76 let mut out = Vec::new();
77 if project_trusted {
78 scan(&project_workflow_dir(cwd), true, &mut out);
79 }
80 if let Some(dir) = user_workflow_dir() {
81 scan(&dir, false, &mut out);
82 }
83 out.sort_by(|left, right| left.name.cmp(&right.name));
84 let mut seen = std::collections::HashSet::new();
85 out.retain(|workflow| seen.insert(workflow.name.clone()));
86 out
87}
88
89pub fn save(
96 location: SaveLocation,
97 cwd: &Path,
98 name: &str,
99 source: &str,
100 overwrite: bool,
101) -> anyhow::Result<PathBuf> {
102 let name = name.trim();
103 if name.is_empty() || !name.bytes().all(is_name_byte) {
104 anyhow::bail!(
105 "a workflow name uses lower-case letters, digits, and dashes, for example \
106 `audit-routes`"
107 );
108 }
109 let directory = location
110 .directory(cwd)
111 .ok_or_else(|| anyhow::anyhow!("no home directory"))?;
112
113 if location == SaveLocation::Project {
117 for ancestor in [directory.parent(), Some(directory.as_path())]
118 .into_iter()
119 .flatten()
120 {
121 refuse_symlink(ancestor)?;
122 }
123 }
124
125 std::fs::create_dir_all(&directory)?;
126 let path = directory.join(format!("{name}.js"));
127 refuse_symlink(&path)?;
128 if path.exists() && !overwrite {
129 anyhow::bail!("{} already exists", path.display());
130 }
131
132 let temporary = directory.join(format!(".{name}.js.tmp"));
133 std::fs::write(&temporary, source)?;
134 std::fs::rename(&temporary, &path)?;
135 Ok(path)
136}
137
138fn refuse_symlink(path: &Path) -> anyhow::Result<()> {
139 if std::fs::symlink_metadata(path).is_ok_and(|data| data.file_type().is_symlink()) {
140 anyhow::bail!(
141 "{} is a symbolic link. Saving there would write outside the location you chose",
142 path.display()
143 );
144 }
145 Ok(())
146}
147
148fn is_name_byte(byte: u8) -> bool {
149 byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 const SCRIPT: &str =
157 "export const meta = { name: 'audit-routes', description: 'Audit routes' }\nreturn 1\n";
158
159 #[test]
160 fn a_saved_workflow_can_be_found_again_by_name() {
161 let dir = tempfile::tempdir().unwrap();
162 let path = save(
163 SaveLocation::Project,
164 dir.path(),
165 "audit-routes",
166 SCRIPT,
167 false,
168 )
169 .unwrap();
170 assert!(path.ends_with("audit-routes.js"));
171
172 let found = discover(dir.path(), true);
173 assert_eq!(found.len(), 1);
174 assert_eq!(found[0].name, "audit-routes");
175 assert_eq!(found[0].description, "Audit routes");
176 assert!(found[0].from_project);
177 }
178
179 #[test]
180 fn an_untrusted_project_contributes_no_workflows() {
181 let dir = tempfile::tempdir().unwrap();
182 save(
183 SaveLocation::Project,
184 dir.path(),
185 "audit-routes",
186 SCRIPT,
187 false,
188 )
189 .unwrap();
190 assert!(
194 !discover(dir.path(), false)
195 .iter()
196 .any(|workflow| workflow.from_project)
197 );
198 }
199
200 #[test]
201 fn saving_twice_needs_permission_to_overwrite() {
202 let dir = tempfile::tempdir().unwrap();
203 save(SaveLocation::Project, dir.path(), "audit", SCRIPT, false).unwrap();
204 let error = save(SaveLocation::Project, dir.path(), "audit", SCRIPT, false).unwrap_err();
205 assert!(error.to_string().contains("already exists"));
206 assert!(save(SaveLocation::Project, dir.path(), "audit", SCRIPT, true).is_ok());
207 }
208
209 #[test]
210 fn a_bad_name_is_refused_with_an_example() {
211 let dir = tempfile::tempdir().unwrap();
212 for name in ["", "Audit Routes", "audit/routes", "../escape"] {
213 let error = save(SaveLocation::Project, dir.path(), name, SCRIPT, false)
214 .unwrap_err()
215 .to_string();
216 assert!(error.contains("audit-routes"), "{name}: {error}");
217 }
218 }
219
220 #[cfg(unix)]
221 #[test]
222 fn saving_refuses_to_write_through_a_symbolic_link() {
223 let dir = tempfile::tempdir().unwrap();
224 let elsewhere = tempfile::tempdir().unwrap();
225 std::fs::create_dir_all(dir.path().join(".kiss")).unwrap();
226 std::os::unix::fs::symlink(elsewhere.path(), dir.path().join(".kiss/workflows")).unwrap();
227
228 let error = save(SaveLocation::Project, dir.path(), "audit", SCRIPT, false)
229 .unwrap_err()
230 .to_string();
231 assert!(error.contains("symbolic link"), "{error}");
232 assert!(!elsewhere.path().join("audit.js").exists());
233 }
234
235 #[test]
236 fn a_file_that_does_not_parse_is_skipped_rather_than_breaking_discovery() {
237 let dir = tempfile::tempdir().unwrap();
238 let workflows = project_workflow_dir(dir.path());
239 std::fs::create_dir_all(&workflows).unwrap();
240 std::fs::write(workflows.join("broken.js"), "this is not a workflow").unwrap();
241 std::fs::write(workflows.join("good.js"), SCRIPT).unwrap();
242
243 let found: Vec<String> = discover(dir.path(), true)
244 .into_iter()
245 .filter(|workflow| workflow.from_project)
246 .map(|workflow| workflow.name)
247 .collect();
248 assert_eq!(found, ["audit-routes"]);
249 }
250
251 #[test]
252 fn the_name_comes_from_meta_not_from_the_file_name() {
253 let dir = tempfile::tempdir().unwrap();
254 let workflows = project_workflow_dir(dir.path());
255 std::fs::create_dir_all(&workflows).unwrap();
256 std::fs::write(workflows.join("whatever.js"), SCRIPT).unwrap();
257 let found = discover(dir.path(), true);
258 assert_eq!(found[0].name, "audit-routes");
259 }
260}