use std::path::{Path, PathBuf};
use crate::archive_layout;
use crate::openspec::VerificationDeclaration;
use crate::orchestration::acceptance::evidence_location::{prepare_store, AcceptanceStore};
use crate::orchestration::acceptance::verification_evidence::{
decide_reuse, eligible_request, plan_reuse, CaptureOutcome, EvidenceStore, GitRepositoryFacts,
ReusePolicy, RuntimeVerificationExecutor, SystemClock, VerificationRequest,
};
const EXIT_UNSATISFIED: i32 = 1;
fn proposal_path(workspace: &Path, change_id: &str) -> Result<PathBuf, String> {
let active = workspace
.join(archive_layout::ACTIVE_CHANGES_PREFIX)
.join(change_id)
.join("proposal.md");
if active.is_file() {
return Ok(active);
}
let archive_dir = workspace.join(archive_layout::ARCHIVE_PREFIX);
match archive_layout::find_archived_proposal(change_id, &archive_dir) {
Ok(Some(archived)) => Ok(archived),
Ok(None) => Err(format!(
"Change '{change_id}' has no proposal at {} and no archived proposal under {}",
active.display(),
archive_dir.display()
)),
Err(error) => Err(error.message()),
}
}
fn declarations(workspace: &Path, change_id: &str) -> Result<Vec<VerificationDeclaration>, String> {
let proposal = proposal_path(workspace, change_id)?;
Ok(crate::openspec::parse_proposal_metadata_from_file(&proposal).verifications)
}
fn select(
declarations: Vec<VerificationDeclaration>,
verification_id: Option<&str>,
) -> Result<Vec<VerificationDeclaration>, String> {
let Some(wanted) = verification_id else {
return Ok(declarations);
};
let selected: Vec<_> = declarations
.into_iter()
.filter(|declaration| declaration.id.as_deref().map(str::trim) == Some(wanted))
.collect();
if selected.is_empty() {
return Err(format!(
"verification-id '{wanted}' is not declared in proposal.md verifications"
));
}
Ok(selected)
}
fn report_envelope(store: &EvidenceStore, body: serde_json::Value) -> serde_json::Value {
let mut envelope = body;
if let Some(object) = envelope.as_object_mut() {
object.insert(
"store_root".to_string(),
serde_json::Value::String(store.root().display().to_string()),
);
}
envelope
}
async fn report_plan(
workspace: &Path,
store: &EvidenceStore,
declarations: &[VerificationDeclaration],
json_output: bool,
) -> Result<(), String> {
let plan = plan_reuse(
&GitRepositoryFacts,
workspace,
store,
declarations,
ReusePolicy::default(),
)
.await;
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&report_envelope(store, plan.to_json()))
.unwrap_or_default()
);
return Ok(());
}
if plan.is_empty() {
println!("No repository-local verifications are declared.");
return Ok(());
}
println!("Evidence store: {}", store.root().display());
for decision in &plan.decisions {
println!("{}", decision.summary());
}
Ok(())
}
async fn run_verifications(
workspace: &Path,
store: &EvidenceStore,
declarations: &[VerificationDeclaration],
json_output: bool,
) -> Result<i32, String> {
let facts = GitRepositoryFacts;
let policy = ReusePolicy::default();
let mut reports = Vec::new();
let mut unsatisfied = 0;
for declaration in declarations {
let Some(decision) = decide_reuse(&facts, workspace, store, declaration, policy).await
else {
continue;
};
if decision.is_reuse() {
reports.push(decision.to_json());
continue;
}
let request: VerificationRequest = match eligible_request(declaration) {
Ok(request) => request,
Err(reason) => {
reports.push(serde_json::json!({
"verification_id": declaration.id.clone().unwrap_or_default(),
"outcome": "not_captured",
"reason": reason.code(),
"detail": reason.detail(),
}));
unsatisfied += 1;
continue;
}
};
let executor = RuntimeVerificationExecutor::new(
workspace.to_path_buf(),
store.clone(),
GitRepositoryFacts,
crate::orchestration::acceptance::verification_evidence::DirectCommandSupervisor,
SystemClock,
);
let outcome = executor.capture(&request).await;
if !matches!(outcome, CaptureOutcome::Captured { .. }) {
unsatisfied += 1;
}
reports.push(outcome.to_json());
}
if json_output {
println!(
"{}",
serde_json::to_string_pretty(&report_envelope(
store,
serde_json::json!({"verifications": reports})
))
.unwrap_or_default()
);
} else if reports.is_empty() {
println!("No repository-local verifications are declared.");
} else {
println!("Evidence store: {}", store.root().display());
for report in &reports {
println!(
"{}: {}{}{}",
report["verification_id"].as_str().unwrap_or_default(),
report["outcome"].as_str().unwrap_or_default(),
report["detail"]
.as_str()
.map(|detail| format!(" — {detail}"))
.unwrap_or_default(),
report["artifact_location"]
.as_str()
.map(|location| format!(" — {location}"))
.unwrap_or_default()
);
}
}
Ok(if unsatisfied == 0 {
0
} else {
EXIT_UNSATISFIED
})
}
pub async fn cmd_verify(
change_id: &str,
verification_id: Option<&str>,
plan_only: bool,
json_output: bool,
) -> Result<i32, String> {
let workspace = std::env::current_dir()
.map_err(|error| format!("Failed to resolve the current directory: {error}"))?;
let workspace: PathBuf = workspace;
let selected = select(declarations(&workspace, change_id)?, verification_id)?;
let store = resolve_store(&workspace, change_id)?;
if plan_only {
report_plan(&workspace, &store.evidence(), &selected, json_output).await?;
return Ok(0);
}
run_verifications(&workspace, &store.evidence(), &selected, json_output).await
}
fn resolve_store(workspace: &Path, change_id: &str) -> Result<AcceptanceStore, String> {
let state_base_dir = crate::config::OrchestratorConfig::load_storage_settings(None)
.ok()
.and_then(|config| config.get_state_base_dir().map(str::to_string));
prepare_store(state_base_dir.as_deref(), workspace, workspace, change_id).map_err(|refusal| {
format!(
"Verification evidence cannot be stored outside the repository: {}",
refusal.evidence()
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orchestration::acceptance::verification_evidence::{
EvidenceDefect, RepositoryFacts, RerunReason, ReuseDecision, ToolIdentity,
VerificationReusePlan,
};
fn declaration(id: &str) -> VerificationDeclaration {
VerificationDeclaration {
id: Some(id.to_string()),
execution_class: Some("repository-local".to_string()),
completion_role: Some("change-blocking".to_string()),
automation: Some("Cargo.toml".to_string()),
evidence: Some("cargo test".to_string()),
rerun: Some("cargo test".to_string()),
..VerificationDeclaration::default()
}
}
#[test]
fn selection_keeps_only_the_named_verification() {
let selected = select(
vec![declaration("alpha"), declaration("beta")],
Some("beta"),
)
.expect("selection succeeds");
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].id.as_deref(), Some("beta"));
}
#[test]
fn selection_refuses_an_undeclared_verification_id() {
let error = select(vec![declaration("alpha")], Some("missing")).unwrap_err();
assert!(error.contains("is not declared"));
}
#[test]
fn selection_without_a_filter_keeps_every_declaration() {
let selected = select(vec![declaration("alpha"), declaration("beta")], None).unwrap();
assert_eq!(selected.len(), 2);
}
#[test]
fn a_change_without_a_proposal_is_an_error_rather_than_an_empty_plan() {
let workspace = tempfile::tempdir().expect("tempdir");
let error = declarations(workspace.path(), "absent-change").unwrap_err();
assert!(error.contains("has no proposal"));
}
#[test]
fn declarations_are_read_from_the_workspace_proposal() {
let workspace = tempfile::tempdir().expect("tempdir");
let change_dir = workspace.path().join("openspec/changes/example");
std::fs::create_dir_all(&change_dir).expect("create change dir");
std::fs::write(
change_dir.join("proposal.md"),
"---\nverifications:\n - id: local-tests\n phase: pre-integration\n execution_class: repository-local\n completion_role: change-blocking\n prerequisites: []\n---\n# Example\n",
)
.expect("write proposal");
let declared = declarations(workspace.path(), "example").expect("declarations");
assert_eq!(declared.len(), 1);
assert_eq!(declared[0].id.as_deref(), Some("local-tests"));
}
const PROPOSAL: &str = "---\nverifications:\n - id: local-tests\n phase: pre-integration\n automation: Cargo.toml\n rerun: cargo test --lib\n execution_class: repository-local\n completion_role: change-blocking\n prerequisites: []\n---\n# Example\n";
fn write_proposal(workspace: &Path, relative_dir: &str) {
let dir = workspace.join(relative_dir);
std::fs::create_dir_all(&dir).expect("create proposal dir");
std::fs::write(dir.join("proposal.md"), PROPOSAL).expect("write proposal");
}
struct UnobservedFacts;
#[async_trait::async_trait]
impl RepositoryFacts for UnobservedFacts {
async fn head_commit(&self, _workspace: &Path) -> Result<String, String> {
Err("repository facts must not be observed here".to_string())
}
async fn head_tree(&self, _workspace: &Path) -> Result<String, String> {
Err("repository facts must not be observed here".to_string())
}
async fn tracked_blob_oid(&self, _workspace: &Path, _path: &str) -> Result<String, String> {
Err("repository facts must not be observed here".to_string())
}
async fn hash_file(&self, _workspace: &Path, _path: &Path) -> Result<String, String> {
Err("repository facts must not be observed here".to_string())
}
async fn porcelain_status(&self, _workspace: &Path) -> Result<String, String> {
Err("repository facts must not be observed here".to_string())
}
async fn resolve_tool(
&self,
_workspace: &Path,
_program: &str,
) -> Result<ToolIdentity, String> {
Err("repository facts must not be observed here".to_string())
}
}
#[tokio::test]
async fn an_archived_change_keeps_its_verification_id_and_repository_level_automation() {
let workspace = tempfile::tempdir().expect("tempdir");
write_proposal(
workspace.path(),
"openspec/changes/archive/2026-07-09-example",
);
assert_eq!(
proposal_path(workspace.path(), "example").expect("archived proposal"),
workspace
.path()
.join("openspec/changes/archive/2026-07-09-example/proposal.md")
);
let declared = declarations(workspace.path(), "example").expect("declarations");
assert_eq!(declared.len(), 1);
assert_eq!(declared[0].id.as_deref(), Some("local-tests"));
let request = eligible_request(&declared[0]).expect("supervisable request");
assert_eq!(request.verification_id, "local-tests");
assert_eq!(request.automation_path, "Cargo.toml");
assert_eq!(request.cwd, ".");
assert_eq!(request.argv, vec!["cargo", "test", "--lib"]);
let plan = plan_reuse(
&UnobservedFacts,
workspace.path(),
&EvidenceStore::new(workspace.path().join("../external-acceptance-store")),
&declared,
ReusePolicy::default(),
)
.await;
assert_eq!(plan.decisions.len(), 1);
assert_eq!(plan.decisions[0].verification_id(), "local-tests");
}
#[test]
fn an_archived_change_resolves_from_the_direct_entry_too() {
let workspace = tempfile::tempdir().expect("tempdir");
write_proposal(workspace.path(), "openspec/changes/archive/example");
assert_eq!(
proposal_path(workspace.path(), "example").expect("archived proposal"),
workspace
.path()
.join("openspec/changes/archive/example/proposal.md")
);
}
#[test]
fn an_active_proposal_outranks_a_same_named_archive_entry() {
let workspace = tempfile::tempdir().expect("tempdir");
write_proposal(workspace.path(), "openspec/changes/example");
write_proposal(
workspace.path(),
"openspec/changes/archive/2026-07-09-example",
);
write_proposal(workspace.path(), "openspec/changes/archive/example");
assert_eq!(
proposal_path(workspace.path(), "example").expect("active proposal"),
workspace
.path()
.join("openspec/changes/example/proposal.md")
);
assert_eq!(
declarations(workspace.path(), "example")
.expect("declarations")
.len(),
1
);
}
#[test]
fn competing_archive_entries_fail_closed_instead_of_picking_one() {
let workspace = tempfile::tempdir().expect("tempdir");
write_proposal(
workspace.path(),
"openspec/changes/archive/2026-07-09-example",
);
write_proposal(workspace.path(), "openspec/changes/archive/example");
let error = declarations(workspace.path(), "example").unwrap_err();
assert!(error.contains("Ambiguous archive for 'example'"));
assert!(error.contains("2026-07-09-example"));
}
#[test]
fn a_nested_archive_layout_reports_the_repository_diagnostic() {
let workspace = tempfile::tempdir().expect("tempdir");
write_proposal(
workspace.path(),
"openspec/changes/archive/2026-07-09/example",
);
let error = declarations(workspace.path(), "example").unwrap_err();
assert!(error.contains("Invalid archive layout for 'example'"));
assert!(error.contains("2026-07-09/example"));
}
#[test]
fn non_canonical_or_proposal_less_archive_entries_never_resolve() {
let workspace = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(workspace.path().join("openspec/changes/archive/example"))
.expect("create empty entry");
write_proposal(workspace.path(), "openspec/changes/archive/prefix-example");
write_proposal(
workspace.path(),
"openspec/changes/archive/2026-7-9-example",
);
write_proposal(workspace.path(), "openspec/changes/archive/other-change");
let error = declarations(workspace.path(), "example").unwrap_err();
assert!(error.contains("has no proposal"));
assert!(error.contains("no archived proposal"));
}
fn store_with_artifact(root: &Path, verification_id: &str, output: &[u8]) -> EvidenceStore {
let store = EvidenceStore::new(root.join("cflx-state/acceptance/project/workspace/change"));
store.ensure_directory().expect("create store directory");
std::fs::write(store.artifact_path(verification_id), output).expect("write artifact");
store
}
#[test]
fn a_json_report_lets_a_caller_locate_the_captured_artifact() {
let temp = tempfile::tempdir().expect("tempdir");
let store = store_with_artifact(temp.path(), "local-tests", b"error: 1 test failed");
let outcome = CaptureOutcome::CommandFailed {
verification_id: "local-tests".to_string(),
exit_code: 101,
artifact_path: EvidenceStore::artifact_relative_path("local-tests"),
artifact_location: store.artifact_path("local-tests"),
};
let report = report_envelope(
&store,
serde_json::json!({"verifications": [outcome.to_json()]}),
);
let entry = &report["verifications"][0];
let location = entry["artifact_location"]
.as_str()
.expect("a failing verification names an openable artifact");
assert_eq!(
std::fs::read(location).expect("the reported location opens"),
b"error: 1 test failed"
);
let root = report["store_root"]
.as_str()
.expect("the report names the resolved store root");
assert_eq!(root, store.root().display().to_string());
let relative = entry["artifact_path"]
.as_str()
.expect("the store-relative artifact path is preserved");
assert_eq!(relative, "gates/local-tests.log");
assert_eq!(
std::fs::read(Path::new(root).join(relative))
.expect("store_root + artifact_path opens"),
b"error: 1 test failed"
);
}
#[test]
fn a_plan_report_carries_the_store_root_it_was_resolved_against() {
let temp = tempfile::tempdir().expect("tempdir");
let store = store_with_artifact(temp.path(), "local-tests", b"test result: ok");
let plan = VerificationReusePlan {
decisions: vec![ReuseDecision::Rerun {
verification_id: "local-tests".to_string(),
reason: RerunReason::Defect(EvidenceDefect::Missing),
}],
};
let report = report_envelope(&store, plan.to_json());
assert_eq!(
report["store_root"].as_str(),
Some(store.root().display().to_string().as_str())
);
assert_eq!(report["verifications"], plan.to_json()["verifications"]);
assert_eq!(report["rerun"], serde_json::json!(["local-tests"]));
}
#[test]
fn a_reuse_decision_renders_one_operator_line() {
let decision = ReuseDecision::Rerun {
verification_id: "local-tests".to_string(),
reason: RerunReason::Defect(EvidenceDefect::Missing),
};
let line = decision.summary();
assert!(line.starts_with("local-tests: rerun"));
assert!(line.contains("evidence_missing"));
}
}