1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4use crate::core::config::e2e::ArgMapping;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum TemplateReturnForm {
9 #[default]
10 Dict,
11 BareString,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FixtureEnv {
16 #[serde(default)]
17 pub api_key_var: Option<String>,
18}
19
20impl FixtureEnv {
21 pub(crate) fn api_key_var_or_default(env: Option<&Self>) -> &str {
22 env.and_then(|value| value.api_key_var.as_deref()).unwrap_or("API_KEY")
23 }
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SetupCall {
28 pub call: String,
29 #[serde(default)]
30 pub input: serde_json::Value,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct FixtureDocs {
35 pub topic: String,
36 #[serde(default)]
37 pub stem: Option<String>,
38 #[serde(default)]
39 pub paths: BTreeMap<String, String>,
40 #[serde(default)]
41 pub title: Option<String>,
42 #[serde(default)]
43 pub description: Option<String>,
44 #[serde(default)]
45 pub input: Option<serde_json::Value>,
46 #[serde(default)]
47 pub shows: Vec<String>,
48 #[serde(default)]
49 pub error: Option<bool>,
50 #[serde(default)]
51 pub presentation: Option<FixtureDocsPresentation>,
52 #[serde(default)]
53 pub client: Option<FixtureDocsClient>,
54 #[serde(default)]
55 pub side_effects: SideEffectClass,
56 #[serde(default)]
57 pub coverage_exceptions: BTreeMap<String, SnippetCoverageException>,
58 #[serde(default)]
69 pub sample_url_vars: BTreeMap<String, String>,
70 #[serde(default)]
79 pub body_file: Option<String>,
80 #[serde(default)]
99 pub sample_url: Option<String>,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
114pub struct FixtureDocsClient {
115 #[serde(default)]
121 pub base_url: Option<String>,
122 #[serde(default)]
131 pub args: BTreeMap<String, Vec<String>>,
132}
133
134impl FixtureDocsClient {
135 pub fn args_for(&self, language: &str) -> Option<&[String]> {
138 self.args.get(language).map(Vec::as_slice)
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct FixtureDocsPresentation {
144 #[serde(default)]
145 pub call: Option<String>,
146 #[serde(default)]
147 pub input: Option<serde_json::Value>,
148 #[serde(default)]
149 pub args: Option<Vec<ArgMapping>>,
150 #[serde(default)]
151 pub files: Vec<FixtureDocsFileInput>,
152 #[serde(default)]
153 pub operations: Vec<FixtureDocsOperation>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct FixtureDocsFileInput {
158 pub field: String,
159 pub path: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(tag = "op", rename_all = "snake_case")]
164pub enum FixtureDocsOperation {
165 Show {
166 path: String,
167 #[serde(default)]
175 display: bool,
176 },
177 Iterate {
178 path: String,
179 item: String,
180 #[serde(default)]
181 fields: Vec<String>,
182 #[serde(default)]
183 display: bool,
184 #[serde(default)]
185 optional: bool,
186 },
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
190pub struct SnippetCoverageException {
191 pub reason: String,
192 pub documentation: String,
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum SideEffectClass {
198 #[default]
199 #[serde(alias = "none", alias = "local")]
200 Safe,
201 Network,
202 Process,
203 Install,
204 Server,
205}
206
207#[cfg(test)]
208mod tests {
209 use super::{FixtureDocs, FixtureDocsOperation, FixtureEnv, SideEffectClass};
210
211 #[test]
212 fn snippet_credential_name_prefers_fixture_metadata_with_a_generic_fallback() {
213 let configured = FixtureEnv {
214 api_key_var: Some("SAMPLE_SERVICE_TOKEN".into()),
215 };
216 assert_eq!(
217 FixtureEnv::api_key_var_or_default(Some(&configured)),
218 "SAMPLE_SERVICE_TOKEN"
219 );
220 assert_eq!(FixtureEnv::api_key_var_or_default(None), "API_KEY");
221 }
222
223 #[test]
224 fn side_effects_round_trip_without_collapsing_classes() {
225 for class in [
226 SideEffectClass::Safe,
227 SideEffectClass::Network,
228 SideEffectClass::Process,
229 SideEffectClass::Install,
230 SideEffectClass::Server,
231 ] {
232 let encoded = serde_json::to_string(&class).unwrap();
233 assert_eq!(serde_json::from_str::<SideEffectClass>(&encoded).unwrap(), class);
234 }
235 }
236
237 #[test]
238 fn docs_paths_deserialize_as_target_specific_relative_paths() {
239 let docs: FixtureDocs = serde_json::from_value(serde_json::json!({
240 "topic": "fallback",
241 "paths": {
242 "node": "config/example.md",
243 "wasm": "browser/example.md"
244 }
245 }))
246 .expect("fixture docs deserialize");
247
248 assert_eq!(docs.paths["node"], "config/example.md");
249 assert_eq!(docs.paths["wasm"], "browser/example.md");
250 }
251
252 #[test]
253 fn legacy_safe_aliases_remain_accepted_but_external_mutation_is_rejected() {
254 assert_eq!(
255 serde_json::from_str::<SideEffectClass>(r#""none""#).unwrap(),
256 SideEffectClass::Safe
257 );
258 assert_eq!(
259 serde_json::from_str::<SideEffectClass>(r#""local""#).unwrap(),
260 SideEffectClass::Safe
261 );
262 assert!(serde_json::from_str::<SideEffectClass>(r#""external_mutation""#).is_err());
263 }
264
265 #[test]
266 fn structured_presentation_deserializes_without_language_code() {
267 let docs: FixtureDocs = serde_json::from_value(serde_json::json!({
268 "topic": "configuration",
269 "presentation": {
270 "call": "process_file",
271 "input": {"source": "guide.txt"},
272 "args": [{"name": "source", "field": "source", "type": "string"}],
273 "files": [{"field": "/source", "path": "examples/guide.txt"}],
274 "operations": [
275 {"op": "show", "path": "summary"},
276 {"op": "iterate", "path": "items", "item": "item", "fields": ["text"], "optional": true}
277 ]
278 }
279 }))
280 .expect("fixture docs presentation deserialize");
281
282 let presentation = docs.presentation.expect("presentation");
283 assert_eq!(presentation.input, Some(serde_json::json!({"source": "guide.txt"})));
284 assert_eq!(presentation.call.as_deref(), Some("process_file"));
285 assert_eq!(presentation.files[0].field, "/source");
286 assert!(matches!(
287 presentation.operations[0],
288 FixtureDocsOperation::Show { display: false, .. }
289 ));
290 assert!(matches!(
291 presentation.operations[1],
292 FixtureDocsOperation::Iterate { optional: true, .. }
293 ));
294 }
295
296 #[test]
297 fn show_operation_display_flag_is_opt_in() {
298 let docs: FixtureDocs = serde_json::from_value(serde_json::json!({
299 "topic": "configuration",
300 "presentation": {
301 "operations": [
302 {"op": "show", "path": "summary"},
303 {"op": "show", "path": "text", "display": true}
304 ]
305 }
306 }))
307 .expect("fixture docs presentation deserialize");
308
309 let presentation = docs.presentation.expect("presentation");
310 assert!(
311 matches!(
312 presentation.operations[0],
313 FixtureDocsOperation::Show { display: false, .. }
314 ),
315 "an existing `show` without the flag must keep debug formatting"
316 );
317 assert!(
318 matches!(
319 presentation.operations[1],
320 FixtureDocsOperation::Show { display: true, .. }
321 ),
322 "`display: true` must survive deserialization"
323 );
324 }
325
326 #[test]
327 fn docs_client_deserializes_a_base_url_and_per_language_argument_lists() {
328 let docs: FixtureDocs = serde_json::from_value(serde_json::json!({
329 "topic": "configuration",
330 "client": {
331 "base_url": "https://llm.internal.example.com/v1",
332 "args": {"rust": ["Some(30)", "None", "None"], "java": ["30", "null", "null"]}
333 }
334 }))
335 .expect("fixture docs client deserialize");
336
337 let client = docs.client.expect("client");
338 assert_eq!(client.base_url.as_deref(), Some("https://llm.internal.example.com/v1"));
339 let expected: Vec<String> = ["Some(30)", "None", "None"].map(String::from).to_vec();
340 assert_eq!(client.args_for("rust"), Some(expected.as_slice()));
341 assert_eq!(
342 client.args_for("gleam"),
343 None,
344 "an unlisted language must not be invented"
345 );
346 }
347
348 #[test]
349 fn docs_without_a_client_key_carry_no_client_override() {
350 let docs: FixtureDocs =
351 serde_json::from_value(serde_json::json!({"topic": "configuration"})).expect("fixture docs deserialize");
352 assert_eq!(docs.client, None);
353 }
354
355 #[test]
356 fn sample_url_vars_default_to_empty_and_deserialize_when_declared() {
357 let bare: FixtureDocs =
358 serde_json::from_value(serde_json::json!({"topic": "configuration"})).expect("fixture docs deserialize");
359 assert!(bare.sample_url_vars.is_empty());
360
361 let with_vars: FixtureDocs = serde_json::from_value(serde_json::json!({
362 "topic": "configuration",
363 "sample_url_vars": {"digest": "9f86d081884c7d659a2feaa0c55ad015"}
364 }))
365 .expect("fixture docs with sample_url_vars deserialize");
366 assert_eq!(
367 with_vars.sample_url_vars.get("digest").map(String::as_str),
368 Some("9f86d081884c7d659a2feaa0c55ad015")
369 );
370 }
371
372 #[test]
373 fn body_file_defaults_to_none_and_deserializes_when_declared() {
374 let bare: FixtureDocs =
375 serde_json::from_value(serde_json::json!({"topic": "configuration"})).expect("fixture docs deserialize");
376 assert_eq!(bare.body_file, None);
377
378 let with_body_file: FixtureDocs = serde_json::from_value(serde_json::json!({
379 "topic": "configuration",
380 "body_file": "pdf/memo.pdf"
381 }))
382 .expect("fixture docs with body_file deserialize");
383 assert_eq!(with_body_file.body_file.as_deref(), Some("pdf/memo.pdf"));
384 }
385
386 #[test]
387 fn concise_docs_contract_deserializes_structured_input_and_result_intent() {
388 let docs: FixtureDocs = serde_json::from_value(serde_json::json!({
389 "topic": "configuration",
390 "description": "Process a structured source.",
391 "input": {"source": {"kind": "text", "value": "Hello"}},
392 "shows": ["summary", "items[0].label"],
393 "error": false
394 }))
395 .expect("fixture docs deserialize");
396
397 assert_eq!(
398 docs.input,
399 Some(serde_json::json!({"source": {"kind": "text", "value": "Hello"}}))
400 );
401 assert_eq!(docs.shows, vec!["summary", "items[0].label"]);
402 assert_eq!(docs.error, Some(false));
403 }
404}