Skip to main content

kiss_coding/workflows/
store.rs

1//! Saved workflows on disk.
2//!
3//! A saved workflow is a `.js` file whose `meta.name` becomes a slash command,
4//! following the same discovery rules as prompt templates in
5//! `crate::prompts`: the user's own directory always, and the project's only
6//! when the project is trusted.
7
8use kiss_workflow::Script;
9use std::path::{Path, PathBuf};
10
11/// A workflow found on disk.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SavedWorkflow {
14    pub name: String,
15    pub description: String,
16    pub path: PathBuf,
17    /// True for a workflow in the project rather than the user's home.
18    pub from_project: bool,
19}
20
21/// Where a saved workflow can be written.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SaveLocation {
24    /// `.kiss/workflows/` in the project, shared with everyone who clones it.
25    Project,
26    /// `~/.kiss/agent/workflows/`, available in every project.
27    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        // A file that does not parse is skipped rather than reported: a broken
60        // file in the directory must not stop the others from loading, and the
61        // error surfaces when the user runs it.
62        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
74/// Find every saved workflow, with the project's copy winning on a name clash.
75pub 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
89/// Write a workflow script, refusing to follow a symbolic link.
90///
91/// Writing through a link would put the file somewhere the user did not choose,
92/// so each part of the path is checked first. The write goes through a
93/// temporary file in the same directory and is then renamed, so an interrupted
94/// save cannot leave a truncated script behind.
95pub 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    // The project location has two directories of its own to check. The
114    // personal one is often managed by a dotfiles tool, so only the file itself
115    // is checked there.
116    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        // The user's own directory may hold workflows on this machine, so the
191        // check is that the project's file is absent rather than that nothing
192        // was found.
193        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}