1use anyhow::{bail, Context, Result};
2use serde::Deserialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Deserialize)]
8pub struct AgentSpec {
9 pub name: Option<String>,
11 pub goal: String,
13 pub context: Option<String>,
15 #[serde(default)]
17 pub tasks: Vec<String>,
18 #[serde(default)]
20 pub deliverables: Vec<String>,
21 #[serde(default)]
23 pub constraints: Vec<String>,
24 #[serde(skip)]
26 source: Option<PathBuf>,
27}
28
29impl AgentSpec {
30 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
32 let path = path.as_ref();
33 if !path.exists() {
34 bail!("spec file '{}' was not found", path.display());
35 }
36 if !Self::is_spec_extension(path) {
37 bail!(
38 "spec files must use the `.spec` extension (got '{}')",
39 path.display()
40 );
41 }
42
43 let raw = fs::read_to_string(path)
44 .with_context(|| format!("failed reading spec file '{}'", path.display()))?;
45 let mut spec = Self::from_str(&raw)?;
46 spec.source = Some(path.to_path_buf());
47 Ok(spec)
48 }
49
50 pub fn from_str(contents: &str) -> Result<Self> {
52 let spec: AgentSpec = toml::from_str(contents).context("failed to parse spec TOML")?;
53 spec.validate()?;
54 Ok(spec)
55 }
56
57 pub fn to_prompt(&self) -> String {
59 let mut sections = Vec::new();
60 if let Some(name) = &self.name {
61 if !name.trim().is_empty() {
62 sections.push(format!("Spec Name: {}", name.trim()));
63 }
64 }
65 sections.push(format!("Primary Goal:\n{}", self.goal.trim()));
66
67 if let Some(ctx) = self.context_text() {
68 sections.push(format!("Context:\n{}", ctx));
69 }
70
71 if let Some(tasks) = self.formatted_list("Tasks", &self.tasks, true) {
72 sections.push(tasks);
73 }
74 if let Some(deliverables) = self.formatted_list("Deliverables", &self.deliverables, true) {
75 sections.push(deliverables);
76 }
77 if let Some(constraints) = self.formatted_list("Constraints", &self.constraints, false) {
78 sections.push(constraints);
79 }
80
81 let mut prompt = String::from(
82 "You have been provided with a structured execution spec from the user.\n\
83 Follow every goal, task, and deliverable precisely. Reference section names when responding.\n\n",
84 );
85 prompt.push_str(§ions.join("\n\n"));
86 prompt.push_str(
87 "\n\nWhen complete, explicitly explain how each deliverable was satisfied and call out any blockers.",
88 );
89 prompt
90 }
91
92 pub fn preview(&self) -> String {
94 let mut preview = Vec::new();
95 if let Some(name) = &self.name {
96 if !name.trim().is_empty() {
97 preview.push(format!("Name: {}", name.trim()));
98 }
99 }
100 preview.push(format!("Goal: {}", self.goal.trim()));
101 if let Some(ctx) = self.context_preview(2) {
102 preview.push(format!("Context: {}", ctx));
103 }
104 if let Some(tasks) = self.preview_list("Tasks", &self.tasks) {
105 preview.push(tasks);
106 }
107 if let Some(deliverables) = self.preview_list("Deliverables", &self.deliverables) {
108 preview.push(deliverables);
109 }
110 if let Some(constraints) = self.preview_list("Constraints", &self.constraints) {
111 preview.push(constraints);
112 }
113 preview.join("\n")
114 }
115
116 pub fn display_name(&self) -> &str {
118 if let Some(name) = &self.name {
119 let trimmed = name.trim();
120 if !trimmed.is_empty() {
121 return trimmed;
122 }
123 }
124 self.goal.trim()
125 }
126
127 pub fn source_path(&self) -> Option<&Path> {
129 self.source.as_deref()
130 }
131
132 fn context_text(&self) -> Option<String> {
133 self.context
134 .as_ref()
135 .map(|ctx| ctx.trim())
136 .filter(|ctx| !ctx.is_empty())
137 .map(|ctx| ctx.to_string())
138 }
139
140 fn formatted_list(&self, label: &str, items: &[String], number_items: bool) -> Option<String> {
141 let normalized = Self::normalized_items(items);
142 if normalized.is_empty() {
143 return None;
144 }
145
146 let formatted = normalized
147 .iter()
148 .enumerate()
149 .map(|(idx, item)| {
150 if number_items {
151 format!("{}. {}", idx + 1, item)
152 } else {
153 format!("- {}", item)
154 }
155 })
156 .collect::<Vec<_>>()
157 .join("\n");
158 Some(format!("{}:\n{}", label, formatted))
159 }
160
161 fn preview_list(&self, label: &str, items: &[String]) -> Option<String> {
162 let normalized = Self::normalized_items(items);
163 if normalized.is_empty() {
164 return None;
165 }
166
167 let mut lines = normalized
168 .iter()
169 .take(3)
170 .enumerate()
171 .map(|(idx, item)| format!(" {}. {}", idx + 1, item))
172 .collect::<Vec<_>>();
173
174 if normalized.len() > 3 {
175 lines.push(format!(" ... ({} more)", normalized.len() - 3));
176 }
177
178 Some(format!("{}:\n{}", label, lines.join("\n")))
179 }
180
181 fn context_preview(&self, max_lines: usize) -> Option<String> {
182 self.context_text().map(|ctx| {
183 let lines: Vec<&str> = ctx
184 .lines()
185 .map(str::trim)
186 .filter(|l| !l.is_empty())
187 .collect();
188 if lines.is_empty() {
189 return ctx;
190 }
191
192 lines
193 .into_iter()
194 .take(max_lines)
195 .collect::<Vec<_>>()
196 .join(" / ")
197 })
198 }
199
200 fn normalized_items(items: &[String]) -> Vec<String> {
201 items
202 .iter()
203 .map(|item| item.trim())
204 .filter(|item| !item.is_empty())
205 .map(|item| item.to_string())
206 .collect()
207 }
208
209 fn validate(&self) -> Result<()> {
210 if self.goal.trim().is_empty() {
211 bail!("spec goal must be provided");
212 }
213
214 let has_tasks = !Self::normalized_items(&self.tasks).is_empty();
215 let has_deliverables = !Self::normalized_items(&self.deliverables).is_empty();
216 if !has_tasks && !has_deliverables {
217 bail!("spec must include at least one task or deliverable");
218 }
219
220 Ok(())
221 }
222
223 fn is_spec_extension(path: &Path) -> bool {
224 path.extension()
225 .and_then(|ext| ext.to_str())
226 .map(|ext| ext.eq_ignore_ascii_case("spec"))
227 .unwrap_or(false)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn parses_valid_spec_and_generates_prompt() {
237 let contents = r#"
238name = "Docs refresh"
239goal = "Update README to mention the new CLI command"
240context = "Ensure we mention the spec workflow."
241
242tasks = [
243 "Document the new command",
244 "Provide an example spec file"
245]
246
247deliverables = [
248 "README update summary"
249]
250 "#;
251
252 let spec = AgentSpec::from_str(contents).expect("spec should parse");
253 assert_eq!(spec.display_name(), "Docs refresh");
254 assert!(spec.preview().contains("Goal: Update README"));
255
256 let prompt = spec.to_prompt();
257 assert!(prompt.contains("Primary Goal"));
258 assert!(prompt.contains("Tasks"));
259 assert!(prompt.contains("Deliverables"));
260 }
261
262 #[test]
263 fn rejects_spec_without_goal() {
264 let contents = r#"
265tasks = ["Do the thing"]
266 "#;
267 let err = AgentSpec::from_str(contents).unwrap_err();
268 let message = format!("{:?}", err);
269 assert!(message.contains("goal"));
270 }
271
272 #[test]
273 fn rejects_spec_without_tasks_or_deliverables() {
274 let contents = r#"
275goal = "Just saying hi"
276 "#;
277 let err = AgentSpec::from_str(contents).unwrap_err();
278 assert!(format!("{}", err).contains("task"));
279 }
280}