use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use crate::agent::AgentName;
use crate::flight::{ItineraryId, RunId};
pub const MAX_HEADLINE: usize = 160;
pub const MAX_BODY: usize = 12_000;
pub const MAX_ARTIFACTS: usize = 32;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Report {
pub run: RunId,
pub agent: AgentName,
pub itinerary: ItineraryId,
pub headline: String,
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<String>,
pub at: Timestamp,
}
impl Report {
#[must_use]
pub fn new(
run: RunId,
agent: AgentName,
itinerary: ItineraryId,
headline: impl Into<String>,
body: impl Into<String>,
at: Timestamp,
) -> Self {
Self {
run,
agent,
itinerary,
headline: clamp(&headline.into(), MAX_HEADLINE),
body: clamp(&body.into(), MAX_BODY),
artifacts: Vec::new(),
at,
}
}
#[must_use]
pub fn producing(mut self, artifacts: Vec<String>) -> Self {
self.artifacts = artifacts.into_iter().take(MAX_ARTIFACTS).collect();
self
}
#[must_use]
pub fn has_body(&self) -> bool {
!self.body.trim().is_empty()
}
#[must_use]
pub fn was_trimmed(&self) -> bool {
self.headline.ends_with('…') || self.body.ends_with('…')
}
}
fn clamp(text: &str, limit: usize) -> String {
let trimmed = text.trim();
if trimmed.chars().count() <= limit {
return trimmed.to_owned();
}
let mut kept: String = trimmed.chars().take(limit.saturating_sub(1)).collect();
kept.push('…');
kept
}
#[cfg(test)]
mod tests {
use super::*;
fn at() -> Timestamp {
"2026-09-17T10:00:00Z".parse().expect("valid timestamp")
}
fn report(headline: &str, body: &str) -> Report {
Report::new(
RunId::generate(),
"developer".into(),
ItineraryId::generate(),
headline,
body,
at(),
)
}
#[test]
fn a_report_keeps_what_the_agent_wrote() {
let written = report(
"Fixed the proxy option mismatch",
"The three assertions were incompatible; changed the factory to take the resolved \
options rather than rebuilding them.",
);
assert_eq!(written.headline, "Fixed the proxy option mismatch");
assert!(written.has_body());
assert!(!written.was_trimmed());
}
#[test]
fn an_overlong_report_is_trimmed_rather_than_refused() {
let written = report(&"x".repeat(MAX_HEADLINE + 50), &"y".repeat(MAX_BODY + 500));
assert_eq!(written.headline.chars().count(), MAX_HEADLINE);
assert_eq!(written.body.chars().count(), MAX_BODY);
assert!(written.was_trimmed());
}
#[test]
fn a_reader_can_tell_a_report_was_cut() {
assert!(report(&"x".repeat(MAX_HEADLINE + 1), "short").was_trimmed());
assert!(!report("short", "short").was_trimmed());
}
#[test]
fn artifacts_are_capped_but_the_report_survives() {
let many: Vec<String> = (0..MAX_ARTIFACTS + 10)
.map(|n| format!("file{n}.rs"))
.collect();
let written = report("did a lot", "body").producing(many);
assert_eq!(written.artifacts.len(), MAX_ARTIFACTS);
assert!(written.has_body());
}
#[test]
fn a_report_with_nothing_in_the_body_is_recognisable() {
let written = report("Nothing had changed on the pull request", " ");
assert!(!written.has_body());
}
#[test]
fn a_report_serialises_to_one_readable_line() {
let line = serde_json::to_string(&report("Fixed it", "Details here")).expect("serialises");
assert!(!line.contains('\n'));
assert!(line.contains(r#""headline":"Fixed it""#), "{line}");
assert!(line.contains(r#""at":"2026-09-17T10:00:00Z""#), "{line}");
assert!(!line.contains("artifacts"), "absent, not empty: {line}");
}
#[test]
fn a_report_round_trips() {
let written = report("Fixed it", "Details").producing(vec!["branch/fix-1".to_owned()]);
let line = serde_json::to_string(&written).expect("serialises");
assert_eq!(
serde_json::from_str::<Report>(&line).expect("deserialises"),
written
);
}
}