Skip to main content

supercov_engine/
agent_json.rs

1use serde::Serialize;
2use serde_json::Value;
3use supercov_contracts::{AGENT_JSON_MAX_BYTES, AGENT_JSON_SCHEMA_VERSION, AgentPagination};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
6#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
7pub enum ErrorCode {
8    AmbiguousSelector,
9    DecisionNotFound,
10    FilterUnavailable,
11    InternalError,
12    InvalidArgument,
13    MinimizationComplexityLimit,
14    NoRuns,
15    ResponseTooLarge,
16    RunNotFound,
17    ScopeUnavailable,
18    SourceNotFound,
19    TargetUnreachable,
20    TestFilterEmpty,
21    TestNotFound,
22    UnattributedEvidence,
23    UnknownCommand,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct AgentError {
29    pub code: ErrorCode,
30    pub message: String,
31    pub retryable: bool,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub details: Option<Value>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ResponseTooLarge {
38    pub actual_bytes: usize,
39    pub max_bytes: usize,
40}
41
42#[derive(Serialize)]
43#[serde(rename_all = "camelCase")]
44struct SuccessEnvelope<'a, T> {
45    schema_version: u32,
46    ok: bool,
47    command: &'a str,
48    data: &'a T,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pagination: Option<&'a AgentPagination>,
51}
52
53#[derive(Serialize)]
54#[serde(rename_all = "camelCase")]
55struct FailureEnvelope<'a> {
56    schema_version: u32,
57    ok: bool,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    command: Option<&'a str>,
60    error: &'a AgentError,
61}
62
63fn newline_json<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
64    let mut json = serde_json::to_string(value)?;
65    json.push('\n');
66    Ok(json)
67}
68
69pub fn pagination(offset: usize, limit: usize, returned: usize, total: usize) -> AgentPagination {
70    let next = offset.saturating_add(returned);
71    let has_more = returned > 0 && next < total;
72    AgentPagination {
73        offset,
74        limit,
75        returned,
76        total,
77        has_more,
78        next_offset: has_more.then_some(next),
79    }
80}
81
82pub fn success<T: Serialize>(
83    command: &str,
84    data: &T,
85    page: Option<&AgentPagination>,
86) -> Result<String, ResponseTooLarge> {
87    let json = newline_json(&SuccessEnvelope {
88        schema_version: AGENT_JSON_SCHEMA_VERSION,
89        ok: true,
90        command,
91        data,
92        pagination: page,
93    })
94    .expect("serializing a Supercov JSON envelope must not fail");
95    if json.len() > AGENT_JSON_MAX_BYTES {
96        return Err(ResponseTooLarge {
97            actual_bytes: json.len(),
98            max_bytes: AGENT_JSON_MAX_BYTES,
99        });
100    }
101    Ok(json)
102}
103
104pub fn failure(command: Option<&str>, error: &AgentError) -> String {
105    let envelope = FailureEnvelope {
106        schema_version: AGENT_JSON_SCHEMA_VERSION,
107        ok: false,
108        command,
109        error,
110    };
111    let json = newline_json(&envelope).expect("serializing a Supercov error must not fail");
112    if json.len() <= AGENT_JSON_MAX_BYTES {
113        return json;
114    }
115
116    let mut truncated = error.clone();
117    truncated.message = truncated.message.chars().take(1_000).collect();
118    truncated.details = None;
119    newline_json(&FailureEnvelope {
120        error: &truncated,
121        ..envelope
122    })
123    .expect("serializing a truncated Supercov error must not fail")
124}
125
126#[cfg(test)]
127mod tests {
128    use serde_json::json;
129
130    use super::*;
131
132    #[test]
133    fn success_is_byte_identical_to_the_frozen_golden() {
134        let actual = success(
135            "coverage.summary",
136            &json!({"run": "run-123", "coverage": {"lines": 100}}),
137            None,
138        )
139        .unwrap();
140        assert_eq!(
141            actual,
142            include_str!("../test-assets/agent/agent-success.json")
143        );
144    }
145
146    #[test]
147    fn pagination_is_byte_identical_to_the_frozen_golden() {
148        let page = pagination(20, 20, 1, 21);
149        let actual = success(
150            "coverage.gaps",
151            &json!({"gaps": [{"file": "src/example.ts"}]}),
152            Some(&page),
153        )
154        .unwrap();
155        assert_eq!(actual, include_str!("../test-assets/agent/agent-page.json"));
156    }
157
158    #[test]
159    fn errors_are_byte_identical_to_the_frozen_golden() {
160        let actual = failure(
161            Some("coverage.file"),
162            &AgentError {
163                code: ErrorCode::SourceNotFound,
164                message: "Source file not found: missing.ts".into(),
165                retryable: false,
166                details: Some(json!({"selector": "missing.ts"})),
167            },
168        );
169        assert_eq!(
170            actual,
171            include_str!("../test-assets/agent/agent-error.json")
172        );
173    }
174
175    #[test]
176    fn success_enforces_the_agent_context_budget() {
177        let result = success("coverage.file", &"x".repeat(AGENT_JSON_MAX_BYTES), None);
178        assert!(matches!(
179            result,
180            Err(ResponseTooLarge {
181                max_bytes: AGENT_JSON_MAX_BYTES,
182                ..
183            })
184        ));
185    }
186}