speccoder-speckit 0.12.0

GitHub Spec Kit adapter for speccoder: artifact discovery and tasks.md parsing.
Documentation
//! Real-world Spec Kit corpus: projects generated by
//! `specify init` 1.1.3 and 1.2.2, the Explicit Task Dependencies preset's task
//! format, and a converge-appended task list.

// Test code: panicking on unexpected input is the failure report.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use std::fs;
use std::path::{Path, PathBuf};

use speccoder_core::snapshot::{
    ConstitutionState, DependencyMode, ProjectSnapshot, SelectionReason, Severity, TaskState,
};
use speccoder_speckit::{inspect, inspect_with};

fn real(name: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/real")
        .join(name)
}

/// Copies a fixture into a temp dir so a test can vary it.
fn copy(name: &str) -> tempfile::TempDir {
    fn walk(from: &Path, to: &Path) {
        fs::create_dir_all(to).unwrap();
        for e in fs::read_dir(from).unwrap().flatten() {
            let target = to.join(e.file_name());
            if e.file_type().unwrap().is_dir() {
                walk(&e.path(), &target);
            } else {
                fs::copy(e.path(), target).unwrap();
            }
        }
    }
    let dir = tempfile::tempdir().unwrap();
    walk(&real(name), dir.path());
    dir
}

fn codes(s: &ProjectSnapshot) -> Vec<&str> {
    s.diagnostics.iter().map(|d| d.code.as_str()).collect()
}

fn ids_in(s: &ProjectSnapshot, state: TaskState) -> Vec<String> {
    s.feature
        .as_ref()
        .unwrap()
        .schedule
        .as_ref()
        .unwrap()
        .tasks
        .iter()
        .filter(|t| t.state == state)
        .map(|t| t.id.clone())
        .collect()
}

// ── US1: real projects ─────────────────────────────────────────────────────

#[test]
fn speckit_1_1_3_project_with_the_unmodified_task_template() {
    let s = inspect(&real("speckit-1.1.3"), None).unwrap();
    assert_eq!(s.speckit.version.as_deref(), Some("1.1.3"));
    assert_eq!(s.speckit.integration.as_deref(), Some("claude"));
    assert_eq!(s.speckit.script.as_deref(), Some("sh"));
    assert_eq!(
        s.speckit.current_feature, None,
        "feature.json is per-checkout (ignored by Spec Kit)"
    );
    assert_eq!(s.constitution, ConstitutionState::Unfilled);
    assert_eq!(s.selection.reason, SelectionReason::OnlyFeature);

    let mut c = codes(&s);
    c.sort_unstable();
    c.dedup();
    assert_eq!(c, ["discover.constitution-unfilled", "tasks.missing-id"]);
    let placeholders = s
        .diagnostics
        .iter()
        .filter(|d| d.code == "tasks.missing-id")
        .count();
    assert_eq!(placeholders, 6, "the six TXXX polish samples");

    let t = s.feature.as_ref().unwrap().tasks.as_ref().unwrap();
    assert_eq!(t.totals.total, 28);
    assert_eq!(t.dependency_mode, DependencyMode::Structural);
    assert_eq!(ids_in(&s, TaskState::Ready), ["T001"]);
}

#[test]
fn speckit_1_2_2_metadata_parses_without_warnings() {
    let s = inspect(&real("speckit-1.2.2"), Some("001")).unwrap();
    assert_eq!(s.speckit.version.as_deref(), Some("1.2.2"));
    assert!(
        !codes(&s).iter().any(|c| c.starts_with("discover.metadata")),
        "{:?}",
        codes(&s)
    );
}

// ── US2: declared dependencies ─────────────────────────────────────────────

#[test]
fn preset_ready_set_is_the_presets_wave_5() {
    let s = inspect(&real("preset-explicit-deps"), None).unwrap();
    let t = s.feature.as_ref().unwrap().tasks.as_ref().unwrap();
    assert_eq!(t.dependency_mode, DependencyMode::Declared);
    assert!(!s.has_errors(), "{:?}", codes(&s));
    // "Wave 5 (Phase 2 complete — all user stories can begin in parallel)"
    assert_eq!(
        ids_in(&s, TaskState::Ready),
        [
            "T010", "T011", "T012", "T013", "T018", "T019", "T020", "T024", "T025", "T026"
        ]
    );
    // Waves inside the preset's ```text fence are not tasks.
    assert_eq!(t.totals.total, 34);
}

