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 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}