use serde::{Deserialize, Deserializer, Serialize};
use crate::risk::{RiskScore, ScoredCommit};
pub const TOUR_SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TourStop {
pub commit_ids: Vec<String>,
pub summary: String,
#[serde(default = "RiskScore::min")]
pub risk: RiskScore,
}
impl TourStop {
pub fn last_sha(&self) -> &str {
self.commit_ids.last().map_or("", String::as_str)
}
pub fn first_sha(&self) -> &str {
self.commit_ids.first().map_or("", String::as_str)
}
pub fn is_batched(&self) -> bool {
self.commit_ids.len() > 1
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct TourState {
pub stops: Vec<TourStop>,
pub index: usize,
#[serde(default)]
pub threshold: RiskScore,
#[serde(default)]
pub tour_schema_version: u8,
}
impl<'de> Deserialize<'de> for TourState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct Raw {
stops: Vec<TourStop>,
#[serde(default)]
index: usize,
#[serde(default)]
threshold: RiskScore,
#[serde(default)]
tour_schema_version: u8,
}
let raw = Raw::deserialize(deserializer)?;
let max_index = raw.stops.len().saturating_sub(1);
let clamped = if raw.stops.is_empty() {
0
} else {
raw.index.min(max_index)
};
Ok(TourState {
stops: raw.stops,
index: clamped,
threshold: raw.threshold,
tour_schema_version: raw.tour_schema_version,
})
}
}
pub fn migrate_tour(state: &mut TourState) -> Result<bool, MigrateTourError> {
let entry_version = state.tour_schema_version;
if state.tour_schema_version > TOUR_SCHEMA_VERSION {
return Err(MigrateTourError::FromTheFuture {
recorded: state.tour_schema_version,
supported: TOUR_SCHEMA_VERSION,
});
}
let mut changed = false;
while state.tour_schema_version < TOUR_SCHEMA_VERSION {
let before = state.tour_schema_version;
match state.tour_schema_version {
0 => {
state.tour_schema_version = 1;
}
other => {
state.tour_schema_version = entry_version;
return Err(MigrateTourError::MissingArm { version: other });
}
}
debug_assert!(
state.tour_schema_version > before,
"migrate_tour arm must bump tour_schema_version"
);
changed = true;
}
Ok(changed)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MigrateTourError {
FromTheFuture { recorded: u8, supported: u8 },
MissingArm { version: u8 },
}
impl std::fmt::Display for MigrateTourError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FromTheFuture {
recorded,
supported,
} => write!(
f,
"tour schema version {recorded} is newer than supported {supported}",
),
Self::MissingArm { version } => {
write!(f, "migrate_tour has no arm for v{version}")
}
}
}
}
impl std::error::Error for MigrateTourError {}
impl TourState {
pub fn new(stops: Vec<TourStop>) -> Self {
Self {
stops,
index: 0,
threshold: RiskScore::MIN,
tour_schema_version: TOUR_SCHEMA_VERSION,
}
}
pub fn new_with_threshold(stops: Vec<TourStop>, threshold: RiskScore) -> Self {
Self {
stops,
index: 0,
threshold,
tour_schema_version: TOUR_SCHEMA_VERSION,
}
}
pub fn current_index(&self) -> Option<usize> {
if self.stops.is_empty() {
None
} else {
Some(self.index)
}
}
pub fn set_index(&mut self, idx: usize) -> bool {
if idx < self.stops.len() {
self.index = idx;
true
} else {
false
}
}
pub fn current(&self) -> Option<&TourStop> {
self.stops.get(self.index)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TourTriageVerdict {
Live,
LikelyObsolete,
Moved,
}
impl TourTriageVerdict {
pub fn id(&self) -> &'static str {
match self {
Self::Live => "live",
Self::LikelyObsolete => "likely_obsolete",
Self::Moved => "moved",
}
}
pub fn from_id(s: &str) -> Option<Self> {
match s {
"live" => Some(Self::Live),
"likely_obsolete" | "obsolete" => Some(Self::LikelyObsolete),
"moved" => Some(Self::Moved),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewCommentLocation {
pub file: String,
pub line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentTriage {
pub verdict: TourTriageVerdict,
pub reasoning: String,
#[serde(default)]
pub new_location: Option<NewCommentLocation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TourCommentMeta {
pub stop_index: usize,
pub stop_commit_shas: Vec<String>,
pub file: String,
pub line: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TourAggressiveness {
Cautious,
Balanced,
Aggressive,
Custom(RiskScore),
}
impl TourAggressiveness {
pub fn threshold(self) -> RiskScore {
match self {
Self::Cautious => RiskScore::new(1),
Self::Balanced => RiskScore::new(3),
Self::Aggressive => RiskScore::new(5),
Self::Custom(s) => s,
}
}
pub fn preset_id(self) -> &'static str {
match self {
Self::Cautious => "cautious",
Self::Balanced => "balanced",
Self::Aggressive => "aggressive",
Self::Custom(_) => "custom",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim() {
"cautious" => Some(Self::Cautious),
"balanced" => Some(Self::Balanced),
"aggressive" => Some(Self::Aggressive),
other => other.parse::<u8>().ok().and_then(|n| {
if n <= 5 {
Some(Self::Custom(RiskScore::new(n)))
} else {
None
}
}),
}
}
pub fn from_threshold(s: RiskScore) -> Self {
match s.as_u8() {
1 => Self::Cautious,
3 => Self::Balanced,
5 => Self::Aggressive,
_ => Self::Custom(s),
}
}
}
pub fn build_tour_stops(commits: &[ScoredCommit], threshold: RiskScore) -> Vec<TourStop> {
let mut stops: Vec<TourStop> = Vec::with_capacity(commits.len());
for c in commits {
let above = c.risk > threshold;
if !above
&& let Some(last) = stops.last_mut()
&& last.risk <= threshold
{
let merged = last.risk.max(c.risk);
if merged <= threshold {
last.commit_ids.push(c.sha.clone());
last.risk = merged;
last.summary = format!("{} commits batched", last.commit_ids.len());
continue;
}
}
stops.push(TourStop {
commit_ids: vec![c.sha.clone()],
summary: c.summary.clone(),
risk: c.risk,
});
}
stops
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_commit_stop_is_not_batched() {
let stop = TourStop {
commit_ids: vec!["abc".into()],
summary: "one".into(),
risk: crate::risk::RiskScore::MIN,
};
assert!(!stop.is_batched());
assert_eq!(stop.first_sha(), "abc");
assert_eq!(stop.last_sha(), "abc");
}
#[test]
fn multi_commit_stop_reports_edge_shas() {
let stop = TourStop {
commit_ids: vec!["aaa".into(), "bbb".into(), "ccc".into()],
summary: "batched".into(),
risk: crate::risk::RiskScore::MIN,
};
assert!(stop.is_batched());
assert_eq!(stop.first_sha(), "aaa");
assert_eq!(stop.last_sha(), "ccc");
}
#[test]
fn tour_state_current_returns_indexed_stop() {
let t = TourState {
stops: vec![
TourStop {
commit_ids: vec!["a".into()],
summary: "first".into(),
risk: crate::risk::RiskScore::MIN,
},
TourStop {
commit_ids: vec!["b".into()],
summary: "second".into(),
risk: crate::risk::RiskScore::MIN,
},
],
index: 1,
threshold: crate::risk::RiskScore::MIN,
tour_schema_version: TOUR_SCHEMA_VERSION,
};
assert_eq!(t.current().unwrap().summary, "second");
}
#[test]
fn tour_state_current_is_none_when_empty() {
let t = TourState {
stops: vec![],
index: 0,
threshold: crate::risk::RiskScore::MIN,
tour_schema_version: TOUR_SCHEMA_VERSION,
};
assert!(t.current().is_none());
}
#[test]
fn tour_triage_verdict_roundtrips_via_id() {
for v in [
TourTriageVerdict::Live,
TourTriageVerdict::LikelyObsolete,
TourTriageVerdict::Moved,
] {
assert_eq!(TourTriageVerdict::from_id(v.id()), Some(v));
}
assert_eq!(TourTriageVerdict::from_id("bogus"), None);
assert_eq!(
TourTriageVerdict::from_id("obsolete"),
Some(TourTriageVerdict::LikelyObsolete)
);
}
#[test]
fn tour_state_roundtrips_via_serde() {
let t = TourState {
stops: vec![
TourStop {
commit_ids: vec!["a".into(), "b".into()],
summary: "one".into(),
risk: crate::risk::RiskScore::MIN,
},
TourStop {
commit_ids: vec!["c".into()],
summary: "two".into(),
risk: crate::risk::RiskScore::MIN,
},
],
index: 1,
threshold: crate::risk::RiskScore::MIN,
tour_schema_version: TOUR_SCHEMA_VERSION,
};
let json = serde_json::to_string(&t).unwrap();
let back: TourState = serde_json::from_str(&json).unwrap();
assert_eq!(back.index, 1);
assert_eq!(back.stops.len(), 2);
assert_eq!(back.stops[0].commit_ids, vec!["a", "b"]);
assert_eq!(back.stops[1].summary, "two");
}
#[test]
fn tour_state_new_sets_index_to_zero() {
let t = TourState::new(vec![
TourStop {
commit_ids: vec!["a".into()],
summary: "first".into(),
risk: crate::risk::RiskScore::MIN,
},
TourStop {
commit_ids: vec!["b".into()],
summary: "second".into(),
risk: crate::risk::RiskScore::MIN,
},
]);
assert_eq!(t.index, 0);
assert_eq!(t.current_index(), Some(0));
}
#[test]
fn tour_state_current_index_is_none_when_empty() {
let t = TourState::new(vec![]);
assert_eq!(t.current_index(), None);
assert!(t.current().is_none());
}
#[test]
fn tour_state_set_index_accepts_in_bounds() {
let mut t = TourState::new(vec![
TourStop {
commit_ids: vec!["a".into()],
summary: "a".into(),
risk: crate::risk::RiskScore::MIN,
},
TourStop {
commit_ids: vec!["b".into()],
summary: "b".into(),
risk: crate::risk::RiskScore::MIN,
},
]);
assert!(t.set_index(1));
assert_eq!(t.index, 1);
assert_eq!(t.current().unwrap().summary, "b");
}
#[test]
fn tour_state_set_index_rejects_oob() {
let mut t = TourState::new(vec![TourStop {
commit_ids: vec!["a".into()],
summary: "only".into(),
risk: crate::risk::RiskScore::MIN,
}]);
assert!(!t.set_index(5));
assert_eq!(t.index, 0, "index unchanged after OOB set_index");
}
#[test]
fn tour_state_set_index_rejects_when_empty() {
let mut t = TourState::new(vec![]);
assert!(!t.set_index(0));
assert_eq!(t.index, 0);
}
#[test]
fn tour_state_deserialize_clamps_oob_index() {
let json = r#"{"stops":[{"commit_ids":["a"],"summary":"s"}],"index":99}"#;
let t: TourState = serde_json::from_str(json).expect("deserialize should succeed");
assert_eq!(t.index, 0, "OOB index must be clamped to last valid index");
assert!(t.current().is_some());
}
#[test]
fn tour_state_deserialize_clamps_empty_stops_index() {
let json = r#"{"stops":[],"index":7}"#;
let t: TourState = serde_json::from_str(json).expect("deserialize should succeed");
assert_eq!(t.index, 0);
assert!(t.current().is_none());
}
#[test]
fn tour_state_deserialize_preserves_valid_index() {
let json = r#"{"stops":[{"commit_ids":["a"],"summary":"1"},{"commit_ids":["b"],"summary":"2"}],"index":1}"#;
let t: TourState = serde_json::from_str(json).expect("deserialize should succeed");
assert_eq!(t.index, 1);
assert_eq!(t.current().unwrap().summary, "2");
}
#[test]
fn triage_serializes_verdict_as_snake_case() {
let t = CommentTriage {
verdict: TourTriageVerdict::LikelyObsolete,
reasoning: "fixed in later commit".into(),
new_location: None,
};
let json = serde_json::to_string(&t).unwrap();
assert!(
json.contains("\"likely_obsolete\""),
"expected snake_case verdict in: {json}"
);
}
fn sc(sha: &str, risk: u8, summary: &str) -> ScoredCommit {
ScoredCommit {
sha: sha.into(),
risk: RiskScore::new(risk),
summary: summary.into(),
}
}
#[test]
fn build_stops_batches_below_threshold() {
let commits = vec![
sc("a", 0, "fmt only"),
sc("b", 1, "typo fix"),
sc("c", 0, "whitespace"),
];
let stops = build_tour_stops(&commits, RiskScore::new(1));
assert_eq!(stops.len(), 1, "all three should batch into one stop");
assert_eq!(stops[0].commit_ids.len(), 3);
assert_eq!(stops[0].risk.as_u8(), 1);
}
#[test]
fn build_stops_isolates_above_threshold() {
let commits = vec![sc("a", 0, "docs"), sc("b", 5, "crypto"), sc("c", 0, "fmt")];
let stops = build_tour_stops(&commits, RiskScore::new(1));
assert_eq!(stops.len(), 3);
assert_eq!(stops[1].commit_ids, vec!["b".to_string()]);
assert_eq!(stops[1].risk.as_u8(), 5);
}
#[test]
fn build_stops_respects_merged_risk_cap() {
let commits = vec![sc("a", 1, "doc"), sc("b", 2, "config"), sc("c", 1, "doc")];
let stops = build_tour_stops(&commits, RiskScore::new(1));
assert_eq!(stops.len(), 3, "2-risk commit breaks the batch");
assert_eq!(stops[0].commit_ids, vec!["a".to_string()]);
assert_eq!(stops[1].commit_ids, vec!["b".to_string()]);
assert_eq!(stops[2].commit_ids, vec!["c".to_string()]);
}
#[test]
fn tour_state_serde_preserves_threshold() {
let t = TourState::new_with_threshold(
vec![TourStop {
commit_ids: vec!["a".into()],
summary: "only".into(),
risk: RiskScore::new(2),
}],
RiskScore::new(3),
);
let json = serde_json::to_string(&t).unwrap();
let back: TourState = serde_json::from_str(&json).unwrap();
assert_eq!(back.threshold.as_u8(), 3);
assert_eq!(back.stops[0].risk.as_u8(), 2);
}
#[test]
fn tour_stop_serde_preserves_risk() {
let s = TourStop {
commit_ids: vec!["a".into()],
summary: "summary".into(),
risk: RiskScore::new(4),
};
let json = serde_json::to_string(&s).unwrap();
let back: TourStop = serde_json::from_str(&json).unwrap();
assert_eq!(back.risk.as_u8(), 4);
}
#[test]
fn legacy_tour_json_without_risk_field_defaults_to_zero() {
let json = r#"{"stops":[{"commit_ids":["a"],"summary":"s"}],"index":0}"#;
let t: TourState = serde_json::from_str(json).expect("legacy JSON must load");
assert_eq!(t.stops[0].risk.as_u8(), 0);
assert_eq!(t.threshold.as_u8(), 0);
}
#[test]
fn legacy_tour_json_deserializes_as_schema_version_zero() {
let json = r#"{"stops":[{"commit_ids":["a"],"summary":"s"}],"index":0}"#;
let t: TourState = serde_json::from_str(json).expect("legacy JSON must load");
assert_eq!(t.tour_schema_version, 0, "missing field defaults to 0");
}
#[test]
fn migrate_tour_rolls_legacy_v0_up_to_current() {
let mut t: TourState =
serde_json::from_str(r#"{"stops":[{"commit_ids":["a"],"summary":"s"}],"index":0}"#)
.expect("legacy JSON must load");
assert_eq!(t.tour_schema_version, 0);
let changed = migrate_tour(&mut t).expect("v0 migration succeeds");
assert!(changed, "migration must report work done on v0 input");
assert_eq!(
t.tour_schema_version, TOUR_SCHEMA_VERSION,
"migrated tour is at current schema version"
);
}
#[test]
fn migrate_tour_is_idempotent_on_current_version() {
let mut t = TourState::new(vec![TourStop {
commit_ids: vec!["a".into()],
summary: "s".into(),
risk: RiskScore::MIN,
}]);
assert_eq!(t.tour_schema_version, TOUR_SCHEMA_VERSION);
let changed = migrate_tour(&mut t).expect("idempotent migration");
assert!(!changed, "no migration needed on current tour");
assert_eq!(t.tour_schema_version, TOUR_SCHEMA_VERSION);
}
#[test]
fn migrate_tour_rejects_future_schema_version() {
let mut t = TourState::new(vec![]);
t.tour_schema_version = TOUR_SCHEMA_VERSION + 1;
let err = migrate_tour(&mut t).expect_err("future-versioned tour must fail");
assert!(matches!(
err,
MigrateTourError::FromTheFuture {
recorded,
supported
} if recorded == TOUR_SCHEMA_VERSION + 1 && supported == TOUR_SCHEMA_VERSION
));
assert_eq!(
t.tour_schema_version,
TOUR_SCHEMA_VERSION + 1,
"version stamp must not be modified on rejection"
);
}
#[test]
fn new_tour_is_tagged_with_current_schema_version() {
let t = TourState::new(vec![]);
assert_eq!(t.tour_schema_version, TOUR_SCHEMA_VERSION);
let t = TourState::new_with_threshold(vec![], RiskScore::new(3));
assert_eq!(t.tour_schema_version, TOUR_SCHEMA_VERSION);
}
#[test]
fn fresh_tour_roundtrips_with_schema_version() {
let t = TourState::new(vec![TourStop {
commit_ids: vec!["a".into()],
summary: "s".into(),
risk: RiskScore::MIN,
}]);
let json = serde_json::to_string(&t).expect("serialize");
let back: TourState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.tour_schema_version, TOUR_SCHEMA_VERSION);
}
#[test]
fn aggressiveness_parses_presets_and_numbers() {
assert_eq!(
TourAggressiveness::parse("cautious"),
Some(TourAggressiveness::Cautious)
);
assert_eq!(
TourAggressiveness::parse("balanced"),
Some(TourAggressiveness::Balanced)
);
assert_eq!(
TourAggressiveness::parse("aggressive"),
Some(TourAggressiveness::Aggressive)
);
assert_eq!(
TourAggressiveness::parse("0"),
Some(TourAggressiveness::Custom(RiskScore::new(0)))
);
assert_eq!(
TourAggressiveness::parse("5"),
Some(TourAggressiveness::Custom(RiskScore::new(5)))
);
assert_eq!(TourAggressiveness::parse("6"), None);
assert_eq!(TourAggressiveness::parse("foo"), None);
}
#[test]
fn aggressiveness_preset_thresholds_match_spec() {
assert_eq!(TourAggressiveness::Cautious.threshold().as_u8(), 1);
assert_eq!(TourAggressiveness::Balanced.threshold().as_u8(), 3);
assert_eq!(TourAggressiveness::Aggressive.threshold().as_u8(), 5);
assert_eq!(
TourAggressiveness::Custom(RiskScore::new(2))
.threshold()
.as_u8(),
2
);
}
}