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/// Resolves the effective spec engine type for a repo, or `None` when no
198/// spec source is configured or auto-detected.
199///
200/// Applies the same precedence as [`scan_specs`] (explicit `[specs]` config,
201/// then `.specify/` auto-detection) and resolves a present-but-untyped
202/// `[specs]` section to the `"openspec"` default that `scan_specs` would use.
203/// Consumers that need to gate a capability on the `OpenSpec` engine — notably
204/// the `opsx-role-gating` guard — call this and compare against `"openspec"`.
205#[must_use]
206pub fn resolved_spec_type(config: &PawConfig, repo_root: &Path) -> Option<String> {
207    resolve_specs_config(config, repo_root, None)
208        .map(|c| c.spec_type.unwrap_or_else(|| "openspec".to_string()))
209}
210
211/// Scans for pending specs using the configuration from `[specs]`.
212///
213/// Reads the spec directory and format type from `config`, selects the
214/// appropriate backend, scans for pending specs, and derives branch names.
215///
216/// Returns an error if:
217/// - No `[specs]` section exists in config and no `.specify/` is auto-detected
218/// - The spec directory does not exist or is not a directory
219/// - The spec type is unknown
220pub fn scan_specs(config: &PawConfig, repo_root: &Path) -> Result<Vec<SpecEntry>, PawError> {
221    scan_specs_with_override(config, repo_root, None)
222}
223
224/// Like [`scan_specs`], but honours a CLI `--specs-format` override.
225pub fn scan_specs_with_override(
226    config: &PawConfig,
227    repo_root: &Path,
228    format_override: Option<&str>,
229) -> Result<Vec<SpecEntry>, PawError> {
230    let specs_config = resolve_specs_config(config, repo_root, format_override)
231        .ok_or_else(|| PawError::SpecError("no [specs] section in config".to_string()))?;
232
233    let dir = specs_config.dir.as_deref().unwrap_or("specs");
234    let specs_dir = repo_root.join(dir);
235
236    if !specs_dir.exists() {
237        return Err(PawError::SpecError(format!(
238            "specs directory does not exist: {}",
239            specs_dir.display()
240        )));
241    }
242    if !specs_dir.is_dir() {
243        return Err(PawError::SpecError(format!(
244            "specs path is not a directory: {}",
245            specs_dir.display()
246        )));
247    }
248
249    let spec_type = specs_config.spec_type.as_deref().unwrap_or("openspec");
250    let backend = backend_for_type(spec_type)?;
251
252    let branch_prefix = config.branch_prefix.as_deref().unwrap_or("spec/");
253    let mut entries = backend.scan(&specs_dir)?;
254
255    // Backends that set their own branch name (e.g. SpecKit's `task/` and
256    // `phase/` prefixes) keep it. Backends that leave `branch` empty get the
257    // `<branch_prefix><id>` convention applied here.
258    for entry in &mut entries {
259        if entry.branch.is_empty() {
260            entry.branch = derive_branch(branch_prefix, &entry.id);
261        }
262    }
263
264    Ok(entries)
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::config::SpecsConfig;
271    use std::fs;
272
273    #[test]
274    fn spec_entry_all_fields() {
275        let entry = SpecEntry {
276            id: "add-auth".to_string(),
277            backend: SpecBackendKind::OpenSpec,
278            branch: "spec/add-auth".to_string(),
279            cli: Some("claude".to_string()),
280            prompt: "implement auth".to_string(),
281            owned_files: Some(vec!["src/auth.rs".to_string()]),
282        };
283        assert_eq!(entry.id, "add-auth");
284        assert_eq!(entry.backend, SpecBackendKind::OpenSpec);
285        assert_eq!(entry.branch, "spec/add-auth");
286        assert_eq!(entry.cli.as_deref(), Some("claude"));
287        assert_eq!(entry.prompt, "implement auth");
288        assert_eq!(entry.owned_files.as_ref().unwrap().len(), 1);
289    }
290
291    #[test]
292    fn spec_entry_optional_fields_absent() {
293        let entry = SpecEntry {
294            id: "fix-bug".to_string(),
295            backend: SpecBackendKind::Markdown,
296            branch: "spec/fix-bug".to_string(),
297            cli: None,
298            prompt: "fix the bug".to_string(),
299            owned_files: None,
300        };
301        assert_eq!(entry.backend, SpecBackendKind::Markdown);
302        assert!(entry.cli.is_none());
303        assert!(entry.owned_files.is_none());
304    }
305
306    #[test]
307    fn derive_branch_default_prefix() {
308        assert_eq!(derive_branch("spec/", "add-auth"), "spec/add-auth");
309    }
310
311    #[test]
312    fn derive_branch_custom_prefix_with_trailing_slash() {
313        assert_eq!(derive_branch("feat/", "login"), "feat/login");
314    }
315
316    #[test]
317    fn derive_branch_custom_prefix_without_trailing_slash() {
318        assert_eq!(derive_branch("feat", "login"), "feat/login");
319    }
320
321    #[test]
322    fn backend_for_type_openspec() {
323        assert!(backend_for_type("openspec").is_ok());
324    }
325
326    #[test]
327    fn backend_for_type_markdown() {
328        assert!(backend_for_type("markdown").is_ok());
329    }
330
331    #[test]
332    fn backend_for_type_speckit() {
333        assert!(backend_for_type("speckit").is_ok());
334    }
335
336    #[test]
337    fn backend_for_type_unknown() {
338        let err = backend_for_type("unknown").unwrap_err();
339        let msg = err.to_string();
340        assert!(msg.contains("unknown spec type"), "got: {msg}");
341    }
342
343    #[test]
344    fn scan_specs_no_specs_config() {
345        let config = PawConfig::default();
346        let tmp = tempfile::tempdir().unwrap();
347        let err = scan_specs(&config, tmp.path()).unwrap_err();
348        let msg = err.to_string();
349        assert!(msg.contains("[specs]"), "got: {msg}");
350    }
351
352    #[test]
353    fn scan_specs_nonexistent_directory() {
354        let config = PawConfig {
355            specs: Some(SpecsConfig {
356                dir: Some("nonexistent".to_string()),
357                spec_type: Some("openspec".to_string()),
358            }),
359            ..Default::default()
360        };
361        let tmp = tempfile::tempdir().unwrap();
362        let err = scan_specs(&config, tmp.path()).unwrap_err();
363        let msg = err.to_string();
364        assert!(msg.contains("does not exist"), "got: {msg}");
365        assert!(msg.contains("nonexistent"), "got: {msg}");
366    }
367
368    #[test]
369    fn scan_specs_file_instead_of_directory() {
370        let tmp = tempfile::tempdir().unwrap();
371        let file_path = tmp.path().join("specs");
372        fs::write(&file_path, "not a directory").unwrap();
373        let config = PawConfig {
374            specs: Some(SpecsConfig {
375                dir: Some("specs".to_string()),
376                spec_type: Some("openspec".to_string()),
377            }),
378            ..Default::default()
379        };
380        let err = scan_specs(&config, tmp.path()).unwrap_err();
381        let msg = err.to_string();
382        assert!(msg.contains("not a directory"), "got: {msg}");
383    }
384
385    #[test]
386    fn scan_specs_valid_config_stub_backend() {
387        let tmp = tempfile::tempdir().unwrap();
388        fs::create_dir(tmp.path().join("specs")).unwrap();
389        let config = PawConfig {
390            specs: Some(SpecsConfig {
391                dir: Some("specs".to_string()),
392                spec_type: Some("openspec".to_string()),
393            }),
394            ..Default::default()
395        };
396        let entries = scan_specs(&config, tmp.path()).unwrap();
397        assert!(entries.is_empty());
398    }
399
400    // --- Auto-detection of .specify/ ---
401
402    #[test]
403    fn auto_detect_specify_activates_speckit() {
404        let tmp = tempfile::tempdir().unwrap();
405        fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
406        let config = PawConfig::default();
407        // The path exists but has no features — backend returns empty Vec.
408        let entries = scan_specs(&config, tmp.path()).unwrap();
409        assert!(entries.is_empty());
410    }
411
412    #[test]
413    fn auto_detect_skipped_when_specs_section_present() {
414        let tmp = tempfile::tempdir().unwrap();
415        fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
416        fs::create_dir(tmp.path().join("my-specs")).unwrap();
417        let config = PawConfig {
418            specs: Some(SpecsConfig {
419                dir: Some("my-specs".to_string()),
420                spec_type: Some("markdown".to_string()),
421            }),
422            ..Default::default()
423        };
424        let resolved = resolve_specs_config(&config, tmp.path(), None).unwrap();
425        assert_eq!(resolved.spec_type.as_deref(), Some("markdown"));
426        assert_eq!(resolved.dir.as_deref(), Some("my-specs"));
427    }
428
429    #[test]
430    fn auto_detect_skipped_when_no_specify_dir() {
431        let tmp = tempfile::tempdir().unwrap();
432        let config = PawConfig::default();
433        assert!(resolve_specs_config(&config, tmp.path(), None).is_none());
434    }
435
436    #[test]
437    fn auto_detect_skipped_when_specify_missing_specs_subdir() {
438        let tmp = tempfile::tempdir().unwrap();
439        fs::create_dir_all(tmp.path().join(".specify").join("memory")).unwrap();
440        let config = PawConfig::default();
441        assert!(resolve_specs_config(&config, tmp.path(), None).is_none());
442    }
443
444    // Maps to scenario `Explicit config in TOML wins over auto-detection`
445    // from spec-kit-format. The repo has BOTH a `.specify/specs/` directory
446    // (which would normally auto-activate the SpecKit backend) AND an
447    // explicit `[specs] type = "markdown"` config. The explicit config
448    // must win: the Markdown backend is selected, not SpecKit.
449    // (test-coverage-v0-5-0 task 11.5)
450    #[test]
451    fn explicit_config_wins_over_auto_detection() {
452        let tmp = tempfile::tempdir().unwrap();
453        // Seed `.specify/specs/` so the auto-detection branch *would* fire.
454        fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
455        // Seed a markdown specs directory the explicit config points at.
456        let md_dir = tmp.path().join("specs");
457        fs::create_dir(&md_dir).unwrap();
458
459        let config = PawConfig {
460            specs: Some(SpecsConfig {
461                dir: Some("specs".to_string()),
462                spec_type: Some("markdown".to_string()),
463            }),
464            ..Default::default()
465        };
466
467        // resolve_specs_config must select the explicit config without
468        // falling through to auto-detection.
469        let resolved = resolve_specs_config(&config, tmp.path(), None)
470            .expect("explicit config should resolve");
471        assert_eq!(
472            resolved.spec_type.as_deref(),
473            Some("markdown"),
474            "explicit type = markdown must win over the auto-detected speckit"
475        );
476        assert_eq!(
477            resolved.dir.as_deref(),
478            Some("specs"),
479            "explicit dir = specs must win over the auto-detected .specify/specs"
480        );
481
482        // End-to-end: scan_specs must run the Markdown backend and NOT the
483        // SpecKit backend. With an empty markdown specs/ dir the result is
484        // an empty entry list; with SpecKit on the `.specify/specs/` dir
485        // we would similarly get zero entries — but a SpecKit-routed scan
486        // would set up the `.specify/specs/` dir as its source. We assert
487        // success on the markdown path explicitly.
488        let entries = scan_specs(&config, tmp.path()).unwrap();
489        assert!(
490            entries.is_empty(),
491            "empty markdown specs dir should produce no entries; got: {entries:?}"
492        );
493    }
494
495    #[test]
496    fn format_override_wins_over_specs_config_and_auto_detection() {
497        let tmp = tempfile::tempdir().unwrap();
498        fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
499        let config = PawConfig {
500            specs: Some(SpecsConfig {
501                dir: Some("my-specs".to_string()),
502                spec_type: Some("markdown".to_string()),
503            }),
504            ..Default::default()
505        };
506        let resolved = resolve_specs_config(&config, tmp.path(), Some("openspec")).unwrap();
507        assert_eq!(resolved.spec_type.as_deref(), Some("openspec"));
508        assert_eq!(resolved.dir.as_deref(), Some("my-specs"));
509    }
510
511    #[test]
512    fn format_override_speckit_supplies_default_dir() {
513        let tmp = tempfile::tempdir().unwrap();
514        let config = PawConfig::default();
515        let resolved = resolve_specs_config(&config, tmp.path(), Some("speckit")).unwrap();
516        assert_eq!(resolved.spec_type.as_deref(), Some("speckit"));
517        assert_eq!(resolved.dir.as_deref(), Some(".specify/specs"));
518    }
519
520    #[test]
521    fn scan_specs_with_override_routes_to_speckit() {
522        let tmp = tempfile::tempdir().unwrap();
523        let specify = tmp.path().join(".specify").join("specs");
524        let feat = specify.join("001-feature");
525        fs::create_dir_all(&feat).unwrap();
526        fs::write(
527            feat.join("tasks.md"),
528            "## Phase 1: Setup\n- [ ] T001 do thing\n",
529        )
530        .unwrap();
531
532        let config = PawConfig::default();
533        let entries = scan_specs_with_override(&config, tmp.path(), Some("speckit")).unwrap();
534        assert_eq!(entries.len(), 1);
535        // SpecKit-supplied branch name is preserved (not overwritten with `spec/...`).
536        assert!(
537            entries[0].branch.starts_with("phase/"),
538            "got branch: {}",
539            entries[0].branch
540        );
541    }
542
543    #[test]
544    fn scan_specs_openspec_still_gets_branch_prefix() {
545        let tmp = tempfile::tempdir().unwrap();
546        let specs_dir = tmp.path().join("specs");
547        let change = specs_dir.join("add-auth");
548        fs::create_dir_all(&change).unwrap();
549        fs::write(change.join("tasks.md"), "implement auth").unwrap();
550
551        let config = PawConfig {
552            specs: Some(SpecsConfig {
553                dir: Some("specs".to_string()),
554                spec_type: Some("openspec".to_string()),
555            }),
556            ..Default::default()
557        };
558        let entries = scan_specs(&config, tmp.path()).unwrap();
559        assert_eq!(entries.len(), 1);
560        assert_eq!(entries[0].branch, "spec/add-auth");
561    }
562}