use octl_core::plan::Tier;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Action {
ReCodeChunk {
chunk_id: String,
findings: Vec<Finding>,
},
TriggerReSpec {
reason: String,
chunk_ids: Vec<String>,
},
AcceptChunk {
chunk_id: String,
},
PromoteTier {
chunk_id: String,
tier: Tier,
},
OpenDiscussion {
topic: String,
severity: Severity,
},
ProposeSpinoff {
title: String,
kind: String,
rationale: String,
scope: SpinoffScope,
},
DeclareConverged,
Escalate {
reason: String,
},
}
impl Action {
pub fn name(&self) -> &'static str {
match self {
Action::ReCodeChunk { .. } => "re_code_chunk",
Action::TriggerReSpec { .. } => "trigger_re_spec",
Action::AcceptChunk { .. } => "accept_chunk",
Action::PromoteTier { .. } => "promote_tier",
Action::OpenDiscussion { .. } => "open_discussion",
Action::ProposeSpinoff { .. } => "propose_spinoff",
Action::DeclareConverged => "declare_converged",
Action::Escalate { .. } => "escalate",
}
}
pub fn referenced_chunks(&self) -> Vec<&str> {
match self {
Action::ReCodeChunk { chunk_id, .. }
| Action::AcceptChunk { chunk_id }
| Action::PromoteTier { chunk_id, .. } => vec![chunk_id.as_str()],
Action::TriggerReSpec { chunk_ids, .. } => {
chunk_ids.iter().map(String::as_str).collect()
}
Action::OpenDiscussion { .. }
| Action::ProposeSpinoff { .. }
| Action::DeclareConverged
| Action::Escalate { .. } => Vec::new(),
}
}
pub fn decision_class(&self) -> DecisionClass {
match self {
Action::DeclareConverged | Action::TriggerReSpec { .. } | Action::Escalate { .. } => {
DecisionClass::Consequential
}
Action::ProposeSpinoff { scope, .. } => match scope {
SpinoffScope::Trivial => DecisionClass::Routine,
SpinoffScope::Substantial => DecisionClass::Consequential,
},
Action::ReCodeChunk { .. }
| Action::AcceptChunk { .. }
| Action::PromoteTier { .. }
| Action::OpenDiscussion { .. } => DecisionClass::Routine,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecisionClass {
Routine,
Consequential,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpinoffScope {
Trivial,
Substantial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Low,
Medium,
High,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub id: String,
pub summary: String,
pub verdict: FindingVerdict,
pub severity: Severity,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingVerdict {
Fix,
SpecFlaw,
Discuss,
SpinOff,
Drop,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn consequential_primitives_are_classified_consequential() {
assert_eq!(
Action::DeclareConverged.decision_class(),
DecisionClass::Consequential
);
assert_eq!(
Action::TriggerReSpec {
reason: "spec flaw".into(),
chunk_ids: vec!["c1".into()],
}
.decision_class(),
DecisionClass::Consequential
);
assert_eq!(
Action::Escalate {
reason: "stuck".into(),
}
.decision_class(),
DecisionClass::Consequential
);
}
#[test]
fn routine_primitives_are_classified_routine() {
assert_eq!(
Action::ReCodeChunk {
chunk_id: "c1".into(),
findings: vec![],
}
.decision_class(),
DecisionClass::Routine
);
assert_eq!(
Action::AcceptChunk {
chunk_id: "c1".into(),
}
.decision_class(),
DecisionClass::Routine
);
assert_eq!(
Action::PromoteTier {
chunk_id: "c1".into(),
tier: Tier::High,
}
.decision_class(),
DecisionClass::Routine
);
assert_eq!(
Action::OpenDiscussion {
topic: "naming".into(),
severity: Severity::High,
}
.decision_class(),
DecisionClass::Routine
);
}
#[test]
fn spinoff_class_follows_scope_not_severity() {
let trivial = Action::ProposeSpinoff {
title: "tidy docs".into(),
kind: "improvement".into(),
rationale: "nice-to-have".into(),
scope: SpinoffScope::Trivial,
};
assert_eq!(trivial.decision_class(), DecisionClass::Routine);
let substantial = Action::ProposeSpinoff {
title: "extract module".into(),
kind: "refactor".into(),
rationale: "real work".into(),
scope: SpinoffScope::Substantial,
};
assert_eq!(substantial.decision_class(), DecisionClass::Consequential);
}
#[test]
fn referenced_chunks_are_reported_for_chunk_actions() {
assert_eq!(
Action::AcceptChunk {
chunk_id: "c1".into()
}
.referenced_chunks(),
vec!["c1"]
);
assert_eq!(
Action::TriggerReSpec {
reason: "r".into(),
chunk_ids: vec!["c1".into(), "c2".into()],
}
.referenced_chunks(),
vec!["c1", "c2"]
);
assert!(Action::DeclareConverged.referenced_chunks().is_empty());
}
#[test]
fn action_round_trips_through_serde_tagged() {
let action = Action::ReCodeChunk {
chunk_id: "c1".into(),
findings: vec![Finding {
id: "f1".into(),
summary: "off-by-one".into(),
verdict: FindingVerdict::Fix,
severity: Severity::High,
}],
};
let v = serde_json::to_value(&action).unwrap();
assert_eq!(v["type"], json!("re_code_chunk"));
let back: Action = serde_json::from_value(v).unwrap();
assert_eq!(back, action);
}
#[test]
fn name_matches_serde_tag() {
let samples = [
Action::ReCodeChunk {
chunk_id: "c".into(),
findings: vec![],
},
Action::TriggerReSpec {
reason: "r".into(),
chunk_ids: vec![],
},
Action::AcceptChunk {
chunk_id: "c".into(),
},
Action::PromoteTier {
chunk_id: "c".into(),
tier: Tier::Mid,
},
Action::OpenDiscussion {
topic: "t".into(),
severity: Severity::Low,
},
Action::ProposeSpinoff {
title: "t".into(),
kind: "k".into(),
rationale: "r".into(),
scope: SpinoffScope::Trivial,
},
Action::DeclareConverged,
Action::Escalate { reason: "r".into() },
];
for action in samples {
let tag = serde_json::to_value(&action).unwrap()["type"]
.as_str()
.unwrap()
.to_string();
assert_eq!(action.name(), tag, "name()/serde tag drift for {action:?}");
}
}
}