1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
27pub enum TemplateKind {
28 Ticket,
29 Flow,
30 Project,
31 Config,
32}
33
34impl TemplateKind {
35 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 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 #[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 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 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 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 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 #[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 assert_eq!(config.flows["default"].stages.len(), 6);
249 }
250}