Skip to main content

sloop/
templates.rs

1//! Compiled-in authoring templates for the files operators write by hand.
2//!
3//! Users install Sloop as a bare binary, so nothing in this repository —
4//! including `docs/` — is reachable from an installed `sloop`. These
5//! templates are the grammar documentation that ships with the binary:
6//! `sloop template <kind>` prints one to stdout, and the intended use is
7//! redirection (`sloop template ticket > .agents/sloop/tickets/mine.md`).
8//! Writing into `.agents/sloop/` directly is deliberately not offered: the
9//! ticket directory is a live queue, so an example file dropped there is an
10//! example file at risk of being posted.
11//!
12//! Every template is a working example annotated with comments, and the
13//! tests below round-trip each one through the same parser the daemon uses.
14//! A grammar change that these templates do not follow fails the build
15//! rather than shipping documentation that lies.
16
17use clap::ValueEnum;
18
19const TICKET: &str = include_str!("templates/ticket.md");
20const FLOW: &str = include_str!("templates/flow.yaml");
21const PROJECT: &str = include_str!("templates/project.md");
22const CONFIG: &str = include_str!("templates/config.yaml");
23
24/// The file kinds `sloop template` can print. clap renders the variants as
25/// the accepted values, so an unknown kind fails with the valid list.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
27pub enum TemplateKind {
28    Ticket,
29    Flow,
30    Project,
31    Config,
32}
33
34impl TemplateKind {
35    /// The name the operator typed, used as the envelope's `kind`.
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::Ticket => "ticket",
39            Self::Flow => "flow",
40            Self::Project => "project",
41            Self::Config => "config",
42        }
43    }
44
45    /// The template text, verbatim and newline-terminated.
46    pub fn text(self) -> &'static str {
47        match self {
48            Self::Ticket => TICKET,
49            Self::Flow => FLOW,
50            Self::Project => PROJECT,
51            Self::Config => CONFIG,
52        }
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use std::fs;
59
60    use tempfile::tempdir;
61
62    use super::{CONFIG, FLOW, PROJECT, TICKET, TemplateKind};
63
64    #[test]
65    fn every_kind_prints_a_commented_template() {
66        for kind in [
67            TemplateKind::Ticket,
68            TemplateKind::Flow,
69            TemplateKind::Project,
70            TemplateKind::Config,
71        ] {
72            let text = kind.text();
73            assert!(!text.trim().is_empty(), "{} is empty", kind.as_str());
74            assert!(
75                text.ends_with('\n'),
76                "{} lacks a final newline",
77                kind.as_str()
78            );
79            assert!(
80                text.lines().any(|line| line.trim_start().starts_with('#')),
81                "{} carries no commentary",
82                kind.as_str()
83            );
84        }
85    }
86
87    /// The ticket template must survive the exact validation `sloop post`
88    /// applies, not merely the frontmatter parser: required fields and a
89    /// non-empty body are part of the grammar it documents.
90    #[test]
91    fn the_ticket_template_posts_cleanly() {
92        let frontmatter =
93            crate::post::parse_ticket_frontmatter(TICKET, "template.md").expect("ticket template");
94
95        assert_eq!(frontmatter.name, "Add request logging");
96        assert!(frontmatter.has_blocked_by());
97        assert!(frontmatter.blocked_by.is_empty());
98        // Stamped fields stay commented out, so `sloop post` allocates them.
99        assert_eq!(frontmatter.id, None);
100        assert_eq!(frontmatter.project, None);
101        assert_eq!(frontmatter.worktree, None);
102        assert!(
103            !crate::frontmatter::body(TICKET)
104                .expect("ticket body")
105                .trim()
106                .is_empty()
107        );
108    }
109
110    #[test]
111    fn the_flow_template_parses_through_the_flow_loader() {
112        let flow = crate::flow::parse("example", FLOW).expect("flow template");
113
114        let names: Vec<&str> = flow
115            .stages
116            .iter()
117            .map(|stage| stage.name.as_str())
118            .collect();
119        assert_eq!(names, ["build", "test", "lint", "review", "merge"]);
120
121        // One example of every stage kind and every verdict policy, which is
122        // the whole point of this template.
123        assert!(
124            flow.stages
125                .iter()
126                .any(|stage| stage.kind == crate::flow::StageKind::Agent)
127        );
128        assert!(
129            flow.stages
130                .iter()
131                .any(|stage| stage.kind == crate::flow::StageKind::Merge)
132        );
133        assert!(
134            flow.stages
135                .iter()
136                .any(|stage| matches!(stage.kind, crate::flow::StageKind::Exec { .. }))
137        );
138        for policy in [
139            crate::flow::VerdictPolicy::Commits,
140            crate::flow::VerdictPolicy::Exit,
141            crate::flow::VerdictPolicy::Reported,
142        ] {
143            assert!(
144                flow.stages.iter().any(|stage| stage.verdict == policy),
145                "no stage demonstrates {policy:?}"
146            );
147        }
148        assert!(
149            flow.stages
150                .iter()
151                .any(|stage| matches!(stage.verdict, crate::flow::VerdictPolicy::Check { .. })),
152            "no stage demonstrates a check verdict"
153        );
154
155        // `on_fail` is shown on both stage kinds that accept it.
156        let repaired: Vec<&str> = flow
157            .stages
158            .iter()
159            .filter(|stage| stage.on_fail.is_some())
160            .map(|stage| stage.name.as_str())
161            .collect();
162        assert_eq!(repaired, ["test", "merge"]);
163    }
164
165    #[test]
166    fn the_project_template_parses_through_the_frontmatter_path() {
167        let frontmatter = crate::frontmatter::parse(PROJECT).expect("project template");
168
169        assert_eq!(frontmatter.id.as_deref(), Some("web"));
170        assert_eq!(frontmatter.title.as_deref(), Some("Web frontend"));
171        assert!(
172            !crate::frontmatter::body(PROJECT)
173                .expect("project body")
174                .trim()
175                .is_empty()
176        );
177    }
178
179    #[test]
180    fn the_config_template_loads_through_the_config_loader() {
181        let root = tempdir().unwrap();
182        fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
183        fs::write(root.path().join(".agents/sloop/config.yaml"), CONFIG).unwrap();
184
185        let repository = crate::config::Repository::discover(root.path()).unwrap();
186        let config = crate::config::Config::load(&repository).unwrap();
187
188        let agent = config.agent.expect("template configures an agent");
189        assert_eq!(agent.default_target, "claude");
190        assert_eq!(
191            agent.targets.keys().map(String::as_str).collect::<Vec<_>>(),
192            ["claude", "codex", "opencode"]
193        );
194        assert_eq!(config.worktree_retention_ms, Some(7 * 24 * 60 * 60 * 1000));
195        assert_eq!(config.max_parallel_tasks, 1);
196        assert_eq!(config.ticket_prefix, "TICK");
197    }
198
199    /// The flow and config templates are meant to be dropped into the same
200    /// repository, so the stage `test` the flow declares must not collide
201    /// with an `aftercare.test_cmd` the config template leaves enabled.
202    #[test]
203    fn the_flow_and_config_templates_coexist() {
204        let root = tempdir().unwrap();
205        fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
206        fs::write(root.path().join(".agents/sloop/config.yaml"), CONFIG).unwrap();
207        fs::write(root.path().join(".agents/sloop/flows/default.yaml"), FLOW).unwrap();
208
209        let repository = crate::config::Repository::discover(root.path()).unwrap();
210        let config = crate::config::Config::load(&repository).unwrap();
211
212        // Loading also validates every `on_fail.target` against the config's
213        // agent targets, which a flow template naming a target must satisfy.
214        assert_eq!(config.flows["default"].stages.len(), 5);
215    }
216}