#![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)
}
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()
}
#[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)
);
}
#[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));
assert_eq!(
ids_in(&s, TaskState::Ready),
[
"T010", "T011", "T012", "T013", "T018", "T019", "T020", "T024", "T025", "T026"
]
);
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();
assert_eq!(ids_in(&s, TaskState::Ready), ["T002", "T003"]);
}
#[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"
);
}
#[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());
}