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}