use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct AgentSpec {
pub name: Option<String>,
pub goal: String,
pub context: Option<String>,
#[serde(default)]
pub tasks: Vec<String>,
#[serde(default)]
pub deliverables: Vec<String>,
#[serde(default)]
pub constraints: Vec<String>,
#[serde(skip)]
source: Option<PathBuf>,
}
impl AgentSpec {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
bail!("spec file '{}' was not found", path.display());
}
if !Self::is_spec_extension(path) {
bail!(
"spec files must use the `.spec` extension (got '{}')",
path.display()
);
}
let raw = fs::read_to_string(path)
.with_context(|| format!("failed reading spec file '{}'", path.display()))?;
let mut spec = Self::from_str(&raw)?;
spec.source = Some(path.to_path_buf());
Ok(spec)
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(contents: &str) -> Result<Self> {
let spec: AgentSpec = toml::from_str(contents).context("failed to parse spec TOML")?;
spec.validate()?;
Ok(spec)
}
pub fn to_prompt(&self) -> String {
let mut sections = Vec::new();
if let Some(name) = &self.name {
if !name.trim().is_empty() {
sections.push(format!("Spec Name: {}", name.trim()));
}
}
sections.push(format!("Primary Goal:\n{}", self.goal.trim()));
if let Some(ctx) = self.context_text() {
sections.push(format!("Context:\n{}", ctx));
}
if let Some(tasks) = self.formatted_list("Tasks", &self.tasks, true) {
sections.push(tasks);
}
if let Some(deliverables) = self.formatted_list("Deliverables", &self.deliverables, true) {
sections.push(deliverables);
}
if let Some(constraints) = self.formatted_list("Constraints", &self.constraints, false) {
sections.push(constraints);
}
let mut prompt = String::from(
"You have been provided with a structured execution spec from the user.\n\
Follow every goal, task, and deliverable precisely. Reference section names when responding.\n\n",
);
prompt.push_str(§ions.join("\n\n"));
prompt.push_str(
"\n\nWhen complete, explicitly explain how each deliverable was satisfied and call out any blockers.",
);
prompt
}
pub fn preview(&self) -> String {
let mut preview = Vec::new();
if let Some(name) = &self.name {
if !name.trim().is_empty() {
preview.push(format!("Name: {}", name.trim()));
}
}
preview.push(format!("Goal: {}", self.goal.trim()));
if let Some(ctx) = self.context_preview(2) {
preview.push(format!("Context: {}", ctx));
}
if let Some(tasks) = self.preview_list("Tasks", &self.tasks) {
preview.push(tasks);
}
if let Some(deliverables) = self.preview_list("Deliverables", &self.deliverables) {
preview.push(deliverables);
}
if let Some(constraints) = self.preview_list("Constraints", &self.constraints) {
preview.push(constraints);
}
preview.join("\n")
}
pub fn display_name(&self) -> &str {
if let Some(name) = &self.name {
let trimmed = name.trim();
if !trimmed.is_empty() {
return trimmed;
}
}
self.goal.trim()
}
pub fn source_path(&self) -> Option<&Path> {
self.source.as_deref()
}
fn context_text(&self) -> Option<String> {
self.context
.as_ref()
.map(|ctx| ctx.trim())
.filter(|ctx| !ctx.is_empty())
.map(|ctx| ctx.to_string())
}
fn formatted_list(&self, label: &str, items: &[String], number_items: bool) -> Option<String> {
let normalized = Self::normalized_items(items);
if normalized.is_empty() {
return None;
}
let formatted = normalized
.iter()
.enumerate()
.map(|(idx, item)| {
if number_items {
format!("{}. {}", idx + 1, item)
} else {
format!("- {}", item)
}
})
.collect::<Vec<_>>()
.join("\n");
Some(format!("{}:\n{}", label, formatted))
}
fn preview_list(&self, label: &str, items: &[String]) -> Option<String> {
let normalized = Self::normalized_items(items);
if normalized.is_empty() {
return None;
}
let mut lines = normalized
.iter()
.take(3)
.enumerate()
.map(|(idx, item)| format!(" {}. {}", idx + 1, item))
.collect::<Vec<_>>();
if normalized.len() > 3 {
lines.push(format!(" ... ({} more)", normalized.len() - 3));
}
Some(format!("{}:\n{}", label, lines.join("\n")))
}
fn context_preview(&self, max_lines: usize) -> Option<String> {
self.context_text().map(|ctx| {
let lines: Vec<&str> = ctx
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
return ctx;
}
lines
.into_iter()
.take(max_lines)
.collect::<Vec<_>>()
.join(" / ")
})
}
fn normalized_items(items: &[String]) -> Vec<String> {
items
.iter()
.map(|item| item.trim())
.filter(|item| !item.is_empty())
.map(|item| item.to_string())
.collect()
}
fn validate(&self) -> Result<()> {
if self.goal.trim().is_empty() {
bail!("spec goal must be provided");
}
let has_tasks = !Self::normalized_items(&self.tasks).is_empty();
let has_deliverables = !Self::normalized_items(&self.deliverables).is_empty();
if !has_tasks && !has_deliverables {
bail!("spec must include at least one task or deliverable");
}
Ok(())
}
fn is_spec_extension(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("spec"))
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_spec_and_generates_prompt() {
let contents = r#"
name = "Docs refresh"
goal = "Update README to mention the new CLI command"
context = "Ensure we mention the spec workflow."
tasks = [
"Document the new command",
"Provide an example spec file"
]
deliverables = [
"README update summary"
]
"#;
let spec = AgentSpec::from_str(contents).expect("spec should parse");
assert_eq!(spec.display_name(), "Docs refresh");
assert!(spec.preview().contains("Goal: Update README"));
let prompt = spec.to_prompt();
assert!(prompt.contains("Primary Goal"));
assert!(prompt.contains("Tasks"));
assert!(prompt.contains("Deliverables"));
}
#[test]
fn rejects_spec_without_goal() {
let contents = r#"
tasks = ["Do the thing"]
"#;
let err = AgentSpec::from_str(contents).unwrap_err();
let message = format!("{:?}", err);
assert!(message.contains("goal"));
}
#[test]
fn rejects_spec_without_tasks_or_deliverables() {
let contents = r#"
goal = "Just saying hi"
"#;
let err = AgentSpec::from_str(contents).unwrap_err();
assert!(format!("{}", err).contains("task"));
}
}