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
197#[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
211pub fn scan_specs(config: &PawConfig, repo_root: &Path) -> Result<Vec<SpecEntry>, PawError> {
221 scan_specs_with_override(config, repo_root, None)
222}
223
224pub 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 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 #[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 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 #[test]
451 fn explicit_config_wins_over_auto_detection() {
452 let tmp = tempfile::tempdir().unwrap();
453 fs::create_dir_all(tmp.path().join(".specify").join("specs")).unwrap();
455 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 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 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 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}