#[test]
fn preset_with_only_t001_done_is_the_presets_wave_2() {
    let dir = copy("preset-explicit-deps");
    let path = dir.path().join("specs/001-shop/tasks.md");
    let text = fs::read_to_string(&path).unwrap();
    let mut reopened = text.clone();
    for n in 2..=9 {
        reopened = reopened.replace(&format!("- [x] T{n:03}"), &format!("- [ ] T{n:03}"));
    }
    assert_ne!(text, reopened);
    fs::write(&path, reopened).unwrap();

    let s = inspect(dir.path(), None).unwrap();
    // "Wave 2 (T001 done): T002, T003" — T003 is [P] after non-[P] T002.
    assert_eq!(ids_in(&s, TaskState::Ready), ["T002", "T003"]);
}

// ── US3: converge-appended phases ──────────────────────────────────────────

#[test]
fn convergence_rounds_follow_everything_else() {
    let s = inspect(&real("converge-appended"), None).unwrap();
    assert!(!s.has_errors(), "{:?}", codes(&s));
    let sched = s.feature.as_ref().unwrap().schedule.as_ref().unwrap();
    let rows: Vec<_> = sched
        .tasks
        .iter()
        .filter(|t| t.state != TaskState::Done)
        .map(|t| (t.id.as_str(), t.state, t.blocked_by.clone()))
        .collect();
    assert_eq!(
        rows,
        [
            ("T005", TaskState::Ready, vec![]),
            ("T006", TaskState::Blocked, vec!["T005".to_owned()]),
            (
                "T007",
                TaskState::Blocked,
                vec!["T005".to_owned(), "T006".to_owned()]
            ),
        ]
    );
    let tasks = &s.feature.as_ref().unwrap().tasks.as_ref().unwrap().tasks;
    assert!(
        tasks.iter().all(|t| t.depends_on.is_empty()),
        "`per FR-004 (missing)` is not a reference"
    );
}

// ── US4: Spec Kit's feature resolution order ───────────────────────────────

#[test]
fn several_features_in_a_fresh_clone_need_a_selection() {
    let s = inspect(&real("speckit-1.2.2"), None).unwrap();
    assert_eq!(s.selection.reason, SelectionReason::None);
    assert!(codes(&s).contains(&"select.ambiguous"));
}

#[test]
fn specify_feature_directory_selects_and_wins_over_feature_json() {
    let s = inspect_with(&real("speckit-1.2.2"), None, Some("specs/002-reports")).unwrap();
    assert_eq!(s.selection.reason, SelectionReason::Environment);
    assert_eq!(s.selection.feature.as_deref(), Some("002-reports"));

    let dir = copy("speckit-1.2.2");
    fs::write(
        dir.path().join(".specify/feature.json"),
        r#"{"feature_directory":"specs/001-search"}"#,
    )
    .unwrap();
    let recorded = inspect(dir.path(), None).unwrap();
    assert_eq!(recorded.selection.feature.as_deref(), Some("001-search"));
    let env = inspect_with(dir.path(), None, Some("./specs/002-reports/")).unwrap();
    assert_eq!(env.selection.feature.as_deref(), Some("002-reports"));
    let arg = inspect_with(dir.path(), Some("1"), Some("specs/002-reports")).unwrap();
    assert_eq!(arg.selection.reason, SelectionReason::Argument);
    assert_eq!(arg.selection.feature.as_deref(), Some("001-search"));
}

#[test]
fn invalid_specify_feature_directory_is_a_selection_error() {
    let s = inspect_with(&real("speckit-1.2.2"), None, Some("../elsewhere")).unwrap();
    let d = s
        .diagnostics
        .iter()
        .find(|d| d.code == "select.not-found")
        .unwrap();
    assert_eq!(d.severity, Severity::Error);
    assert!(
        d.message
            .starts_with("SPECIFY_FEATURE_DIRECTORY names `../elsewhere`")
    );
    assert!(s.feature.is_none());
}