use serde::{Deserialize, Serialize};
use super::gate::{
AcceptanceBasis, BuildTestStatus, GateEvidence, MergeVerdict, PolicyDecision,
};
use super::harness::{IntegrationBlame, IntegrationResult, Subtask, SubtaskOutcome};
use super::planner::DecomposeResult;
pub const FOREMAN_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct SymbolRefDto {
pub file: String,
pub symbol: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DuplicateDto {
pub file: String,
pub symbol: String,
pub kind: String,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum BuildTestDto {
NotConfigured,
NotRun { reason: String },
Passed,
Failed { code: Option<i32>, output: String },
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AcceptanceBasisDto {
Verified,
Waived { class: String, reason: String },
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GateEvidenceDto {
pub subtask: String,
pub changed_symbol_count: usize,
pub footprint_declared: bool,
pub containment_violations: Vec<SymbolRefDto>,
pub unparsed_changed_files: Vec<String>,
pub semantic_conflicts: Vec<DuplicateDto>,
pub build_test: BuildTestDto,
pub policy_denied: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum MergeVerdictDto {
Accepted {
basis: AcceptanceBasisDto,
evidence: GateEvidenceDto,
},
Rejected {
reasons: Vec<String>,
evidence: GateEvidenceDto,
},
Inconclusive {
reasons: Vec<String>,
evidence: GateEvidenceDto,
},
#[serde(other)]
Unknown,
}
impl MergeVerdictDto {
pub fn is_accepting(&self) -> bool {
matches!(self, MergeVerdictDto::Accepted { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubtaskReportDto {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub verdict: Option<MergeVerdictDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyConflictDto {
pub subtask_id: String,
pub files: Vec<String>,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DuplicateBlameDto {
pub file: String,
pub symbol: String,
pub candidate_subtask_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildTestFailureDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<i32>,
pub output_tail: String,
pub candidate_subtask_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrationBlameDto {
pub apply_conflicts: Vec<ApplyConflictDto>,
pub duplicate_conflicts: Vec<DuplicateBlameDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub build_test: Option<BuildTestFailureDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IntegrationReportDto {
pub applied: usize,
pub apply_conflicts: Vec<String>,
pub integrated_cleanly: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub verdict: Option<MergeVerdictDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blame: Option<IntegrationBlameDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForemanReport {
pub schema_version: u32,
pub subtasks: Vec<SubtaskReportDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub integration: Option<IntegrationReportDto>,
}
impl ForemanReport {
pub fn from_run(outcomes: &[SubtaskOutcome], integration: Option<&IntegrationResult>) -> Self {
Self {
schema_version: FOREMAN_SCHEMA_VERSION,
subtasks: outcomes.iter().map(SubtaskReportDto::from).collect(),
integration: integration.map(IntegrationReportDto::from),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanSubtaskDto {
pub id: String,
pub prompt: String,
pub files: Vec<String>,
pub writes: Vec<SymbolRefDto>,
pub reads: Vec<SymbolRefDto>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanReport {
pub schema_version: u32,
pub valid: bool,
pub prefer_single_session: bool,
pub attempts: u32,
pub issues: Vec<String>,
pub levels: Vec<Vec<String>>,
pub subtasks: Vec<PlanSubtaskDto>,
}
impl From<&DecomposeResult> for PlanReport {
fn from(r: &DecomposeResult) -> Self {
Self {
schema_version: FOREMAN_SCHEMA_VERSION,
valid: r.is_valid(),
prefer_single_session: r.prefer_single_session,
attempts: r.attempts,
issues: r.issues.clone(),
levels: r.levels.clone(),
subtasks: r.subtasks.iter().map(PlanSubtaskDto::from).collect(),
}
}
}
impl From<&Subtask> for PlanSubtaskDto {
fn from(s: &Subtask) -> Self {
let (mut writes, mut reads): (Vec<SymbolRefDto>, Vec<SymbolRefDto>) = match &s.footprint {
Some(fp) => (
fp.writes
.iter()
.map(|r| SymbolRefDto {
file: r.file.clone(),
symbol: r.symbol.clone(),
})
.collect(),
fp.reads
.iter()
.map(|r| SymbolRefDto {
file: r.file.clone(),
symbol: r.symbol.clone(),
})
.collect(),
),
None => (Vec::new(), Vec::new()),
};
writes.sort();
reads.sort();
Self {
id: s.id.clone(),
prompt: s.prompt.clone(),
files: s.files.clone(),
writes,
reads,
}
}
}
impl From<&BuildTestStatus> for BuildTestDto {
fn from(s: &BuildTestStatus) -> Self {
match s {
BuildTestStatus::NotConfigured => BuildTestDto::NotConfigured,
BuildTestStatus::NotRun { reason } => BuildTestDto::NotRun {
reason: reason.clone(),
},
BuildTestStatus::Passed => BuildTestDto::Passed,
BuildTestStatus::Failed { code, output } => BuildTestDto::Failed {
code: *code,
output: output.clone(),
},
}
}
}
impl From<&AcceptanceBasis> for AcceptanceBasisDto {
fn from(b: &AcceptanceBasis) -> Self {
match b {
AcceptanceBasis::Verified => AcceptanceBasisDto::Verified,
AcceptanceBasis::Waived { class, reason } => AcceptanceBasisDto::Waived {
class: class.clone(),
reason: reason.clone(),
},
}
}
}
impl From<&GateEvidence> for GateEvidenceDto {
fn from(e: &GateEvidence) -> Self {
let GateEvidence {
subtask,
changed_symbols,
footprint_declared,
containment: _, containment_violations,
unparsed_changed_files,
duplicates: _, semantic_conflicts,
build_test,
policy,
} = e;
Self {
subtask: subtask.clone(),
changed_symbol_count: changed_symbols.len(),
footprint_declared: *footprint_declared,
containment_violations: containment_violations
.iter()
.map(|v| SymbolRefDto {
file: v.changed.file.clone(),
symbol: v.changed.symbol.clone(),
})
.collect(),
unparsed_changed_files: unparsed_changed_files.clone(),
semantic_conflicts: semantic_conflicts
.iter()
.map(|d| DuplicateDto {
file: d.file.clone(),
symbol: d.symbol.clone(),
kind: d.kind.clone(),
count: d.count,
})
.collect(),
build_test: BuildTestDto::from(build_test),
policy_denied: match policy {
PolicyDecision::Deny { reasons } => Some(reasons.clone()),
PolicyDecision::Allow => None,
},
}
}
}
impl From<&MergeVerdict> for MergeVerdictDto {
fn from(v: &MergeVerdict) -> Self {
match v {
MergeVerdict::Accepted { basis, evidence } => MergeVerdictDto::Accepted {
basis: AcceptanceBasisDto::from(basis),
evidence: GateEvidenceDto::from(evidence),
},
MergeVerdict::Rejected { reasons, evidence } => MergeVerdictDto::Rejected {
reasons: reasons.clone(),
evidence: GateEvidenceDto::from(evidence),
},
MergeVerdict::Inconclusive { reasons, evidence } => MergeVerdictDto::Inconclusive {
reasons: reasons.clone(),
evidence: GateEvidenceDto::from(evidence),
},
}
}
}
impl From<&SubtaskOutcome> for SubtaskReportDto {
fn from(o: &SubtaskOutcome) -> Self {
Self {
id: o.subtask_id.clone(),
verdict: o.verdict.as_ref().map(MergeVerdictDto::from),
error: o.error.clone(),
}
}
}
impl From<&super::harness::ApplyConflict> for ApplyConflictDto {
fn from(c: &super::harness::ApplyConflict) -> Self {
let super::harness::ApplyConflict {
subtask_id,
files,
detail,
} = c;
Self {
subtask_id: subtask_id.clone(),
files: files.clone(),
detail: detail.clone(),
}
}
}
impl From<&super::harness::DuplicateBlame> for DuplicateBlameDto {
fn from(d: &super::harness::DuplicateBlame) -> Self {
let super::harness::DuplicateBlame {
file,
symbol,
candidate_subtask_ids,
} = d;
Self {
file: file.clone(),
symbol: symbol.clone(),
candidate_subtask_ids: candidate_subtask_ids.clone(),
}
}
}
impl From<&super::harness::BuildTestFailure> for BuildTestFailureDto {
fn from(f: &super::harness::BuildTestFailure) -> Self {
let super::harness::BuildTestFailure {
code,
output_tail,
candidate_subtask_ids,
} = f;
Self {
code: *code,
output_tail: output_tail.clone(),
candidate_subtask_ids: candidate_subtask_ids.clone(),
}
}
}
impl From<&IntegrationBlame> for IntegrationBlameDto {
fn from(b: &IntegrationBlame) -> Self {
let IntegrationBlame {
apply_conflicts,
duplicate_conflicts,
build_test,
} = b;
Self {
apply_conflicts: apply_conflicts.iter().map(ApplyConflictDto::from).collect(),
duplicate_conflicts: duplicate_conflicts
.iter()
.map(DuplicateBlameDto::from)
.collect(),
build_test: build_test.as_ref().map(BuildTestFailureDto::from),
}
}
}
impl From<&IntegrationResult> for IntegrationReportDto {
fn from(r: &IntegrationResult) -> Self {
Self {
applied: r.applied,
apply_conflicts: r.apply_conflicts.clone(),
integrated_cleanly: r.integrated_cleanly(),
verdict: r.verdict.as_ref().map(MergeVerdictDto::from),
blame: r.blame.as_ref().map(IntegrationBlameDto::from),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn evidence() -> GateEvidenceDto {
GateEvidenceDto {
subtask: "a".into(),
changed_symbol_count: 1,
footprint_declared: true,
containment_violations: vec![],
unparsed_changed_files: vec![],
semantic_conflicts: vec![],
build_test: BuildTestDto::Passed,
policy_denied: None,
}
}
#[test]
fn verdict_uses_stable_string_discriminants() {
let v = MergeVerdictDto::Accepted {
basis: AcceptanceBasisDto::Verified,
evidence: evidence(),
};
let json = serde_json::to_value(&v).unwrap();
assert_eq!(json["outcome"], "accepted");
assert_eq!(json["basis"]["kind"], "verified");
assert_eq!(json["evidence"]["build_test"]["status"], "passed");
}
#[test]
fn report_carries_schema_version_and_round_trips() {
let report = ForemanReport {
schema_version: FOREMAN_SCHEMA_VERSION,
subtasks: vec![SubtaskReportDto {
id: "a".into(),
verdict: Some(MergeVerdictDto::Rejected {
reasons: vec!["build/test failed".into()],
evidence: evidence(),
}),
error: None,
}],
integration: Some(IntegrationReportDto {
applied: 1,
apply_conflicts: vec![],
integrated_cleanly: true,
verdict: None,
blame: None,
}),
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"schema_version\":1"));
let back: ForemanReport = serde_json::from_str(&json).unwrap();
assert_eq!(report, back, "DTO round-trips losslessly");
}
#[test]
fn failure_output_and_policy_reasons_survive_the_wire() {
let mut ev = evidence();
ev.build_test = BuildTestDto::Failed {
code: Some(101),
output: "error[E0308]: mismatched types".into(),
};
ev.policy_denied = Some(vec!["protected path".into()]);
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["build_test"]["output"], "error[E0308]: mismatched types");
assert_eq!(json["policy_denied"][0], "protected path");
}
#[test]
fn is_accepting_is_fail_closed() {
let accepted = MergeVerdictDto::Accepted {
basis: AcceptanceBasisDto::Verified,
evidence: evidence(),
};
assert!(accepted.is_accepting());
assert!(!MergeVerdictDto::Unknown.is_accepting());
assert!(!MergeVerdictDto::Inconclusive {
reasons: vec![],
evidence: evidence()
}
.is_accepting());
}
#[test]
fn unknown_variant_is_forward_compatible() {
let json = r#"{"outcome":"quarantined","evidence":{}}"#;
let v: MergeVerdictDto = serde_json::from_str(json).unwrap();
assert_eq!(v, MergeVerdictDto::Unknown, "unknown outcome degrades, not panics");
let bt: BuildTestDto = serde_json::from_str(r#"{"status":"sandboxed"}"#).unwrap();
assert_eq!(bt, BuildTestDto::Unknown);
}
}