mod markdown;
mod openspec;
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use crate::config::PawConfig;
use crate::error::PawError;
use openspec::OpenSpecBackend;
#[derive(Debug)]
pub struct SpecEntry {
pub id: String,
pub branch: String,
pub cli: Option<String>,
pub prompt: String,
pub owned_files: Option<Vec<String>>,
}
pub trait SpecBackend: fmt::Debug {
fn scan(&self, dir: &Path) -> Result<Vec<SpecEntry>, PawError>;
}
use markdown::MarkdownBackend;
pub(crate) fn parse_frontmatter(content: &str) -> (Option<HashMap<String, String>>, &str) {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return (None, content);
}
let after_open = match trimmed.strip_prefix("---") {
Some(rest) => {
match rest.find('\n') {
Some(idx) => &rest[idx + 1..],
None => return (None, content),
}
}
None => return (None, content),
};
let close_pos = after_open
.lines()
.enumerate()
.find(|(_, line)| line.trim() == "---");
let (frontmatter_str, body) = match close_pos {
Some((line_idx, _)) => {
let byte_offset: usize = after_open.lines().take(line_idx).map(|l| l.len() + 1).sum();
let fm = &after_open[..byte_offset];
let after_close = &after_open[byte_offset..];
let body = match after_close.find('\n') {
Some(idx) => &after_close[idx + 1..],
None => "",
};
(fm, body)
}
None => return (None, content),
};
let mut fields = HashMap::new();
for line in frontmatter_str.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once(':') {
fields.insert(key.trim().to_string(), value.trim().to_string());
}
}
(Some(fields), body)
}
fn backend_for_type(spec_type: &str) -> Result<Box<dyn SpecBackend>, PawError> {
match spec_type {
"openspec" => Ok(Box::new(OpenSpecBackend)),
"markdown" => Ok(Box::new(MarkdownBackend)),
_ => Err(PawError::SpecError(format!(
"unknown spec type: {spec_type}"
))),
}
}
fn derive_branch(prefix: &str, id: &str) -> String {
if prefix.ends_with('/') {
format!("{prefix}{id}")
} else {
format!("{prefix}/{id}")
}
}
pub fn scan_specs(config: &PawConfig, repo_root: &Path) -> Result<Vec<SpecEntry>, PawError> {
let specs_config = config
.specs
.as_ref()
.ok_or_else(|| PawError::SpecError("no [specs] section in config".to_string()))?;
let dir = specs_config.dir.as_deref().unwrap_or("specs");
let specs_dir = repo_root.join(dir);
if !specs_dir.exists() {
return Err(PawError::SpecError(format!(
"specs directory does not exist: {}",
specs_dir.display()
)));
}
if !specs_dir.is_dir() {
return Err(PawError::SpecError(format!(
"specs path is not a directory: {}",
specs_dir.display()
)));
}
let spec_type = specs_config.spec_type.as_deref().unwrap_or("openspec");
let backend = backend_for_type(spec_type)?;
let branch_prefix = config.branch_prefix.as_deref().unwrap_or("spec/");
let mut entries = backend.scan(&specs_dir)?;
for entry in &mut entries {
entry.branch = derive_branch(branch_prefix, &entry.id);
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SpecsConfig;
use std::fs;
#[test]
fn spec_entry_all_fields() {
let entry = SpecEntry {
id: "add-auth".to_string(),
branch: "spec/add-auth".to_string(),
cli: Some("claude".to_string()),
prompt: "implement auth".to_string(),
owned_files: Some(vec!["src/auth.rs".to_string()]),
};
assert_eq!(entry.id, "add-auth");
assert_eq!(entry.branch, "spec/add-auth");
assert_eq!(entry.cli.as_deref(), Some("claude"));
assert_eq!(entry.prompt, "implement auth");
assert_eq!(entry.owned_files.as_ref().unwrap().len(), 1);
}
#[test]
fn spec_entry_optional_fields_absent() {
let entry = SpecEntry {
id: "fix-bug".to_string(),
branch: "spec/fix-bug".to_string(),
cli: None,
prompt: "fix the bug".to_string(),
owned_files: None,
};
assert!(entry.cli.is_none());
assert!(entry.owned_files.is_none());
}
#[test]
fn derive_branch_default_prefix() {
assert_eq!(derive_branch("spec/", "add-auth"), "spec/add-auth");
}
#[test]
fn derive_branch_custom_prefix_with_trailing_slash() {
assert_eq!(derive_branch("feat/", "login"), "feat/login");
}
#[test]
fn derive_branch_custom_prefix_without_trailing_slash() {
assert_eq!(derive_branch("feat", "login"), "feat/login");
}
#[test]
fn backend_for_type_openspec() {
assert!(backend_for_type("openspec").is_ok());
}
#[test]
fn backend_for_type_markdown() {
assert!(backend_for_type("markdown").is_ok());
}
#[test]
fn backend_for_type_unknown() {
let err = backend_for_type("unknown").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("unknown spec type"), "got: {msg}");
}
#[test]
fn scan_specs_no_specs_config() {
let config = PawConfig::default();
let tmp = tempfile::tempdir().unwrap();
let err = scan_specs(&config, tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("[specs]"), "got: {msg}");
}
#[test]
fn scan_specs_nonexistent_directory() {
let config = PawConfig {
specs: Some(SpecsConfig {
dir: Some("nonexistent".to_string()),
spec_type: Some("openspec".to_string()),
}),
..Default::default()
};
let tmp = tempfile::tempdir().unwrap();
let err = scan_specs(&config, tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("does not exist"), "got: {msg}");
assert!(msg.contains("nonexistent"), "got: {msg}");
}
#[test]
fn scan_specs_file_instead_of_directory() {
let tmp = tempfile::tempdir().unwrap();
let file_path = tmp.path().join("specs");
fs::write(&file_path, "not a directory").unwrap();
let config = PawConfig {
specs: Some(SpecsConfig {
dir: Some("specs".to_string()),
spec_type: Some("openspec".to_string()),
}),
..Default::default()
};
let err = scan_specs(&config, tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not a directory"), "got: {msg}");
}
#[test]
fn scan_specs_valid_config_stub_backend() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir(tmp.path().join("specs")).unwrap();
let config = PawConfig {
specs: Some(SpecsConfig {
dir: Some("specs".to_string()),
spec_type: Some("openspec".to_string()),
}),
..Default::default()
};
let entries = scan_specs(&config, tmp.path()).unwrap();
assert!(entries.is_empty());
}
}