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", "sync", "merge"]);
120
121        // One example of every action and every result check, which is the
122        // whole point of this template.
123        use crate::flow::{Actor, Builtin, Check};
124        assert!(flow.stages.iter().any(|stage| stage.action == Actor::Agent));
125        assert!(
126            flow.stages
127                .iter()
128                .any(|stage| stage.action == Actor::Builtin(Builtin::Merge))
129        );
130        assert!(
131            flow.stages
132                .iter()
133                .any(|stage| stage.action == Actor::Builtin(Builtin::Sync))
134        );
135        assert!(
136            flow.stages
137                .iter()
138                .any(|stage| matches!(stage.action, Actor::Exec { .. }))
139        );
140        for check in [
141            Check::Actor(Actor::Builtin(Builtin::Commits)),
142            Check::None,
143            Check::Reported,
144        ] {
145            assert!(
146                flow.stages.iter().any(|stage| stage.result_check == check),
147                "no stage demonstrates {check:?}"
148            );
149        }
150        assert!(
151            flow.stages
152                .iter()
153                .any(|stage| matches!(stage.result_check, Check::Actor(Actor::Exec { .. }))),
154            "no stage demonstrates an exec result check"
155        );
156
157        // Both non-default fail actions are demonstrated, not merely described
158        // in a comment: a template that only mentions a feature cannot go
159        // stale loudly. Exactly one of each, because the point of the example
160        // is the grammar, not a plausible pipeline.
161        use crate::flow::FailAction;
162        let advisory: Vec<&str> = flow
163            .stages
164            .iter()
165            .filter(|stage| stage.fail_action == FailAction::Continue)
166            .map(|stage| stage.name.as_str())
167            .collect();
168        assert_eq!(advisory, ["lint"]);
169        let returning: Vec<&str> = flow
170            .stages
171            .iter()
172            .filter(|stage| matches!(stage.fail_action, FailAction::ReturnTo { .. }))
173            .map(|stage| stage.name.as_str())
174            .collect();
175        assert_eq!(returning, ["test"]);
176
177        // Every stage writes all three parts. `fail_action` is the only one
178        // the parser defaults, so it is the only one whose presence has to be
179        // asserted against the source text rather than the parsed flow.
180        for stage in &flow.stages {
181            assert!(
182                FLOW.contains(&format!("name: {}", stage.name)),
183                "stage `{}` is not named in the template text",
184                stage.name
185            );
186        }
187        assert_eq!(
188            FLOW.lines()
189                .filter(|line| line.trim_start().starts_with("fail_action:"))
190                .count(),
191            flow.stages.len(),
192            "every stage must write its own `fail_action`"
193        );
194    }
195
196    #[test]
197    fn the_project_template_parses_through_the_frontmatter_path() {
198        let frontmatter = crate::frontmatter::parse(PROJECT).expect("project template");
199
200        assert_eq!(frontmatter.id.as_deref(), Some("web"));
201        assert_eq!(frontmatter.title.as_deref(), Some("Web frontend"));
202        assert!(
203            !crate::frontmatter::body(PROJECT)
204                .expect("project body")
205                .trim()
206                .is_empty()
207        );
208    }
209
210    #[test]
211    fn the_config_template_loads_through_the_config_loader() {
212        let root = tempdir().unwrap();
213        fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
214        fs::write(root.path().join(".agents/sloop/config.yaml"), CONFIG).unwrap();
215
216        let repository = crate::config::Repository::discover(root.path()).unwrap();
217        let config = crate::config::Config::load(&repository).unwrap();
218
219        let agent = config.agent.expect("template configures an agent");
220        assert_eq!(agent.default_target, "claude");
221        assert_eq!(
222            agent.targets.keys().map(String::as_str).collect::<Vec<_>>(),
223            ["claude", "codex", "opencode"]
224        );
225        assert_eq!(config.worktree_retention_ms, Some(7 * 24 * 60 * 60 * 1000));
226        assert_eq!(config.max_parallel_tasks, 1);
227        assert_eq!(config.stall_report_after_ms, 10 * 60 * 1000);
228        assert_eq!(config.stall_after_ms, 2 * 60 * 60 * 1000);
229        assert_eq!(config.ticket_prefix, "TICK");
230    }
231
232    /// The flow and config templates are meant to be dropped into the same
233    /// repository, so the stage `test` the flow declares must not collide
234    /// with a `flow.test_cmd` the config template leaves enabled.
235    #[test]
236    fn the_flow_and_config_templates_coexist() {
237        let root = tempdir().unwrap();
238        fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
239        fs::write(root.path().join(".agents/sloop/config.yaml"), CONFIG).unwrap();
240        fs::write(root.path().join(".agents/sloop/flows/default.yaml"), FLOW).unwrap();
241
242        let repository = crate::config::Repository::discover(root.path()).unwrap();
243        let config = crate::config::Config::load(&repository).unwrap();
244
245        // Loading also validates every panel seat's target against the
246        // config's agent targets, which a flow template seating one must
247        // satisfy.
248        assert_eq!(config.flows["default"].stages.len(), 6);
249    }
250}