mod markdown;
mod openspec;
pub mod resolve;
pub mod speckit;
use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use crate::config::PawConfig;
use crate::error::PawError;
use openspec::OpenSpecBackend;
use speckit::SpecKitBackend;
#[derive(Debug, Clone)]
pub struct SpecEntry {
pub id: String,
pub backend: SpecBackendKind,
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>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpecBackendKind {
OpenSpec,
Markdown,
SpecKit,
}
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)),
"speckit" => Ok(Box::new(SpecKitBackend)),
_ => 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}")
}
}
fn resolve_specs_config(
config: &PawConfig,
repo_root: &Path,
format_override: Option<&str>,
) -> Option<crate::config::SpecsConfig> {
if let Some(format) = format_override {
let mut base = config.specs.clone().unwrap_or_default();
base.spec_type = Some(format.to_string());
if base.dir.is_none() && format == "speckit" {
base.dir = Some(".specify/specs".to_string());
}
return Some(base);
}
if config.specs.is_some() {
return config.specs.clone();
}
let specify = repo_root.join(".specify");
if specify.is_dir() && specify.join("specs").is_dir() {
return Some(crate::config::SpecsConfig {
dir: Some(".specify/specs".to_string()),
spec_type: Some("speckit".to_string()),
});
}
None
}
#[must_use]
pub fn resolved_spec_type(config: &PawConfig, repo_root: &Path) -> Option<String> {
resolve_specs_config(config, repo_root, None)
.map(|c| c.spec_type.unwrap_or_else(|| "openspec".to_string()))
}
pub fn scan_specs(config: &PawConfig, repo_root: &Path) -> Result<Vec<SpecEntry>, PawError> {
scan_specs_with_override(config, repo_root, None)
}
pub fn scan_specs_with_override(
config: &PawConfig,
repo_root: &Path,
format_override: Option<&str>,
) -> Result<Vec<SpecEntry>, PawError> {
let specs_config = resolve_specs_config(config, repo_root, format_override)
.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 {
if entry.branch.is_empty() {
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(),
backend: SpecBackendKind::OpenSpec,
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.backend, SpecBackendKind::OpenSpec);
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(),
backend: SpecBackendKind::Markdown,
branch: "spec/fix-bug".to_string(),
cli: None,
prompt: "fix the bug".to_string(),
owned_files: None,
};
assert_eq!(entry.backend, SpecBackendKind::Markdown);
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_speckit() {
assert!(backend_for_type("speckit").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());
}
#[test]
fn auto_detect_specify_activates_speckit() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
let config = PawConfig::default();
let entries = scan_specs(&config, tmp.path()).unwrap();
assert!(entries.is_empty());
}
#[test]
fn auto_detect_skipped_when_specs_section_present() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
fs::create_dir(tmp.path().join("my-specs")).unwrap();
let config = PawConfig {
specs: Some(SpecsConfig {
dir: Some("my-specs".to_string()),
spec_type: Some("markdown".to_string()),
}),
..Default::default()
};
let resolved = resolve_specs_config(&config, tmp.path(), None).unwrap();
assert_eq!(resolved.spec_type.as_deref(), Some("markdown"));
assert_eq!(resolved.dir.as_deref(), Some("my-specs"));
}
#[test]
fn auto_detect_skipped_when_no_specify_dir() {
let tmp = tempfile::tempdir().unwrap();
let config = PawConfig::default();
assert!(resolve_specs_config(&config, tmp.path(), None).is_none());
}
#[test]
fn auto_detect_skipped_when_specify_missing_specs_subdir() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join(".specify").join("memory")).unwrap();
let config = PawConfig::default();
assert!(resolve_specs_config(&config, tmp.path(), None).is_none());
}
#[test]
fn explicit_config_wins_over_auto_detection() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
let md_dir = tmp.path().join("specs");
fs::create_dir(&md_dir).unwrap();
let config = PawConfig {
specs: Some(SpecsConfig {
dir: Some("specs".to_string()),
spec_type: Some("markdown".to_string()),
}),
..Default::default()
};
let resolved = resolve_specs_config(&config, tmp.path(), None)
.expect("explicit config should resolve");
assert_eq!(
resolved.spec_type.as_deref(),
Some("markdown"),
"explicit type = markdown must win over the auto-detected speckit"
);
assert_eq!(
resolved.dir.as_deref(),
Some("specs"),
"explicit dir = specs must win over the auto-detected .specify/specs"
);
let entries = scan_specs(&config, tmp.path()).unwrap();
assert!(
entries.is_empty(),
"empty markdown specs dir should produce no entries; got: {entries:?}"
);
}
#[test]
fn format_override_wins_over_specs_config_and_auto_detection() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
let config = PawConfig {
specs: Some(SpecsConfig {
dir: Some("my-specs".to_string()),
spec_type: Some("markdown".to_string()),
}),
..Default::default()
};
let resolved = resolve_specs_config(&config, tmp.path(), Some("openspec")).unwrap();
assert_eq!(resolved.spec_type.as_deref(), Some("openspec"));
assert_eq!(resolved.dir.as_deref(), Some("my-specs"));
}
#[test]
fn format_override_speckit_supplies_default_dir() {
let tmp = tempfile::tempdir().unwrap();
let config = PawConfig::default();
let resolved = resolve_specs_config(&config, tmp.path(), Some("speckit")).unwrap();
assert_eq!(resolved.spec_type.as_deref(), Some("speckit"));
assert_eq!(resolved.dir.as_deref(), Some(".specify/specs"));
}
#[test]
fn scan_specs_with_override_routes_to_speckit() {
let tmp = tempfile::tempdir().unwrap();
let specify = tmp.path().join(".specify").join("specs");
let feat = specify.join("001-feature");
fs::create_dir_all(&feat).unwrap();
fs::write(
feat.join("tasks.md"),
"## Phase 1: Setup\n- [ ] T001 do thing\n",
)
.unwrap();
let config = PawConfig::default();
let entries = scan_specs_with_override(&config, tmp.path(), Some("speckit")).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].branch.starts_with("phase/"),
"got branch: {}",
entries[0].branch
);
}
#[test]
fn scan_specs_openspec_still_gets_branch_prefix() {
let tmp = tempfile::tempdir().unwrap();
let specs_dir = tmp.path().join("specs");
let change = specs_dir.join("add-auth");
fs::create_dir_all(&change).unwrap();
fs::write(change.join("tasks.md"), "implement auth").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_eq!(entries.len(), 1);
assert_eq!(entries[0].branch, "spec/add-auth");
}
}