use crate::coverage::TimeWindow;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use shiplog_ids::EventId;
use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum SourceSystem {
Github,
JsonImport,
LocalGit,
Manual,
Unknown,
Other(String),
}
impl SourceSystem {
pub fn as_str(&self) -> &str {
match self {
Self::Github => "github",
Self::JsonImport => "json_import",
Self::LocalGit => "local_git",
Self::Manual => "manual",
Self::Unknown => "unknown",
Self::Other(s) => s.as_str(),
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"github" => Self::Github,
"json_import" | "jsonimport" => Self::JsonImport,
"local_git" | "localgit" => Self::LocalGit,
"manual" => Self::Manual,
"unknown" => Self::Unknown,
_ => Self::Other(s.to_string()),
}
}
}
impl fmt::Display for SourceSystem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for SourceSystem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for SourceSystem {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SourceSystemVisitor;
impl<'de> serde::de::Visitor<'de> for SourceSystemVisitor {
type Value = SourceSystem;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a source system string or object")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<SourceSystem, E> {
Ok(SourceSystem::from_str_lossy(v))
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<SourceSystem, A::Error> {
let key: String = map
.next_key()?
.ok_or_else(|| serde::de::Error::custom("expected a single-key map"))?;
let result = match key.to_ascii_lowercase().as_str() {
"github" | "jsonimport" | "json_import" | "localgit" | "local_git"
| "manual" | "unknown" => {
let _: serde::de::IgnoredAny = map.next_value()?;
SourceSystem::from_str_lossy(&key)
}
"other" => {
let value: String = map.next_value()?;
SourceSystem::from_str_lossy(&value)
}
_ => {
let _: serde::de::IgnoredAny = map.next_value()?;
SourceSystem::Other(key)
}
};
if map.next_key::<String>()?.is_some() {
return Err(serde::de::Error::custom(
"expected a single-key map for SourceSystem",
));
}
Ok(result)
}
}
deserializer.deserialize_any(SourceSystemVisitor)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceRef {
pub system: SourceSystem,
pub url: Option<String>,
pub opaque_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Actor {
pub login: String,
pub id: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum RepoVisibility {
Public,
Private,
Unknown,
}
impl fmt::Display for RepoVisibility {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Public => f.write_str("Public"),
Self::Private => f.write_str("Private"),
Self::Unknown => f.write_str("Unknown"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepoRef {
pub full_name: String,
pub html_url: Option<String>,
pub visibility: RepoVisibility,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Link {
pub label: String,
pub url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum EventKind {
PullRequest,
Review,
Manual,
}
impl fmt::Display for EventKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PullRequest => f.write_str("PullRequest"),
Self::Review => f.write_str("Review"),
Self::Manual => f.write_str("Manual"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct EventEnvelope {
pub id: EventId,
pub kind: EventKind,
pub occurred_at: DateTime<Utc>,
pub actor: Actor,
pub repo: RepoRef,
pub payload: EventPayload,
pub tags: Vec<String>,
pub links: Vec<Link>,
pub source: SourceRef,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", content = "data")]
pub enum EventPayload {
PullRequest(PullRequestEvent),
Review(ReviewEvent),
Manual(ManualEvent),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum PullRequestState {
Open,
Closed,
Merged,
Unknown,
}
impl fmt::Display for PullRequestState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Open => f.write_str("Open"),
Self::Closed => f.write_str("Closed"),
Self::Merged => f.write_str("Merged"),
Self::Unknown => f.write_str("Unknown"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct PullRequestEvent {
pub number: u64,
pub title: String,
pub state: PullRequestState,
pub created_at: DateTime<Utc>,
pub merged_at: Option<DateTime<Utc>>,
pub additions: Option<u64>,
pub deletions: Option<u64>,
pub changed_files: Option<u64>,
pub touched_paths_hint: Vec<String>,
pub window: Option<TimeWindow>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReviewEvent {
pub pull_number: u64,
pub pull_title: String,
pub submitted_at: DateTime<Utc>,
pub state: String,
pub window: Option<TimeWindow>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub enum ManualEventType {
Note,
Incident,
Design,
Mentoring,
Launch,
Migration,
Review,
Other,
}
impl fmt::Display for ManualEventType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Note => f.write_str("Note"),
Self::Incident => f.write_str("Incident"),
Self::Design => f.write_str("Design"),
Self::Mentoring => f.write_str("Mentoring"),
Self::Launch => f.write_str("Launch"),
Self::Migration => f.write_str("Migration"),
Self::Review => f.write_str("Review"),
Self::Other => f.write_str("Other"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManualEvent {
pub event_type: ManualEventType,
pub title: String,
pub description: Option<String>,
pub started_at: Option<NaiveDate>,
pub ended_at: Option<NaiveDate>,
pub impact: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_system_round_trip_known_variants() {
let cases = [
(SourceSystem::Github, r#""github""#),
(SourceSystem::JsonImport, r#""json_import""#),
(SourceSystem::LocalGit, r#""local_git""#),
(SourceSystem::Manual, r#""manual""#),
(SourceSystem::Unknown, r#""unknown""#),
];
for (variant, expected_json) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected_json, "serialize {:?}", variant);
let back: SourceSystem = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back, "round-trip {:?}", variant);
}
}
#[test]
fn source_system_other_round_trip() {
let variant = SourceSystem::Other("gitlab".into());
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, r#""gitlab""#);
let back: SourceSystem = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back);
}
#[test]
fn source_system_other_does_not_collide_with_known() {
let back: SourceSystem = serde_json::from_str(r#""github""#).unwrap();
assert_eq!(back, SourceSystem::Github);
}
#[test]
fn source_system_backward_compat_pascal_case() {
let cases = [
(r#""Github""#, SourceSystem::Github),
(r#""JsonImport""#, SourceSystem::JsonImport),
(r#""LocalGit""#, SourceSystem::LocalGit),
(r#""Manual""#, SourceSystem::Manual),
(r#""Unknown""#, SourceSystem::Unknown),
];
for (json, expected) in cases {
let back: SourceSystem = serde_json::from_str(json).unwrap();
assert_eq!(back, expected, "backward compat for {json}");
}
}
#[test]
fn source_system_backward_compat_object_form_unit_variants() {
let cases = [
(r#"{"Github":null}"#, SourceSystem::Github),
(r#"{"JsonImport":null}"#, SourceSystem::JsonImport),
(r#"{"LocalGit":null}"#, SourceSystem::LocalGit),
(r#"{"Manual":null}"#, SourceSystem::Manual),
(r#"{"Unknown":null}"#, SourceSystem::Unknown),
];
for (json, expected) in cases {
let back: SourceSystem = serde_json::from_str(json).unwrap();
assert_eq!(back, expected, "backward compat object form for {json}");
}
}
#[test]
fn source_system_backward_compat_object_form_other() {
let back: SourceSystem = serde_json::from_str(r#"{"Other":"gitlab"}"#).unwrap();
assert_eq!(back, SourceSystem::Other("gitlab".into()));
}
#[test]
fn source_system_backward_compat_object_form_other_known_name() {
let back: SourceSystem = serde_json::from_str(r#"{"Other":"github"}"#).unwrap();
assert_eq!(back, SourceSystem::Github);
}
#[test]
fn source_system_object_form_rejects_multi_key_map() {
let result = serde_json::from_str::<SourceSystem>(r#"{"Github":null,"Other":"x"}"#);
assert!(result.is_err(), "multi-key map should be rejected");
}
#[test]
fn source_system_display_matches_serde() {
for variant in [
SourceSystem::Github,
SourceSystem::JsonImport,
SourceSystem::LocalGit,
SourceSystem::Manual,
SourceSystem::Unknown,
SourceSystem::Other("gitlab".into()),
] {
let display = format!("{variant}");
let serialized: String =
serde_json::from_str(&serde_json::to_string(&variant).unwrap()).unwrap();
assert_eq!(display, serialized, "Display vs serde for {:?}", variant);
}
}
#[test]
fn source_system_rejects_wrong_type_with_expecting_message() {
let result = serde_json::from_str::<SourceSystem>("42");
let err = result.unwrap_err().to_string();
assert!(
err.contains("a source system string or object"),
"expected 'expecting' message in error, got: {err}"
);
}
#[test]
fn source_ref_serde_roundtrip() {
let sr = SourceRef {
system: SourceSystem::Github,
url: Some("https://api.github.com/repos/acme/widgets/pulls/1".into()),
opaque_id: Some("PR_abc".into()),
};
let json = serde_json::to_string(&sr).unwrap();
let back: SourceRef = serde_json::from_str(&json).unwrap();
assert_eq!(sr, back);
}
#[test]
fn source_ref_optional_fields_absent() {
let sr = SourceRef {
system: SourceSystem::Manual,
url: None,
opaque_id: None,
};
let json = serde_json::to_string(&sr).unwrap();
let back: SourceRef = serde_json::from_str(&json).unwrap();
assert_eq!(sr, back);
}
#[test]
fn actor_serde_roundtrip() {
let actor = Actor {
login: "octocat".into(),
id: Some(12345),
};
let json = serde_json::to_string(&actor).unwrap();
let back: Actor = serde_json::from_str(&json).unwrap();
assert_eq!(actor, back);
}
#[test]
fn actor_optional_id() {
let actor = Actor {
login: "ghost".into(),
id: None,
};
let json = serde_json::to_string(&actor).unwrap();
let back: Actor = serde_json::from_str(&json).unwrap();
assert_eq!(actor, back);
}
#[test]
fn repo_ref_serde_roundtrip() {
let rr = RepoRef {
full_name: "acme/widgets".into(),
html_url: Some("https://github.com/acme/widgets".into()),
visibility: RepoVisibility::Public,
};
let json = serde_json::to_string(&rr).unwrap();
let back: RepoRef = serde_json::from_str(&json).unwrap();
assert_eq!(rr, back);
}
#[test]
fn link_serde_roundtrip() {
let link = Link {
label: "pr".into(),
url: "https://github.com/acme/widgets/pull/42".into(),
};
let json = serde_json::to_string(&link).unwrap();
let back: Link = serde_json::from_str(&json).unwrap();
assert_eq!(link, back);
}
#[test]
fn event_envelope_pr_serde_roundtrip() {
use chrono::{TimeZone, Utc};
let ts = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
let event = EventEnvelope {
id: EventId::from_parts(["github", "pr", "acme/widgets", "42"]),
kind: EventKind::PullRequest,
occurred_at: ts,
actor: Actor {
login: "octocat".into(),
id: Some(1),
},
repo: RepoRef {
full_name: "acme/widgets".into(),
html_url: Some("https://github.com/acme/widgets".into()),
visibility: RepoVisibility::Public,
},
payload: EventPayload::PullRequest(PullRequestEvent {
number: 42,
title: "Add feature".into(),
state: PullRequestState::Merged,
created_at: ts,
merged_at: Some(ts),
additions: Some(100),
deletions: Some(20),
changed_files: Some(5),
touched_paths_hint: vec!["src/lib.rs".into()],
window: None,
}),
tags: vec!["feature".into()],
links: vec![],
source: SourceRef {
system: SourceSystem::Github,
url: None,
opaque_id: None,
},
};
let json = serde_json::to_string(&event).unwrap();
let back: EventEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(event, back);
}
#[test]
fn event_envelope_review_serde_roundtrip() {
use chrono::{TimeZone, Utc};
let ts = Utc.with_ymd_and_hms(2025, 6, 1, 12, 0, 0).unwrap();
let event = EventEnvelope {
id: EventId::from_parts(["github", "review", "acme/widgets", "42", "1"]),
kind: EventKind::Review,
occurred_at: ts,
actor: Actor {
login: "reviewer".into(),
id: None,
},
repo: RepoRef {
full_name: "acme/widgets".into(),
html_url: None,
visibility: RepoVisibility::Private,
},
payload: EventPayload::Review(ReviewEvent {
pull_number: 42,
pull_title: "Add feature".into(),
submitted_at: ts,
state: "approved".into(),
window: None,
}),
tags: vec![],
links: vec![],
source: SourceRef {
system: SourceSystem::Github,
url: None,
opaque_id: None,
},
};
let json = serde_json::to_string(&event).unwrap();
let back: EventEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(event, back);
}
#[test]
fn event_envelope_manual_serde_roundtrip() {
use chrono::{TimeZone, Utc};
let ts = Utc.with_ymd_and_hms(2025, 3, 15, 10, 0, 0).unwrap();
let event = EventEnvelope {
id: EventId::from_parts(["manual", "incident-1"]),
kind: EventKind::Manual,
occurred_at: ts,
actor: Actor {
login: "oncall".into(),
id: None,
},
repo: RepoRef {
full_name: "acme/widgets".into(),
html_url: None,
visibility: RepoVisibility::Unknown,
},
payload: EventPayload::Manual(ManualEvent {
event_type: ManualEventType::Incident,
title: "P1 incident".into(),
description: Some("Responded to outage".into()),
started_at: Some(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
ended_at: Some(NaiveDate::from_ymd_opt(2025, 3, 16).unwrap()),
impact: Some("Reduced MTTR".into()),
}),
tags: vec!["incident".into()],
links: vec![Link {
label: "postmortem".into(),
url: "https://wiki/incident-1".into(),
}],
source: SourceRef {
system: SourceSystem::Manual,
url: None,
opaque_id: None,
},
};
let json = serde_json::to_string(&event).unwrap();
let back: EventEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(event, back);
}
#[test]
fn manual_events_file_serde_roundtrip() {
use chrono::{TimeZone, Utc};
let ts = Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap();
let file = ManualEventsFile {
version: 1,
generated_at: ts,
events: vec![ManualEventEntry {
id: "entry-1".into(),
event_type: ManualEventType::Design,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 5, 1).unwrap()),
title: "Architecture review".into(),
description: Some("Reviewed microservice boundaries".into()),
workstream: Some("platform".into()),
tags: vec!["architecture".into()],
receipts: vec![Link {
label: "doc".into(),
url: "https://docs/arch".into(),
}],
impact: Some("Improved service isolation".into()),
}],
};
let json = serde_json::to_string(&file).unwrap();
let back: ManualEventsFile = serde_json::from_str(&json).unwrap();
assert_eq!(file, back);
}
#[test]
fn manual_event_entry_type_field_renamed() {
use chrono::NaiveDate;
let entry = ManualEventEntry {
id: "e1".into(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()),
title: "Test".into(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(
json.contains(r#""type":"#),
"expected 'type' key in JSON, got: {json}"
);
assert!(
!json.contains(r#""event_type":"#),
"should not contain 'event_type' key in JSON, got: {json}"
);
let back: ManualEventEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, back);
}
#[test]
fn manual_date_range_in_entry() {
let entry = ManualEventEntry {
id: "e2".into(),
event_type: ManualEventType::Migration,
date: ManualDate::Range {
start: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
end: NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
},
title: "DB migration".into(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
};
let json = serde_json::to_string(&entry).unwrap();
let back: ManualEventEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, back);
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManualEventsFile {
pub version: u32,
pub generated_at: DateTime<Utc>,
pub events: Vec<ManualEventEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManualEventEntry {
pub id: String,
#[serde(rename = "type")]
pub event_type: ManualEventType,
pub date: ManualDate,
pub title: String,
pub description: Option<String>,
pub workstream: Option<String>,
pub tags: Vec<String>,
pub receipts: Vec<Link>,
pub impact: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum ManualDate {
Single(NaiveDate),
Range {
start: NaiveDate,
end: NaiveDate,
},
}