use std::cell::RefCell;
use std::collections::HashMap;
use std::io;
use std::path::Path;
use super::reconcile;
use crate::ports::{Clock, CommandOutput, CommandRunner, RegistryQuery};
use crate::protocol::journal::{PublishReceipt, RunState, RunStatus};
use crate::protocol::release::VerifyOutcome;
use crate::release::adapters::EffectCtx;
#[derive(Default)]
struct RecordingCmd {
calls: RefCell<Vec<String>>,
}
impl CommandRunner for RecordingCmd {
fn run(&self, program: &str, args: &[&str], _cwd: &Path) -> io::Result<CommandOutput> {
self.calls
.borrow_mut()
.push(format!("{program} {}", args.join(" ")));
Ok(CommandOutput {
status: Some(0),
stdout: String::new(),
stderr: String::new(),
})
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now_unix(&self) -> u64 {
0
}
}
struct FakeRegistry {
versions: HashMap<(String, String), Vec<String>>,
outage: bool,
queried: RefCell<Vec<(String, String)>>,
}
impl FakeRegistry {
fn new() -> Self {
Self {
versions: HashMap::new(),
outage: false,
queried: RefCell::new(Vec::new()),
}
}
fn with(mut self, ecosystem: &str, package: &str, versions: &[&str]) -> Self {
self.versions.insert(
(ecosystem.to_string(), package.to_string()),
versions.iter().map(|s| (*s).to_string()).collect(),
);
self
}
fn outage() -> Self {
Self {
outage: true,
..Self::new()
}
}
}
impl RegistryQuery for FakeRegistry {
fn published_versions(&self, ecosystem: &str, package: &str) -> io::Result<Vec<String>> {
self.queried
.borrow_mut()
.push((ecosystem.to_string(), package.to_string()));
if self.outage {
return Err(io::Error::from(io::ErrorKind::TimedOut));
}
Ok(self
.versions
.get(&(ecosystem.to_string(), package.to_string()))
.cloned()
.unwrap_or_default())
}
}
fn receipt(
ecosystem: &str,
package: Option<&str>,
version: &str,
digest: Option<&str>,
) -> PublishReceipt {
PublishReceipt {
ecosystem: ecosystem.to_string(),
package: package.map(str::to_string),
version: version.to_string(),
registry_url: None,
digest: digest.map(str::to_string),
}
}
fn state_with(published: &[(&str, PublishReceipt)]) -> RunState {
let mut s = RunState::empty();
s.run_id = "RUN01".into();
s.plan_id = "plan-abc".into();
s.status = RunStatus::Completed;
s.applied_seq = 7;
s.targets = published.iter().map(|(id, _)| (*id).to_string()).collect();
for (id, r) in published {
s.published.insert((*id).to_string(), r.clone());
}
s
}
fn run<'a>(
state: &RunState,
cmd: &'a RecordingCmd,
clock: &'a FixedClock,
reg: &'a FakeRegistry,
) -> crate::protocol::reconcile::ReconcileReport {
let ctx = EffectCtx {
runner: cmd,
clock,
registry: reg,
repo_root: Path::new("/repo"),
artifacts: &crate::release::adapters::EMPTY_ARTIFACTS,
};
reconcile(state, &ctx)
}
fn only(
report: &crate::protocol::reconcile::ReconcileReport,
) -> &crate::protocol::reconcile::TargetReconcile {
assert_eq!(report.targets.len(), 1, "expected exactly one target");
&report.targets[0]
}
#[test]
fn matches_when_version_is_published() {
let state = state_with(&[("cargo", receipt("rust", Some("tool"), "1.0.0", None))]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new().with("rust", "tool", &["0.9.0", "1.0.0"]);
let report = run(&state, &cmd, &clock, ®);
let t = only(&report);
assert_eq!(t.outcome, VerifyOutcome::Matches);
assert_eq!(t.detail, None, "a clean match carries no detail");
assert_eq!(report.summary.matches, 1);
assert_eq!(report.summary.reconciled, 1);
assert_eq!(
reg.queried.borrow().as_slice(),
&[("rust".to_string(), "tool".to_string())]
);
}
#[test]
fn missing_when_version_absent_from_registry() {
let state = state_with(&[("cargo", receipt("rust", Some("tool"), "1.0.0", None))]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new().with("rust", "tool", &["0.9.0"]);
let report = run(&state, &cmd, &clock, ®);
let t = only(&report);
assert_eq!(t.outcome, VerifyOutcome::Missing);
assert!(t.detail.as_deref().unwrap().contains("does not report"));
assert_eq!(report.summary.missing, 1);
}
#[test]
fn a_recorded_digest_does_not_force_conflicts_without_a_remote_digest() {
let state = state_with(&[(
"npm",
receipt("node", Some("@scope/tool"), "2.0.0", Some("sha256:local")),
)]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new().with("node", "@scope/tool", &["2.0.0"]);
let report = run(&state, &cmd, &clock, ®);
assert_eq!(only(&report).outcome, VerifyOutcome::Matches);
}
#[test]
fn unknown_on_registry_outage_never_missing() {
let state = state_with(&[("cargo", receipt("rust", Some("tool"), "1.0.0", None))]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::outage();
let report = run(&state, &cmd, &clock, ®);
let t = only(&report);
assert_eq!(
t.outcome,
VerifyOutcome::Unknown,
"an outage must never read as Missing"
);
assert!(t
.detail
.as_deref()
.unwrap()
.contains("could not be performed"));
assert_eq!(report.summary.unknown, 1);
assert_eq!(report.summary.missing, 0);
}
#[test]
fn unknown_for_unobservable_distribution_targets() {
let state = state_with(&[("gh", receipt("binary", Some("tool"), "1.0.0", None))]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new().with("binary", "tool", &["1.0.0"]);
let report = run(&state, &cmd, &clock, ®);
let t = only(&report);
assert_eq!(t.outcome, VerifyOutcome::Unknown);
assert!(t.detail.as_deref().unwrap().contains("not observable"));
assert!(
reg.queried.borrow().is_empty(),
"binary must not query the registry"
);
}
#[test]
fn unknown_when_receipt_has_no_package_name() {
let state = state_with(&[("cargo", receipt("rust", None, "1.0.0", None))]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new().with("rust", "tool", &["1.0.0"]);
let report = run(&state, &cmd, &clock, ®);
let t = only(&report);
assert_eq!(t.outcome, VerifyOutcome::Unknown);
assert!(t.detail.as_deref().unwrap().contains("no package name"));
assert!(reg.queried.borrow().is_empty());
}
#[test]
fn unknown_for_unrecognized_ecosystem() {
let state = state_with(&[("weird", receipt("cocoapods", Some("Tool"), "1.0.0", None))]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new();
let report = run(&state, &cmd, &clock, ®);
let t = only(&report);
assert_eq!(t.outcome, VerifyOutcome::Unknown);
assert!(t
.detail
.as_deref()
.unwrap()
.contains("unrecognized ecosystem"));
assert!(reg.queried.borrow().is_empty());
}
#[test]
fn summary_tallies_a_mixed_run_and_targets_are_sorted() {
let state = state_with(&[
("npm", receipt("node", Some("tool"), "3.0.0", None)), ("cargo", receipt("rust", Some("tool"), "1.0.0", None)), ("gh", receipt("binary", Some("tool"), "1.0.0", None)), ]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new()
.with("rust", "tool", &["1.0.0"])
.with("node", "tool", &["1.0.0"]); let report = run(&state, &cmd, &clock, ®);
let ids: Vec<&str> = report.targets.iter().map(|t| t.target.as_str()).collect();
assert_eq!(ids, vec!["cargo", "gh", "npm"]);
assert_eq!(report.summary.reconciled, 3);
assert_eq!(report.summary.matches, 1);
assert_eq!(report.summary.missing, 1);
assert_eq!(report.summary.unknown, 1);
assert_eq!(report.summary.conflicts, 0);
assert_eq!(report.run_id, "RUN01");
assert_eq!(report.plan_id, "plan-abc");
assert_eq!(report.run_status, RunStatus::Completed);
}
#[test]
fn empty_published_set_reconciles_to_an_empty_report() {
let state = state_with(&[]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new();
let report = run(&state, &cmd, &clock, ®);
assert!(report.targets.is_empty());
assert_eq!(
report.summary,
crate::protocol::reconcile::ReconcileSummary::default()
);
}
#[test]
fn reconcile_runs_no_commands() {
let state = state_with(&[
("cargo", receipt("rust", Some("tool"), "1.0.0", None)),
("gh", receipt("binary", Some("tool"), "1.0.0", None)),
]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new().with("rust", "tool", &["1.0.0"]);
let _ = run(&state, &cmd, &clock, ®);
assert!(
cmd.calls.borrow().is_empty(),
"reconcile must not execute any command"
);
}
#[test]
fn report_serializes_under_the_envelope_shape() {
let state = state_with(&[("cargo", receipt("rust", Some("tool"), "1.0.0", None))]);
let (cmd, clock) = (RecordingCmd::default(), FixedClock);
let reg = FakeRegistry::new().with("rust", "tool", &["1.0.0"]);
let report = run(&state, &cmd, &clock, ®);
let v = serde_json::to_value(&report).unwrap();
assert_eq!(v["run_id"], "RUN01");
assert_eq!(v["run_status"], "completed");
assert_eq!(
v["journal_seq"], 7,
"the report pins the snapshot's log prefix"
);
assert_eq!(v["targets"][0]["outcome"], "matches");
assert!(v["targets"][0].get("detail").is_none());
assert_eq!(v["summary"]["matches"], 1);
}