1mod markdown;
8mod openspec;
9
10use std::collections::HashMap;
11use std::fmt;
12use std::path::Path;
13
14use crate::config::PawConfig;
15use crate::error::PawError;
16use openspec::OpenSpecBackend;
17
18#[derive(Debug)]
23pub struct SpecEntry {
24 pub id: String,
26 pub branch: String,
28 pub cli: Option<String>,
30 pub prompt: String,
32 pub owned_files: Option<Vec<String>>,
34}
35
36pub trait SpecBackend: fmt::Debug {
41 fn scan(&self, dir: &Path) -> Result<Vec<SpecEntry>, PawError>;
43}
44
45use markdown::MarkdownBackend;
46
47pub(crate) fn parse_frontmatter(content: &str) -> (Option<HashMap<String, String>>, &str) {
51 let trimmed = content.trim_start();
52 if !trimmed.starts_with("---") {
53 return (None, content);
54 }
55
56 let after_open = match trimmed.strip_prefix("---") {
58 Some(rest) => {
59 match rest.find('\n') {
61 Some(idx) => &rest[idx + 1..],
62 None => return (None, content),
63 }
64 }
65 None => return (None, content),
66 };
67
68 let close_pos = after_open
70 .lines()
71 .enumerate()
72 .find(|(_, line)| line.trim() == "---");
73
74 let (frontmatter_str, body) = match close_pos {
75 Some((line_idx, _)) => {
76 let byte_offset: usize = after_open.lines().take(line_idx).map(|l| l.len() + 1).sum();
77 let fm = &after_open[..byte_offset];
78 let after_close = &after_open[byte_offset..];
79 let body = match after_close.find('\n') {
81 Some(idx) => &after_close[idx + 1..],
82 None => "",
83 };
84 (fm, body)
85 }
86 None => return (None, content),
87 };
88
89 let mut fields = HashMap::new();
90 for line in frontmatter_str.lines() {
91 let line = line.trim();
92 if line.is_empty() {
93 continue;
94 }
95 if let Some((key, value)) = line.split_once(':') {
96 fields.insert(key.trim().to_string(), value.trim().to_string());
97 }
98 }
99
100 (Some(fields), body)
101}
102
103fn backend_for_type(spec_type: &str) -> Result<Box<dyn SpecBackend>, PawError> {
105 match spec_type {
106 "openspec" => Ok(Box::new(OpenSpecBackend)),
107 "markdown" => Ok(Box::new(MarkdownBackend)),
108 _ => Err(PawError::SpecError(format!(
109 "unknown spec type: {spec_type}"
110 ))),
111 }
112}
113
114fn derive_branch(prefix: &str, id: &str) -> String {
118 if prefix.ends_with('/') {
119 format!("{prefix}{id}")
120 } else {
121 format!("{prefix}/{id}")
122 }
123}
124
125pub fn scan_specs(config: &PawConfig, repo_root: &Path) -> Result<Vec<SpecEntry>, PawError> {
135 let specs_config = config
136 .specs
137 .as_ref()
138 .ok_or_else(|| PawError::SpecError("no [specs] section in config".to_string()))?;
139
140 let dir = specs_config.dir.as_deref().unwrap_or("specs");
141 let specs_dir = repo_root.join(dir);
142
143 if !specs_dir.exists() {
144 return Err(PawError::SpecError(format!(
145 "specs directory does not exist: {}",
146 specs_dir.display()
147 )));
148 }
149 if !specs_dir.is_dir() {
150 return Err(PawError::SpecError(format!(
151 "specs path is not a directory: {}",
152 specs_dir.display()
153 )));
154 }
155
156 let spec_type = specs_config.spec_type.as_deref().unwrap_or("openspec");
157 let backend = backend_for_type(spec_type)?;
158
159 let branch_prefix = config.branch_prefix.as_deref().unwrap_or("spec/");
160 let mut entries = backend.scan(&specs_dir)?;
161
162 for entry in &mut entries {
163 entry.branch = derive_branch(branch_prefix, &entry.id);
164 }
165
166 Ok(entries)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::config::SpecsConfig;
173 use std::fs;
174
175 #[test]
176 fn spec_entry_all_fields() {
177 let entry = SpecEntry {
178 id: "add-auth".to_string(),
179 branch: "spec/add-auth".to_string(),
180 cli: Some("claude".to_string()),
181 prompt: "implement auth".to_string(),
182 owned_files: Some(vec!["src/auth.rs".to_string()]),
183 };
184 assert_eq!(entry.id, "add-auth");
185 assert_eq!(entry.branch, "spec/add-auth");
186 assert_eq!(entry.cli.as_deref(), Some("claude"));
187 assert_eq!(entry.prompt, "implement auth");
188 assert_eq!(entry.owned_files.as_ref().unwrap().len(), 1);
189 }
190
191 #[test]
192 fn spec_entry_optional_fields_absent() {
193 let entry = SpecEntry {
194 id: "fix-bug".to_string(),
195 branch: "spec/fix-bug".to_string(),
196 cli: None,
197 prompt: "fix the bug".to_string(),
198 owned_files: None,
199 };
200 assert!(entry.cli.is_none());
201 assert!(entry.owned_files.is_none());
202 }
203
204 #[test]
205 fn derive_branch_default_prefix() {
206 assert_eq!(derive_branch("spec/", "add-auth"), "spec/add-auth");
207 }
208
209 #[test]
210 fn derive_branch_custom_prefix_with_trailing_slash() {
211 assert_eq!(derive_branch("feat/", "login"), "feat/login");
212 }
213
214 #[test]
215 fn derive_branch_custom_prefix_without_trailing_slash() {
216 assert_eq!(derive_branch("feat", "login"), "feat/login");
217 }
218
219 #[test]
220 fn backend_for_type_openspec() {
221 assert!(backend_for_type("openspec").is_ok());
222 }
223
224 #[test]
225 fn backend_for_type_markdown() {
226 assert!(backend_for_type("markdown").is_ok());
227 }
228
229 #[test]
230 fn backend_for_type_unknown() {
231 let err = backend_for_type("unknown").unwrap_err();
232 let msg = err.to_string();
233 assert!(msg.contains("unknown spec type"), "got: {msg}");
234 }
235
236 #[test]
237 fn scan_specs_no_specs_config() {
238 let config = PawConfig::default();
239 let tmp = tempfile::tempdir().unwrap();
240 let err = scan_specs(&config, tmp.path()).unwrap_err();
241 let msg = err.to_string();
242 assert!(msg.contains("[specs]"), "got: {msg}");
243 }
244
245 #[test]
246 fn scan_specs_nonexistent_directory() {
247 let config = PawConfig {
248 specs: Some(SpecsConfig {
249 dir: Some("nonexistent".to_string()),
250 spec_type: Some("openspec".to_string()),
251 }),
252 ..Default::default()
253 };
254 let tmp = tempfile::tempdir().unwrap();
255 let err = scan_specs(&config, tmp.path()).unwrap_err();
256 let msg = err.to_string();
257 assert!(msg.contains("does not exist"), "got: {msg}");
258 assert!(msg.contains("nonexistent"), "got: {msg}");
259 }
260
261 #[test]
262 fn scan_specs_file_instead_of_directory() {
263 let tmp = tempfile::tempdir().unwrap();
264 let file_path = tmp.path().join("specs");
265 fs::write(&file_path, "not a directory").unwrap();
266 let config = PawConfig {
267 specs: Some(SpecsConfig {
268 dir: Some("specs".to_string()),
269 spec_type: Some("openspec".to_string()),
270 }),
271 ..Default::default()
272 };
273 let err = scan_specs(&config, tmp.path()).unwrap_err();
274 let msg = err.to_string();
275 assert!(msg.contains("not a directory"), "got: {msg}");
276 }
277
278 #[test]
279 fn scan_specs_valid_config_stub_backend() {
280 let tmp = tempfile::tempdir().unwrap();
281 fs::create_dir(tmp.path().join("specs")).unwrap();
282 let config = PawConfig {
283 specs: Some(SpecsConfig {
284 dir: Some("specs".to_string()),
285 spec_type: Some("openspec".to_string()),
286 }),
287 ..Default::default()
288 };
289 let entries = scan_specs(&config, tmp.path()).unwrap();
290 assert!(entries.is_empty());
291 }
292}