Skip to main content

git_paw/mcp/query/
specs.rs

1//! Spec & task reads across the `OpenSpec`, Markdown, and Spec Kit backends.
2//!
3//! Uses the same discovery (`specs::scan_specs_with_override`) that
4//! `git paw start --from-all-specs` uses, then reads per-backend artifacts
5//! from their conventional locations. Degrades to empty lists when no specs
6//! are configured or discoverable.
7
8use std::path::{Path, PathBuf};
9
10use rmcp::schemars;
11use serde::Serialize;
12
13use crate::config::{self, PawConfig};
14use crate::mcp::RepoContext;
15use crate::specs::{self, SpecBackendKind, SpecEntry};
16
17/// Maps a backend kind to its tool-facing string.
18fn backend_str(kind: SpecBackendKind) -> &'static str {
19    match kind {
20        SpecBackendKind::OpenSpec => "openspec",
21        SpecBackendKind::Markdown => "markdown",
22        SpecBackendKind::SpecKit => "speckit",
23    }
24}
25
26/// Resolved spec discovery context.
27struct Discovery {
28    repo_root: PathBuf,
29    /// Directory specs are scanned from (relative to repo root).
30    dir: String,
31    entries: Vec<SpecEntry>,
32}
33
34/// Replicates `resolve_specs_config`'s directory choice (which is private):
35/// explicit `[specs].dir`, else Spec Kit auto-detection.
36fn resolve_dir(config: &PawConfig, repo_root: &Path) -> Option<String> {
37    if let Some(specs) = config.specs.as_ref() {
38        return Some(specs.dir.clone().unwrap_or_else(|| "specs".to_string()));
39    }
40    let specify = repo_root.join(".specify");
41    if specify.is_dir() && specify.join("specs").is_dir() {
42        return Some(".specify/specs".to_string());
43    }
44    None
45}
46
47/// Discovers specs for the repository, degrading to an empty set on any
48/// discovery error (no config, missing directory, etc.).
49fn discover(ctx: &RepoContext) -> Discovery {
50    let repo_root = ctx.root.clone();
51    let config = config::load_config(&repo_root, None).unwrap_or_default();
52    let dir = resolve_dir(&config, &repo_root).unwrap_or_else(|| "specs".to_string());
53    let entries = specs::scan_specs(&config, &repo_root).unwrap_or_default();
54    Discovery {
55        repo_root,
56        dir,
57        entries,
58    }
59}
60
61fn first_heading(text: &str) -> Option<String> {
62    text.lines()
63        .find_map(|l| l.trim().strip_prefix("# ").map(str::trim))
64        .filter(|s| !s.is_empty())
65        .map(str::to_string)
66}
67
68/// Derives a human title for a spec: the first `# ` heading in its primary
69/// artifact (proposal.md for `OpenSpec`, spec.md for Spec Kit), else the entry
70/// prompt's heading, else the id.
71fn derive_title(spec_dir: &Path, entry: &SpecEntry) -> String {
72    let primary = match entry.backend {
73        SpecBackendKind::OpenSpec => "proposal.md",
74        SpecBackendKind::SpecKit => "spec.md",
75        SpecBackendKind::Markdown => "",
76    };
77    if !primary.is_empty()
78        && let Ok(content) = std::fs::read_to_string(spec_dir.join(primary))
79        && let Some(h) = first_heading(&content)
80    {
81        return h;
82    }
83    first_heading(&entry.prompt).unwrap_or_else(|| entry.id.clone())
84}
85
86/// One discovered spec summary.
87#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
88pub struct SpecInfo {
89    /// Spec id (directory or file stem).
90    pub id: String,
91    /// Backend: "openspec" | "markdown" | "speckit".
92    pub backend: String,
93    /// Human title.
94    pub title: String,
95    /// Status (discovery yields pending specs).
96    pub status: String,
97    /// Path relative to the repository root.
98    pub path: String,
99}
100
101/// Lists every discovered spec across all backends.
102#[must_use]
103pub fn list_specs(ctx: &RepoContext) -> Vec<SpecInfo> {
104    let d = discover(ctx);
105    d.entries
106        .iter()
107        .map(|e| {
108            let spec_dir = d.repo_root.join(&d.dir).join(&e.id);
109            SpecInfo {
110                id: e.id.clone(),
111                backend: backend_str(e.backend).to_string(),
112                title: derive_title(&spec_dir, e),
113                status: "pending".to_string(),
114                path: format!("{}/{}", d.dir.trim_end_matches('/'), e.id),
115            }
116        })
117        .collect()
118}
119
120/// One artifact (file) belonging to a spec.
121#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
122pub struct Artifact {
123    /// Artifact name (e.g. "proposal", "design", "tasks", or a filename).
124    pub name: String,
125    /// Full content.
126    pub content: String,
127}
128
129/// Full content of a single spec.
130#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
131pub struct SpecDetail {
132    /// Spec id.
133    pub id: String,
134    /// Backend string.
135    pub backend: String,
136    /// Path relative to the repository root.
137    pub path: String,
138    /// Discovered artifacts with their content.
139    pub artifacts: Vec<Artifact>,
140}
141
142fn read_named(dir: &Path, file: &str) -> Option<Artifact> {
143    let content = std::fs::read_to_string(dir.join(file)).ok()?;
144    Some(Artifact {
145        name: file.trim_end_matches(".md").to_string(),
146        content,
147    })
148}
149
150/// Returns the full content of a named spec, or `None` when not found.
151#[must_use]
152pub fn get_spec(ctx: &RepoContext, id: &str) -> Option<SpecDetail> {
153    let d = discover(ctx);
154    let entry = d.entries.iter().find(|e| e.id == id)?;
155    let spec_dir = d.repo_root.join(&d.dir).join(id);
156    let rel_path = format!("{}/{}", d.dir.trim_end_matches('/'), id);
157
158    let mut artifacts = Vec::new();
159    match entry.backend {
160        SpecBackendKind::OpenSpec => {
161            for f in ["proposal.md", "design.md", "tasks.md"] {
162                if let Some(a) = read_named(&spec_dir, f) {
163                    artifacts.push(a);
164                }
165            }
166            // Capability spec files under specs/<cap>/spec.md.
167            let specs_sub = spec_dir.join("specs");
168            collect_spec_md(&specs_sub, &spec_dir, &mut artifacts);
169        }
170        SpecBackendKind::SpecKit => {
171            for f in ["spec.md", "plan.md", "tasks.md"] {
172                if let Some(a) = read_named(&spec_dir, f) {
173                    artifacts.push(a);
174                }
175            }
176            // Checklists: any other *.md in the feature dir.
177            if let Ok(rd) = std::fs::read_dir(&spec_dir) {
178                let mut extra: Vec<_> = rd
179                    .flatten()
180                    .filter_map(|e| {
181                        let p = e.path();
182                        let is_md = p.extension().is_some_and(|x| x.eq_ignore_ascii_case("md"));
183                        let name = p.file_name()?.to_str()?.to_string();
184                        let lower = name.to_ascii_lowercase();
185                        if is_md && !["spec.md", "plan.md", "tasks.md"].contains(&lower.as_str()) {
186                            Some((name, std::fs::read_to_string(&p).ok()?))
187                        } else {
188                            None
189                        }
190                    })
191                    .collect();
192                extra.sort_by(|a, b| a.0.cmp(&b.0));
193                for (name, content) in extra {
194                    artifacts.push(Artifact { name, content });
195                }
196            }
197        }
198        SpecBackendKind::Markdown => {
199            // Markdown specs are single files; the entry prompt holds the body.
200            artifacts.push(Artifact {
201                name: id.to_string(),
202                content: entry.prompt.clone(),
203            });
204        }
205    }
206
207    Some(SpecDetail {
208        id: id.to_string(),
209        backend: backend_str(entry.backend).to_string(),
210        path: rel_path,
211        artifacts,
212    })
213}
214
215/// Recursively collects `spec.md` files under `dir` into artifacts, naming each
216/// by its path relative to `base`.
217fn collect_spec_md(dir: &Path, base: &Path, out: &mut Vec<Artifact>) {
218    let Ok(rd) = std::fs::read_dir(dir) else {
219        return;
220    };
221    let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect();
222    entries.sort();
223    for path in entries {
224        if path.is_dir() {
225            collect_spec_md(&path, base, out);
226        } else if path.file_name().and_then(|n| n.to_str()) == Some("spec.md")
227            && let Ok(content) = std::fs::read_to_string(&path)
228        {
229            let name = path
230                .strip_prefix(base)
231                .unwrap_or(&path)
232                .to_string_lossy()
233                .into_owned();
234            out.push(Artifact { name, content });
235        }
236    }
237}
238
239/// One task entry.
240#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
241pub struct TaskInfo {
242    /// Task id (e.g. "T009", or a derived sequence for `OpenSpec`).
243    pub id: String,
244    /// Phase number (0 for the implicit/leading phase).
245    pub phase: u32,
246    /// Whether the task carries a `[P]` parallel marker (Spec Kit only).
247    pub parallel: bool,
248    /// Description text.
249    pub description: String,
250    /// Completion state.
251    pub complete: bool,
252}
253
254/// Returns the tasks for a named spec. Empty when the spec has no tasks or is
255/// not found.
256#[must_use]
257pub fn get_tasks(ctx: &RepoContext, spec: &str) -> Vec<TaskInfo> {
258    let d = discover(ctx);
259    let Some(entry) = d.entries.iter().find(|e| e.id == spec) else {
260        return Vec::new();
261    };
262    let spec_dir = d.repo_root.join(&d.dir).join(spec);
263    let tasks_path = spec_dir.join("tasks.md");
264    let Ok(content) = std::fs::read_to_string(&tasks_path) else {
265        return Vec::new();
266    };
267
268    match entry.backend {
269        SpecBackendKind::SpecKit => specs::speckit::parse_tasks_md(&content)
270            .into_iter()
271            .flat_map(|phase| {
272                phase.tasks.into_iter().map(move |t| TaskInfo {
273                    id: t.id,
274                    phase: t.phase,
275                    parallel: t.p_marker,
276                    description: t.description,
277                    complete: t.complete,
278                })
279            })
280            .collect(),
281        // OpenSpec / Markdown tasks.md: flat checkbox list, phases from `## `.
282        _ => parse_checkbox_tasks(&content),
283    }
284}
285
286/// Parses a flat Markdown checkbox task list, tracking `## ` headings as
287/// phases. Used for `OpenSpec` `tasks.md`.
288fn parse_checkbox_tasks(content: &str) -> Vec<TaskInfo> {
289    let mut phase = 0u32;
290    let mut seq = 0u32;
291    let mut out = Vec::new();
292    for line in content.lines() {
293        let t = line.trim();
294        if t.starts_with("## ") {
295            phase += 1;
296            continue;
297        }
298        let Some(rest) = t.strip_prefix("- [").or_else(|| t.strip_prefix("* [")) else {
299            continue;
300        };
301        let Some(mark) = rest.chars().next() else {
302            continue;
303        };
304        let desc = rest.get(2..).unwrap_or("").trim().to_string();
305        seq += 1;
306        out.push(TaskInfo {
307            id: format!("{seq}"),
308            phase,
309            parallel: false,
310            description: desc,
311            complete: mark == 'x' || mark == 'X',
312        });
313    }
314    out
315}
316
317/// Spec dependency graph node.
318#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
319pub struct GraphNode {
320    /// Spec id.
321    pub id: String,
322    /// Backend string.
323    pub backend: String,
324}
325
326/// Spec dependency graph edge (`from` depends on / references `to`).
327#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
328pub struct GraphEdge {
329    /// Source spec id.
330    pub from: String,
331    /// Referenced spec id (the `[[target]]`).
332    pub to: String,
333}
334
335/// The dependency graph derived from `[[other-spec]]` cross-references.
336#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
337pub struct DependencyGraph {
338    /// Specs as nodes.
339    pub nodes: Vec<GraphNode>,
340    /// Cross-reference edges.
341    pub edges: Vec<GraphEdge>,
342}
343
344/// Extracts `[[name]]` reference tokens from text.
345fn extract_refs(text: &str) -> Vec<String> {
346    let mut refs = Vec::new();
347    let bytes = text.as_bytes();
348    let mut i = 0;
349    while i + 1 < bytes.len() {
350        if bytes[i] == b'['
351            && bytes[i + 1] == b'['
352            && let Some(end) = text[i + 2..].find("]]")
353        {
354            let name = text[i + 2..i + 2 + end].trim().to_string();
355            if !name.is_empty() {
356                refs.push(name);
357            }
358            i = i + 2 + end + 2;
359            continue;
360        }
361        i += 1;
362    }
363    refs
364}
365
366/// Builds the spec dependency graph from cross-references in each spec's text.
367#[must_use]
368pub fn dependency_graph(ctx: &RepoContext) -> DependencyGraph {
369    let d = discover(ctx);
370    let ids: std::collections::HashSet<String> = d.entries.iter().map(|e| e.id.clone()).collect();
371
372    let nodes = d
373        .entries
374        .iter()
375        .map(|e| GraphNode {
376            id: e.id.clone(),
377            backend: backend_str(e.backend).to_string(),
378        })
379        .collect();
380
381    let mut edges = Vec::new();
382    let mut seen = std::collections::HashSet::new();
383    for entry in &d.entries {
384        // Prefer the proposal for OpenSpec; otherwise scan the prompt text.
385        let spec_dir = d.repo_root.join(&d.dir).join(&entry.id);
386        let text = std::fs::read_to_string(spec_dir.join("proposal.md"))
387            .unwrap_or_else(|_| entry.prompt.clone());
388        for target in extract_refs(&text) {
389            // Only record edges to other discovered specs.
390            if ids.contains(&target) && target != entry.id {
391                let key = (entry.id.clone(), target.clone());
392                if seen.insert(key) {
393                    edges.push(GraphEdge {
394                        from: entry.id.clone(),
395                        to: target,
396                    });
397                }
398            }
399        }
400    }
401    edges.sort_by_key(|a| (a.from.clone(), a.to.clone()));
402
403    DependencyGraph { nodes, edges }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn ctx_for(root: &Path) -> RepoContext {
411        RepoContext {
412            root: root.to_path_buf(),
413            git_paw_dir: None,
414            broker_url: None,
415            server_name: "git-paw".to_string(),
416        }
417    }
418
419    /// Builds a repo with an `OpenSpec` change and the matching `[specs]` config.
420    fn openspec_repo() -> tempfile::TempDir {
421        let tmp = tempfile::tempdir().unwrap();
422        let root = tmp.path();
423        std::fs::create_dir_all(root.join(".git-paw")).unwrap();
424        std::fs::write(
425            root.join(".git-paw/config.toml"),
426            "[specs]\ndir = \"openspec/changes\"\ntype = \"openspec\"\n",
427        )
428        .unwrap();
429        let change = root.join("openspec/changes/add-auth");
430        std::fs::create_dir_all(&change).unwrap();
431        std::fs::write(
432            change.join("tasks.md"),
433            "## 1. Setup\n- [x] 1.1 scaffold\n- [ ] 1.2 wire it\n",
434        )
435        .unwrap();
436        std::fs::write(
437            change.join("proposal.md"),
438            "# Add auth\n\nDepends on [[other-change]].\n",
439        )
440        .unwrap();
441        // Second change so the [[other-change]] edge resolves.
442        let other = root.join("openspec/changes/other-change");
443        std::fs::create_dir_all(&other).unwrap();
444        std::fs::write(other.join("tasks.md"), "- [ ] do thing\n").unwrap();
445        tmp
446    }
447
448    #[test]
449    fn list_specs_discovers_openspec_changes() {
450        let tmp = openspec_repo();
451        let specs = list_specs(&ctx_for(tmp.path()));
452        assert!(
453            specs
454                .iter()
455                .any(|s| s.id == "add-auth" && s.backend == "openspec")
456        );
457        let auth = specs.iter().find(|s| s.id == "add-auth").unwrap();
458        assert_eq!(auth.title, "Add auth");
459        assert!(auth.path.contains("openspec/changes/add-auth"));
460    }
461
462    #[test]
463    fn list_specs_empty_when_no_config() {
464        let tmp = tempfile::tempdir().unwrap();
465        assert!(list_specs(&ctx_for(tmp.path())).is_empty());
466    }
467
468    #[test]
469    fn get_spec_returns_artifacts() {
470        let tmp = openspec_repo();
471        let detail = get_spec(&ctx_for(tmp.path()), "add-auth").expect("found");
472        assert_eq!(detail.backend, "openspec");
473        assert!(detail.artifacts.iter().any(|a| a.name == "tasks"));
474        assert!(detail.artifacts.iter().any(|a| a.name == "proposal"));
475    }
476
477    #[test]
478    fn get_spec_unknown_is_none() {
479        let tmp = openspec_repo();
480        assert!(get_spec(&ctx_for(tmp.path()), "nope").is_none());
481    }
482
483    #[test]
484    fn get_tasks_parses_openspec_checkboxes() {
485        let tmp = openspec_repo();
486        let tasks = get_tasks(&ctx_for(tmp.path()), "add-auth");
487        assert_eq!(tasks.len(), 2);
488        assert!(tasks[0].complete);
489        assert!(!tasks[1].complete);
490        assert_eq!(tasks[0].phase, 1);
491    }
492
493    #[test]
494    fn dependency_graph_resolves_cross_refs() {
495        let tmp = openspec_repo();
496        let graph = dependency_graph(&ctx_for(tmp.path()));
497        assert!(graph.nodes.iter().any(|n| n.id == "add-auth"));
498        assert!(
499            graph
500                .edges
501                .iter()
502                .any(|e| e.from == "add-auth" && e.to == "other-change")
503        );
504    }
505
506    #[test]
507    fn extract_refs_finds_double_bracket_tokens() {
508        let refs = extract_refs("see [[a]] and [[ b ]] but not [single]");
509        assert_eq!(refs, vec!["a", "b"]);
510    }
511}