1use 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
17fn backend_str(kind: SpecBackendKind) -> &'static str {
19 match kind {
20 SpecBackendKind::OpenSpec => "openspec",
21 SpecBackendKind::Markdown => "markdown",
22 SpecBackendKind::SpecKit => "speckit",
23 }
24}
25
26struct Discovery {
28 repo_root: PathBuf,
29 dir: String,
31 entries: Vec<SpecEntry>,
32}
33
34fn 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
47fn 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
68fn 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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
88pub struct SpecInfo {
89 pub id: String,
91 pub backend: String,
93 pub title: String,
95 pub status: String,
97 pub path: String,
99}
100
101#[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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
122pub struct Artifact {
123 pub name: String,
125 pub content: String,
127}
128
129#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
131pub struct SpecDetail {
132 pub id: String,
134 pub backend: String,
136 pub path: String,
138 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#[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 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 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 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
215fn 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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
241pub struct TaskInfo {
242 pub id: String,
244 pub phase: u32,
246 pub parallel: bool,
248 pub description: String,
250 pub complete: bool,
252}
253
254#[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 _ => parse_checkbox_tasks(&content),
283 }
284}
285
286fn 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#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
319pub struct GraphNode {
320 pub id: String,
322 pub backend: String,
324}
325
326#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
328pub struct GraphEdge {
329 pub from: String,
331 pub to: String,
333}
334
335#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
337pub struct DependencyGraph {
338 pub nodes: Vec<GraphNode>,
340 pub edges: Vec<GraphEdge>,
342}
343
344fn 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#[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 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 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 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 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}