use crate::git::GitFlow;
use crate::stage::Stage;
use crate::{events, workflow};
use serde::Serialize;
use std::path::Path;
const STOPPED_AT_REASON: &str = "stopped_at";
const WORKFLOW_SHIPPED_EVENT: &str = "workflow_shipped";
const WORKFLOW_FINISHED_EVENT: &str = "workflow_finished";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ShipEvidence {
pub phase: u32,
pub shipped: bool,
pub workflow_finished_seen: bool,
pub finished_reason: Option<String>,
pub stage: Option<Stage>,
pub state_present: bool,
pub feature_branch_exists: bool,
pub merged_into_develop: bool,
pub has_remote: bool,
}
pub fn collect(project_root: &Path, phase: u32) -> ShipEvidence {
let shipped = events::has_event_for_phase(project_root, phase, WORKFLOW_SHIPPED_EVENT);
let last_finished =
events::last_event_of_kind_for_phase(project_root, phase, WORKFLOW_FINISHED_EVENT);
let workflow_finished_seen = last_finished.is_some();
let finished_reason = last_finished
.as_ref()
.and_then(|event| event.get("reason"))
.and_then(|reason| reason.as_str())
.map(str::to_owned);
let (stage, state_present) = match workflow::load_state(project_root, phase) {
Ok(state) => (Some(state.stage), true),
Err(_) => (None, false),
};
let git = GitFlow::new(project_root);
let branch = format!(
"{}phase-{:02}",
crate::config::GitFlowConfig::default().feature_prefix,
phase
);
let feature_branch_exists = git.branch_exists(&branch);
let merged_into_develop = git.is_merged_into_develop(phase);
let has_remote = git.has_remote();
ShipEvidence {
phase,
shipped,
workflow_finished_seen,
finished_reason,
stage,
state_present,
feature_branch_exists,
merged_into_develop,
has_remote,
}
}
pub fn is_stopped_at(evidence: &ShipEvidence) -> bool {
evidence.finished_reason.as_deref() == Some(STOPPED_AT_REASON)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{AgentKind, State};
fn init_repo(root: &Path) {
let git = |args: &[&str]| {
let ok = crate::test_support::git_command(root)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
};
git(&["init", "-q"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "Test"]);
git(&["config", "commit.gpgsign", "false"]);
git(&["config", "core.hooksPath", "/dev/null"]);
std::fs::write(root.join("README.md"), "init\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "-M", "main"]);
git(&["checkout", "-q", "-b", "develop"]);
}
#[test]
fn stopped_at_phase_reports_not_shipped_but_corroborates_finished() {
let dir = tempfile::tempdir().unwrap();
events::emit(
dir.path(),
42,
"workflow_finished",
serde_json::json!({"reason": "stopped_at", "stage": "plan"}),
);
let evidence = collect(dir.path(), 42);
assert!(
!evidence.shipped,
"a phase that only stopped must not read as shipped"
);
assert!(evidence.workflow_finished_seen);
assert_eq!(evidence.finished_reason.as_deref(), Some("stopped_at"));
assert!(is_stopped_at(&evidence));
}
#[test]
fn shipped_event_is_true_only_for_the_phase_it_names() {
let dir = tempfile::tempdir().unwrap();
events::emit(
dir.path(),
7,
"workflow_shipped",
serde_json::json!({"stage": "ship"}),
);
assert!(collect(dir.path(), 7).shipped);
assert!(!collect(dir.path(), 8).shipped);
}
#[test]
fn shipped_predicate_consults_no_git_field() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
crate::test_support::git_command(dir.path())
.args(["branch", "feature/phase-05", "develop"])
.status()
.unwrap();
crate::test_support::git_command(dir.path())
.args([
"remote",
"add",
"origin",
"https://example.invalid/repo.git",
])
.status()
.unwrap();
let evidence = collect(dir.path(), 5);
assert!(evidence.feature_branch_exists);
assert!(evidence.merged_into_develop);
assert!(evidence.has_remote);
assert!(
!evidence.shipped,
"shipped must not be inferred from any git field"
);
}
#[test]
fn torn_final_line_does_not_hide_an_earlier_shipped_event() {
let dir = tempfile::tempdir().unwrap();
events::emit(
dir.path(),
9,
"workflow_shipped",
serde_json::json!({"stage": "ship"}),
);
let path = events::events_path(dir.path());
let mut contents = std::fs::read_to_string(&path).unwrap();
contents.push_str("{truncated\n");
std::fs::write(&path, contents).unwrap();
assert!(collect(dir.path(), 9).shipped);
}
#[test]
fn missing_devflow_dir_degrades_safely_without_panicking() {
let dir = tempfile::tempdir().unwrap();
let evidence = collect(dir.path(), 1);
assert!(!evidence.shipped);
assert!(!evidence.state_present);
assert!(evidence.stage.is_none());
assert!(!evidence.workflow_finished_seen);
}
#[test]
fn collect_reports_stage_and_state_present_from_live_state() {
let dir = tempfile::tempdir().unwrap();
let state = State::new(
3,
AgentKind::Claude,
crate::mode::Mode::Auto,
dir.path().to_path_buf(),
);
workflow::save_state(&state).unwrap();
let evidence = collect(dir.path(), 3);
assert!(evidence.state_present);
assert_eq!(evidence.stage, Some(Stage::Define));
assert!(!evidence.shipped);
}
}