1mod markdown;
8mod openspec;
9pub mod resolve;
10pub mod speckit;
11
12use std::collections::HashMap;
13use std::fmt;
14use std::path::Path;
15
16use crate::config::PawConfig;
17use crate::error::PawError;
18use openspec::OpenSpecBackend;
19use speckit::SpecKitBackend;
20
21#[derive(Debug, Clone)]
29pub struct SpecEntry {
30 pub id: String,
32 pub backend: SpecBackendKind,
34 pub branch: String,
36 pub cli: Option<String>,
38 pub prompt: String,
40 pub owned_files: Option<Vec<String>>,
42}
43
44pub trait SpecBackend: fmt::Debug {
49 fn scan(&self, dir: &Path) -> Result<Vec<SpecEntry>, PawError>;
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum SpecBackendKind {
69 OpenSpec,
71 Markdown,
73 SpecKit,
75}
76
77use markdown::MarkdownBackend;
78
79pub(crate) fn parse_frontmatter(content: &str) -> (Option<HashMap<String, String>>, &str) {
83 let trimmed = content.trim_start();
84 if !trimmed.starts_with("---") {
85 return (None, content);
86 }
87
88 let after_open = match trimmed.strip_prefix("---") {
90 Some(rest) => {
91 match rest.find('\n') {
93 Some(idx) => &rest[idx + 1..],
94 None => return (None, content),
95 }
96 }
97 None => return (None, content),
98 };
99
100 let close_pos = after_open
102 .lines()
103 .enumerate()
104 .find(|(_, line)| line.trim() == "---");
105
106 let (frontmatter_str, body) = match close_pos {
107 Some((line_idx, _)) => {
108 let byte_offset: usize = after_open.lines().take(line_idx).map(|l| l.len() + 1).sum();
109 let fm = &after_open[..byte_offset];
110 let after_close = &after_open[byte_offset..];
111 let body = match after_close.find('\n') {
113 Some(idx) => &after_close[idx + 1..],
114 None => "",
115 };
116 (fm, body)
117 }
118 None => return (None, content),
119 };
120
121 let mut fields = HashMap::new();
122 for line in frontmatter_str.lines() {
123 let line = line.trim();
124 if line.is_empty() {
125 continue;
126 }
127 if let Some((key, value)) = line.split_once(':') {
128 fields.insert(key.trim().to_string(), value.trim().to_string());
129 }
130 }
131
132 (Some(fields), body)
133}
134
135fn backend_for_type(spec_type: &str) -> Result<Box<dyn SpecBackend>, PawError> {
137 match spec_type {
138 "openspec" => Ok(Box::new(OpenSpecBackend)),
139 "markdown" => Ok(Box::new(MarkdownBackend)),
140 "speckit" => Ok(Box::new(SpecKitBackend)),
141 _ => Err(PawError::SpecError(format!(
142 "unknown spec type: {spec_type}"
143 ))),
144 }
145}
146
147fn derive_branch(prefix: &str, id: &str) -> String {
151 if prefix.ends_with('/') {
152 format!("{prefix}{id}")
153 } else {
154 format!("{prefix}/{id}")
155 }
156}
157
158fn resolve_specs_config(
168 config: &PawConfig,
169 repo_root: &Path,
170 format_override: Option<&str>,
171) -> Option<crate::config::SpecsConfig> {
172 if let Some(format) = format_override {
173 let mut base = config.specs.clone().unwrap_or_default();
174 base.spec_type = Some(format.to_string());
175 if base.dir.is_none() && format == "speckit" {
176 base.dir = Some(".specify/specs".to_string());
177 }
178 return Some(base);
179 }
180
181 if config.specs.is_some() {
182 return config.specs.clone();
183 }
184
185 let specify = repo_root.join(".specify");
187 if specify.is_dir() && specify.join("specs").is_dir() {
188 return Some(crate::config::SpecsConfig {
189 dir: Some(".specify/specs".to_string()),
190 spec_type: Some("speckit".to_string()),
191 });
192 }
193
194 None
195}
196
197pub fn scan_specs(config: &PawConfig, repo_root: &Path) -> Result<Vec<SpecEntry>, PawError> {
207 scan_specs_with_override(config, repo_root, None)
208}
209
210pub fn scan_specs_with_override(
212 config: &PawConfig,
213 repo_root: &Path,
214 format_override: Option<&str>,
215) -> Result<Vec<SpecEntry>, PawError> {
216 let specs_config = resolve_specs_config(config, repo_root, format_override)
217 .ok_or_else(|| PawError::SpecError("no [specs] section in config".to_string()))?;
218
219 let dir = specs_config.dir.as_deref().unwrap_or("specs");
220 let specs_dir = repo_root.join(dir);
221
222 if !specs_dir.exists() {
223 return Err(PawError::SpecError(format!(
224 "specs directory does not exist: {}",
225 specs_dir.display()
226 )));
227 }
228 if !specs_dir.is_dir() {
229 return Err(PawError::SpecError(format!(
230 "specs path is not a directory: {}",
231 specs_dir.display()
232 )));
233 }
234
235 let spec_type = specs_config.spec_type.as_deref().unwrap_or("openspec");
236 let backend = backend_for_type(spec_type)?;
237
238 let branch_prefix = config.branch_prefix.as_deref().unwrap_or("spec/");
239 let mut entries = backend.scan(&specs_dir)?;
240
241 for entry in &mut entries {
245 if entry.branch.is_empty() {
246 entry.branch = derive_branch(branch_prefix, &entry.id);
247 }
248 }
249
250 Ok(entries)
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::config::SpecsConfig;
257 use std::fs;
258
259 #[test]
260 fn spec_entry_all_fields() {
261 let entry = SpecEntry {
262 id: "add-auth".to_string(),
263 backend: SpecBackendKind::OpenSpec,
264 branch: "spec/add-auth".to_string(),
265 cli: Some("claude".to_string()),
266 prompt: "implement auth".to_string(),
267 owned_files: Some(vec!["src/auth.rs".to_string()]),
268 };
269 assert_eq!(entry.id, "add-auth");
270 assert_eq!(entry.backend, SpecBackendKind::OpenSpec);
271 assert_eq!(entry.branch, "spec/add-auth");
272 assert_eq!(entry.cli.as_deref(), Some("claude"));
273 assert_eq!(entry.prompt, "implement auth");
274 assert_eq!(entry.owned_files.as_ref().unwrap().len(), 1);
275 }
276
277 #[test]
278 fn spec_entry_optional_fields_absent() {
279 let entry = SpecEntry {
280 id: "fix-bug".to_string(),
281 backend: SpecBackendKind::Markdown,
282 branch: "spec/fix-bug".to_string(),
283 cli: None,
284 prompt: "fix the bug".to_string(),
285 owned_files: None,
286 };
287 assert_eq!(entry.backend, SpecBackendKind::Markdown);
288 assert!(entry.cli.is_none());
289 assert!(entry.owned_files.is_none());
290 }
291
292 #[test]
293 fn derive_branch_default_prefix() {
294 assert_eq!(derive_branch("spec/", "add-auth"), "spec/add-auth");
295 }
296
297 #[test]
298 fn derive_branch_custom_prefix_with_trailing_slash() {
299 assert_eq!(derive_branch("feat/", "login"), "feat/login");
300 }
301
302 #[test]
303 fn derive_branch_custom_prefix_without_trailing_slash() {
304 assert_eq!(derive_branch("feat", "login"), "feat/login");
305 }
306
307 #[test]
308 fn backend_for_type_openspec() {
309 assert!(backend_for_type("openspec").is_ok());
310 }
311
312 #[test]
313 fn backend_for_type_markdown() {
314 assert!(backend_for_type("markdown").is_ok());
315 }
316
317 #[test]
318 fn backend_for_type_speckit() {
319 assert!(backend_for_type("speckit").is_ok());
320 }
321
322 #[test]
323 fn backend_for_type_unknown() {
324 let err = backend_for_type("unknown").unwrap_err();
325 let msg = err.to_string();
326 assert!(msg.contains("unknown spec type"), "got: {msg}");
327 }
328
329 #[test]
330 fn scan_specs_no_specs_config() {
331 let config = PawConfig::default();
332 let tmp = tempfile::tempdir().unwrap();
333 let err = scan_specs(&config, tmp.path()).unwrap_err();
334 let msg = err.to_string();
335 assert!(msg.contains("[specs]"), "got: {msg}");
336 }
337
338 #[test]
339 fn scan_specs_nonexistent_directory() {
340 let config = PawConfig {
341 specs: Some(SpecsConfig {
342 dir: Some("nonexistent".to_string()),
343 spec_type: Some("openspec".to_string()),
344 }),
345 ..Default::default()
346 };
347 let tmp = tempfile::tempdir().unwrap();
348 let err = scan_specs(&config, tmp.path()).unwrap_err();
349 let msg = err.to_string();
350 assert!(msg.contains("does not exist"), "got: {msg}");
351 assert!(msg.contains("nonexistent"), "got: {msg}");
352 }
353
354 #[test]
355 fn scan_specs_file_instead_of_directory() {
356 let tmp = tempfile::tempdir().unwrap();
357 let file_path = tmp.path().join("specs");
358 fs::write(&file_path, "not a directory").unwrap();
359 let config = PawConfig {
360 specs: Some(SpecsConfig {
361 dir: Some("specs".to_string()),
362 spec_type: Some("openspec".to_string()),
363 }),
364 ..Default::default()
365 };
366 let err = scan_specs(&config, tmp.path()).unwrap_err();
367 let msg = err.to_string();
368 assert!(msg.contains("not a directory"), "got: {msg}");
369 }
370
371 #[test]
372 fn scan_specs_valid_config_stub_backend() {
373 let tmp = tempfile::tempdir().unwrap();
374 fs::create_dir(tmp.path().join("specs")).unwrap();
375 let config = PawConfig {
376 specs: Some(SpecsConfig {
377 dir: Some("specs".to_string()),
378 spec_type: Some("openspec".to_string()),
379 }),
380 ..Default::default()
381 };
382 let entries = scan_specs(&config, tmp.path()).unwrap();
383 assert!(entries.is_empty());
384 }
385
386 #[test]
389 fn auto_detect_specify_activates_speckit() {
390 let tmp = tempfile::tempdir().unwrap();
391 fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
392 let config = PawConfig::default();
393 let entries = scan_specs(&config, tmp.path()).unwrap();
395 assert!(entries.is_empty());
396 }
397
398 #[test]
399 fn auto_detect_skipped_when_specs_section_present() {
400 let tmp = tempfile::tempdir().unwrap();
401 fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
402 fs::create_dir(tmp.path().join("my-specs")).unwrap();
403 let config = PawConfig {
404 specs: Some(SpecsConfig {
405 dir: Some("my-specs".to_string()),
406 spec_type: Some("markdown".to_string()),
407 }),
408 ..Default::default()
409 };
410 let resolved = resolve_specs_config(&config, tmp.path(), None).unwrap();
411 assert_eq!(resolved.spec_type.as_deref(), Some("markdown"));
412 assert_eq!(resolved.dir.as_deref(), Some("my-specs"));
413 }
414
415 #[test]
416 fn auto_detect_skipped_when_no_specify_dir() {
417 let tmp = tempfile::tempdir().unwrap();
418 let config = PawConfig::default();
419 assert!(resolve_specs_config(&config, tmp.path(), None).is_none());
420 }
421
422 #[test]
423 fn auto_detect_skipped_when_specify_missing_specs_subdir() {
424 let tmp = tempfile::tempdir().unwrap();
425 fs::create_dir_all(tmp.path().join(".specify").join("memory")).unwrap();
426 let config = PawConfig::default();
427 assert!(resolve_specs_config(&config, tmp.path(), None).is_none());
428 }
429
430 #[test]
437 fn explicit_config_wins_over_auto_detection() {
438 let tmp = tempfile::tempdir().unwrap();
439 fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
441 let md_dir = tmp.path().join("specs");
443 fs::create_dir(&md_dir).unwrap();
444
445 let config = PawConfig {
446 specs: Some(SpecsConfig {
447 dir: Some("specs".to_string()),
448 spec_type: Some("markdown".to_string()),
449 }),
450 ..Default::default()
451 };
452
453 let resolved = resolve_specs_config(&config, tmp.path(), None)
456 .expect("explicit config should resolve");
457 assert_eq!(
458 resolved.spec_type.as_deref(),
459 Some("markdown"),
460 "explicit type = markdown must win over the auto-detected speckit"
461 );
462 assert_eq!(
463 resolved.dir.as_deref(),
464 Some("specs"),
465 "explicit dir = specs must win over the auto-detected .specify/specs"
466 );
467
468 let entries = scan_specs(&config, tmp.path()).unwrap();
475 assert!(
476 entries.is_empty(),
477 "empty markdown specs dir should produce no entries; got: {entries:?}"
478 );
479 }
480
481 #[test]
482 fn format_override_wins_over_specs_config_and_auto_detection() {
483 let tmp = tempfile::tempdir().unwrap();
484 fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
485 let config = PawConfig {
486 specs: Some(SpecsConfig {
487 dir: Some("my-specs".to_string()),
488 spec_type: Some("markdown".to_string()),
489 }),
490 ..Default::default()
491 };
492 let resolved = resolve_specs_config(&config, tmp.path(), Some("openspec")).unwrap();
493 assert_eq!(resolved.spec_type.as_deref(), Some("openspec"));
494 assert_eq!(resolved.dir.as_deref(), Some("my-specs"));
495 }
496
497 #[test]
498 fn format_override_speckit_supplies_default_dir() {
499 let tmp = tempfile::tempdir().unwrap();
500 let config = PawConfig::default();
501 let resolved = resolve_specs_config(&config, tmp.path(), Some("speckit")).unwrap();
502 assert_eq!(resolved.spec_type.as_deref(), Some("speckit"));
503 assert_eq!(resolved.dir.as_deref(), Some(".specify/specs"));
504 }
505
506 #[test]
507 fn scan_specs_with_override_routes_to_speckit() {
508 let tmp = tempfile::tempdir().unwrap();
509 let specify = tmp.path().join(".specify").join("specs");
510 let feat = specify.join("001-feature");
511 fs::create_dir_all(&feat).unwrap();
512 fs::write(
513 feat.join("tasks.md"),
514 "## Phase 1: Setup\n- [ ] T001 do thing\n",
515 )
516 .unwrap();
517
518 let config = PawConfig::default();
519 let entries = scan_specs_with_override(&config, tmp.path(), Some("speckit")).unwrap();
520 assert_eq!(entries.len(), 1);
521 assert!(
523 entries[0].branch.starts_with("phase/"),
524 "got branch: {}",
525 entries[0].branch
526 );
527 }
528
529 #[test]
530 fn scan_specs_openspec_still_gets_branch_prefix() {
531 let tmp = tempfile::tempdir().unwrap();
532 let specs_dir = tmp.path().join("specs");
533 let change = specs_dir.join("add-auth");
534 fs::create_dir_all(&change).unwrap();
535 fs::write(change.join("tasks.md"), "implement auth").unwrap();
536
537 let config = PawConfig {
538 specs: Some(SpecsConfig {
539 dir: Some("specs".to_string()),
540 spec_type: Some("openspec".to_string()),
541 }),
542 ..Default::default()
543 };
544 let entries = scan_specs(&config, tmp.path()).unwrap();
545 assert_eq!(entries.len(), 1);
546 assert_eq!(entries[0].branch, "spec/add-auth");
547 }
548}