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", "merge"]);
120
121 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 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 #[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 assert_eq!(config.flows["default"].stages.len(), 5);
215 }
216}