Skip to main content

alef/e2e/fixture/
metadata.rs

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    /// Facts this fixture supplies for `[crates.e2e.snippets].sample_url_template` placeholders
59    /// other than `{path}` (see `crate::core::config::e2e::SampleUrlTemplate`), e.g.
60    /// `{"digest": "9f86d081..."}` for a template shaped
61    /// `"https://cdn.example.org/objects/{digest}"`.
62    ///
63    /// Kept on the fixture rather than a separate corpus-manifest file the run would have to
64    /// read and keep in sync: the fact that decides this fixture's own address belongs beside
65    /// the fixture it describes. Unused, and harmless to leave empty, when the project
66    /// configures no `sample_url_template` at all -- resolution then falls back to
67    /// `sample_base_url` exactly as it always has.
68    #[serde(default)]
69    pub sample_url_vars: BTreeMap<String, String>,
70    /// The corpus-relative path of this fixture's underlying content file, used to look up
71    /// `[crates.e2e.snippets].sample_url_manifest` entries for it (see
72    /// `crate::core::config::e2e::SampleUrlManifest`).
73    ///
74    /// Distinct from `sample_url_vars` above: this names WHICH manifest entry belongs to this
75    /// fixture, while `sample_url_vars` declares facts directly. A fixture with no manifest
76    /// configured, or with no `body_file` declared, is unaffected either way -- resolution falls
77    /// back through `sample_url_vars` and then `sample_base_url` exactly as it always has.
78    #[serde(default)]
79    pub body_file: Option<String>,
80    /// The public address THIS fixture's sample input really is served at, overriding
81    /// `[crates.e2e.snippets].sample_base_url` for this fixture alone.
82    ///
83    /// Exists for the asymmetric corpus: a project declares `[crates.e2e.snippets].mock_only`
84    /// because almost none of its sample inputs are hosted anywhere, and the handful that
85    /// genuinely are say so here. The override wins over the corpus default in BOTH directions
86    /// -- it supplies an address where the corpus declares none, and it replaces the corpus
87    /// base where one is configured -- so a mock-only corpus is a default, never a ceiling.
88    ///
89    /// Resolved through exactly the same validator and the same `join` as the corpus-level
90    /// base, so a fixture whose URL argument is undeclared publishes this value verbatim, and
91    /// one whose input declares a mock-relative path (`"/pdf/report.pdf"`) publishes this value
92    /// with that path appended -- identical to what a corpus-wide `sample_base_url` of this
93    /// value would have produced for this fixture.
94    ///
95    /// Declaring it also re-enables the reserved-domain warning for this fixture: `mock_only`
96    /// suppresses "no public address exists for this fixture", never "this fixture claimed one
97    /// and it did not resolve". See `crate::e2e::snippets::sample_url_policy`.
98    #[serde(default)]
99    pub sample_url: Option<String>,
100}
101
102/// How a documentation snippet constructs its client, for fixtures whose subject
103/// *is* the client configuration.
104///
105/// A snippet is rendered with the mock harness stripped, so the generator's client
106/// construction degenerates to `factory(<credential>, <no base URL>, <defaults…>)`.
107/// A `configuration/*` fixture documenting a client setting can therefore not show
108/// the setting it is named for unless it says so here.
109///
110/// This is docs-only. Generators reach it through [`super::Fixture::docs_client`] and
111/// must pass it in from a documentation-only call site, so it cannot retarget the
112/// executable e2e suite, whose client has to keep pointing at the mock server.
113#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
114pub struct FixtureDocsClient {
115    /// Base URL the snippet's client is constructed with.
116    ///
117    /// Carried as plain data rather than a rendered expression: every binding wraps
118    /// an optional string differently (`Some("…".to_string())` in Rust, a bare literal
119    /// in Java), and that wrapping is the generator's knowledge, not the fixture's.
120    #[serde(default)]
121    pub base_url: Option<String>,
122    /// Verbatim argument expressions following the base URL, keyed by binding language
123    /// (`rust`, `java`, …).
124    ///
125    /// Overrides `[e2e.call.overrides.<lang>] client_factory_trailing_args` for this
126    /// fixture alone; a language absent from the map keeps the configured list. Values
127    /// are emitted as written, so they must be valid source in the target language —
128    /// use this for settings with no language-agnostic representation (timeouts,
129    /// retry policies, builder expressions).
130    #[serde(default)]
131    pub args: BTreeMap<String, Vec<String>>,
132}
133
134impl FixtureDocsClient {
135    /// Verbatim trailing argument expressions this fixture declares for `language`,
136    /// or `None` when it defers to the configured default.
137    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        /// Print the value with the language's human-readable formatter rather than its
168        /// debug/inspection one.
169        ///
170        /// Defaults to `false`, which keeps the debug formatting every existing fixture
171        /// renders with today. Set it for a `path` that resolves to a plain string or
172        /// number, where the Rust snippet's `{:?}` publishes `Some(Text("Hello!"))` in
173        /// place of the `Hello!` a reader is looking for. Mirrors `Iterate::display`.
174        #[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}