Skip to main content

git_paw/specs/
mod.rs

1//! Spec scanning and discovery.
2//!
3//! Defines the `SpecBackend` trait for format-specific spec scanning,
4//! `SpecEntry` as the universal spec representation, and `scan_specs()`
5//! as the entry point for discovering pending specs.
6
7mod 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/// A discovered spec ready for session launch.
22///
23/// Represents a single pending spec with all the information needed
24/// to create a worktree and launch an AI coding session. The `backend`
25/// field identifies the `SpecBackend` implementation that produced the
26/// entry, so downstream consumers (notably `build_task_prompt`) can
27/// dispatch behaviour per backend without re-reading configuration.
28#[derive(Debug, Clone)]
29pub struct SpecEntry {
30    /// Unique identifier (folder name or filename).
31    pub id: String,
32    /// The `SpecBackend` implementation that produced this entry.
33    pub backend: SpecBackendKind,
34    /// Derived branch name: `branch_prefix` + `id`.
35    pub branch: String,
36    /// Per-spec CLI override (from `paw_cli` frontmatter).
37    pub cli: Option<String>,
38    /// Content to inject into the worktree `AGENTS.md`.
39    pub prompt: String,
40    /// File ownership if declared by the spec.
41    pub owned_files: Option<Vec<String>>,
42}
43
44/// Trait for format-specific spec scanning backends.
45///
46/// Each spec format (`OpenSpec`, `Markdown`) implements this trait to provide
47/// discovery of pending specs within a directory.
48pub trait SpecBackend: fmt::Debug {
49    /// Scans `dir` for pending specs and returns them as `SpecEntry` values.
50    fn scan(&self, dir: &Path) -> Result<Vec<SpecEntry>, PawError>;
51}
52
53/// The per-entry tag a `SpecBackend` implementation sets on every
54/// `SpecEntry` it returns.
55///
56/// Downstream consumers (notably `build_task_prompt`) dispatch on this
57/// field so per-backend behaviour does not have to re-read configuration
58/// or maintain a parallel map of entry → backend identity.
59// NOTE: tasks.md 1.3 of the `openspec-apply-boot-prompt` change predicted
60// that the `SpecKit` variant would be added by the `spec-kit-format`
61// change. That change shipped before this one and did not extend the
62// enum, so we add the variant here to keep the field non-optional across
63// every backend the codebase actually carries today. The Spec Kit branch
64// of `build_task_prompt` falls through to the generic AGENTS.md pointer
65// (same shape as `Markdown`); the `/speckit:apply` slash-command shape,
66// if it ever lands, will replace that branch in a follow-up change.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum SpecBackendKind {
69    /// Produced by `OpenSpecBackend` (`openspec/changes/<id>/` layout).
70    OpenSpec,
71    /// Produced by `MarkdownBackend` (flat `.md` files with frontmatter).
72    Markdown,
73    /// Produced by `SpecKitBackend` (`.specify/specs/<feature>/` layout).
74    SpecKit,
75}
76
77use markdown::MarkdownBackend;
78
79/// Parses YAML frontmatter delimited by `---` lines.
80///
81/// Returns `(Some(fields), body)` if frontmatter is found, or `(None, content)` if not.
82pub(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    // Find the opening `---` line end
89    let after_open = match trimmed.strip_prefix("---") {
90        Some(rest) => {
91            // Skip to end of line
92            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    // Find the closing `---`
101    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            // Skip the closing `---` line
112            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
135/// Returns the appropriate backend for the given spec format type.
136fn 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
147/// Derives a branch name by concatenating `prefix` and `id`.
148///
149/// Inserts a `/` separator if `prefix` does not already end with one.
150fn 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
158/// Resolves the effective spec configuration with auto-detection and CLI
159/// override applied.
160///
161/// Precedence (highest to lowest):
162/// 1. `format_override` (typically the `--specs-format` CLI value).
163/// 2. Explicit `[specs]` section in TOML config.
164/// 3. Auto-detection of `.specify/specs/` at the repo root → Spec Kit defaults.
165///
166/// Returns `None` when no source resolves a usable configuration.
167fn 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    // Auto-detect Spec Kit when `.specify/specs/` exists at the repo root.
186    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
197/// Scans for pending specs using the configuration from `[specs]`.
198///
199/// Reads the spec directory and format type from `config`, selects the
200/// appropriate backend, scans for pending specs, and derives branch names.
201///
202/// Returns an error if:
203/// - No `[specs]` section exists in config and no `.specify/` is auto-detected
204/// - The spec directory does not exist or is not a directory
205/// - The spec type is unknown
206pub fn scan_specs(config: &PawConfig, repo_root: &Path) -> Result<Vec<SpecEntry>, PawError> {
207    scan_specs_with_override(config, repo_root, None)
208}
209
210/// Like [`scan_specs`], but honours a CLI `--specs-format` override.
211pub 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    // Backends that set their own branch name (e.g. SpecKit's `task/` and
242    // `phase/` prefixes) keep it. Backends that leave `branch` empty get the
243    // `<branch_prefix><id>` convention applied here.
244    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    // --- Auto-detection of .specify/ ---
387
388    #[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        // The path exists but has no features — backend returns empty Vec.
394        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    // Maps to scenario `Explicit config in TOML wins over auto-detection`
431    // from spec-kit-format. The repo has BOTH a `.specify/specs/` directory
432    // (which would normally auto-activate the SpecKit backend) AND an
433    // explicit `[specs] type = "markdown"` config. The explicit config
434    // must win: the Markdown backend is selected, not SpecKit.
435    // (test-coverage-v0-5-0 task 11.5)
436    #[test]
437    fn explicit_config_wins_over_auto_detection() {
438        let tmp = tempfile::tempdir().unwrap();
439        // Seed `.specify/specs/` so the auto-detection branch *would* fire.
440        fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
441        // Seed a markdown specs directory the explicit config points at.
442        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        // resolve_specs_config must select the explicit config without
454        // falling through to auto-detection.
455        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        // End-to-end: scan_specs must run the Markdown backend and NOT the
469        // SpecKit backend. With an empty markdown specs/ dir the result is
470        // an empty entry list; with SpecKit on the `.specify/specs/` dir
471        // we would similarly get zero entries — but a SpecKit-routed scan
472        // would set up the `.specify/specs/` dir as its source. We assert
473        // success on the markdown path explicitly.
474        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        // SpecKit-supplied branch name is preserved (not overwritten with `spec/...`).
522        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}