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    // `--limit 0` asks for every entry; the page says how many that was.
88    let every;
89    let page = match page {
90        Some(page) if page.limit == usize::MAX => {
91            every = AgentPagination {
92                limit: page.total,
93                ..page.clone()
94            };
95            Some(&every)
96        }
97        other => other,
98    };
99    let json = newline_json(&SuccessEnvelope {
100        schema_version: AGENT_JSON_SCHEMA_VERSION,
101        ok: true,
102        command,
103        data,
104        pagination: page,
105    })
106    .expect("serializing a Supercov JSON envelope must not fail");
107    if json.len() > AGENT_JSON_MAX_BYTES {
108        return Err(ResponseTooLarge {
109            actual_bytes: json.len(),
110            max_bytes: AGENT_JSON_MAX_BYTES,
111        });
112    }
113    Ok(json)
114}
115
116pub fn failure(command: Option<&str>, error: &AgentError) -> String {
117    let envelope = FailureEnvelope {
118        schema_version: AGENT_JSON_SCHEMA_VERSION,
119        ok: false,
120        command,
121        error,
122    };
123    let json = newline_json(&envelope).expect("serializing a Supercov error must not fail");
124    if json.len() <= AGENT_JSON_MAX_BYTES {
125        return json;
126    }
127
128    let mut truncated = error.clone();
129    truncated.message = truncated.message.chars().take(1_000).collect();
130    truncated.details = None;
131    newline_json(&FailureEnvelope {
132        error: &truncated,
133        ..envelope
134    })
135    .expect("serializing a truncated Supercov error must not fail")
136}
137
138#[cfg(test)]
139mod tests {
140    use serde_json::json;
141
142    use super::*;
143
144    #[test]
145    fn success_is_byte_identical_to_the_frozen_golden() {
146        let actual = success(
147            "coverage.summary",
148            &json!({"run": "run-123", "coverage": {"lines": 100}}),
149            None,
150        )
151        .unwrap();
152        assert_eq!(
153            actual,
154            include_str!("../test-assets/agent/agent-success.json")
155        );
156    }
157
158    #[test]
159    fn pagination_is_byte_identical_to_the_frozen_golden() {
160        let page = pagination(20, 20, 1, 21);
161        let actual = success(
162            "coverage.gaps",
163            &json!({"gaps": [{"file": "src/example.ts"}]}),
164            Some(&page),
165        )
166        .unwrap();
167        assert_eq!(actual, include_str!("../test-assets/agent/agent-page.json"));
168    }
169
170    #[test]
171    fn errors_are_byte_identical_to_the_frozen_golden() {
172        let actual = failure(
173            Some("coverage.file"),
174            &AgentError {
175                code: ErrorCode::SourceNotFound,
176                message: "Source file not found: missing.ts".into(),
177                retryable: false,
178                details: Some(json!({"selector": "missing.ts"})),
179            },
180        );
181        assert_eq!(
182            actual,
183            include_str!("../test-assets/agent/agent-error.json")
184        );
185    }
186
187    #[test]
188    fn success_enforces_the_agent_context_budget() {
189        let result = success("coverage.file", &"x".repeat(AGENT_JSON_MAX_BYTES), None);
190        assert!(matches!(
191            result,
192            Err(ResponseTooLarge {
193                max_bytes: AGENT_JSON_MAX_BYTES,
194                ..
195            })
196        ));
197    }
198}