Skip to main content

faucet_cli/pipeline_test/
fixtures.rs

1//! Fixture-input loading for `faucet test`.
2//!
3//! A case's `input` is either an inline array of records or a path to a
4//! fixture file. Files resolve relative to the spec file's directory and may
5//! be `.jsonl` (one JSON record per line, blank lines skipped) or `.json` /
6//! `.yaml` / `.yml` (a top-level array).
7
8use crate::error::{CliError, CliResult};
9use crate::pipeline_test::spec::InputSpec;
10use serde_json::Value;
11use std::path::Path;
12
13/// Materialize a case's fixture records.
14pub fn load_input(spec_dir: &Path, input: &InputSpec) -> CliResult<Vec<Value>> {
15    match input {
16        InputSpec::Inline(records) => Ok(records.clone()),
17        InputSpec::Path(rel) => load_fixture_file(&spec_dir.join(rel)),
18    }
19}
20
21fn load_fixture_file(path: &Path) -> CliResult<Vec<Value>> {
22    let text = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
23        path: path.to_path_buf(),
24        source,
25    })?;
26    let ext = path
27        .extension()
28        .and_then(|e| e.to_str())
29        .map(str::to_ascii_lowercase);
30    match ext.as_deref() {
31        Some("jsonl" | "ndjson") => text
32            .lines()
33            .enumerate()
34            .filter(|(_, line)| !line.trim().is_empty())
35            .map(|(i, line)| {
36                serde_json::from_str(line).map_err(|e| CliError::ParseConfig {
37                    path: path.to_path_buf(),
38                    message: format!("line {}: {e}", i + 1),
39                })
40            })
41            .collect(),
42        Some("json") => as_array(
43            serde_json::from_str(&text).map_err(|e| CliError::ParseConfig {
44                path: path.to_path_buf(),
45                message: e.to_string(),
46            })?,
47            path,
48        ),
49        Some("yaml" | "yml") => as_array(
50            serde_yaml::from_str(&text).map_err(|e| CliError::ParseConfig {
51                path: path.to_path_buf(),
52                message: e.to_string(),
53            })?,
54            path,
55        ),
56        _ => Err(CliError::Config(format!(
57            "fixture file '{}' must be .jsonl, .ndjson, .json, .yaml, or .yml",
58            path.display()
59        ))),
60    }
61}
62
63fn as_array(v: Value, path: &Path) -> CliResult<Vec<Value>> {
64    match v {
65        Value::Array(records) => Ok(records),
66        other => Err(CliError::Config(format!(
67            "fixture file '{}' must hold a top-level array of records, got {}",
68            path.display(),
69            type_name(&other)
70        ))),
71    }
72}
73
74fn type_name(v: &Value) -> &'static str {
75    match v {
76        Value::Null => "null",
77        Value::Bool(_) => "a boolean",
78        Value::Number(_) => "a number",
79        Value::String(_) => "a string",
80        Value::Array(_) => "an array",
81        Value::Object(_) => "an object",
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use serde_json::json;
89
90    #[test]
91    fn inline_records_pass_through() {
92        let dir = tempfile::tempdir().unwrap();
93        let records = load_input(
94            dir.path(),
95            &InputSpec::Inline(vec![json!({"a": 1}), json!({"a": 2})]),
96        )
97        .unwrap();
98        assert_eq!(records.len(), 2);
99    }
100
101    #[test]
102    fn jsonl_fixture_skips_blank_lines() {
103        let dir = tempfile::tempdir().unwrap();
104        std::fs::write(dir.path().join("f.jsonl"), "{\"a\":1}\n\n{\"a\":2}\n").unwrap();
105        let records = load_input(dir.path(), &InputSpec::Path("f.jsonl".into())).unwrap();
106        assert_eq!(records, vec![json!({"a": 1}), json!({"a": 2})]);
107    }
108
109    #[test]
110    fn jsonl_bad_line_reports_line_number() {
111        let dir = tempfile::tempdir().unwrap();
112        std::fs::write(dir.path().join("f.jsonl"), "{\"a\":1}\nnot-json\n").unwrap();
113        let err = load_input(dir.path(), &InputSpec::Path("f.jsonl".into()))
114            .unwrap_err()
115            .to_string();
116        assert!(err.contains("line 2"), "{err}");
117    }
118
119    #[test]
120    fn json_and_yaml_arrays_load() {
121        let dir = tempfile::tempdir().unwrap();
122        std::fs::write(dir.path().join("f.json"), r#"[{"a":1}]"#).unwrap();
123        std::fs::write(dir.path().join("f.yaml"), "- a: 1\n- a: 2\n").unwrap();
124        assert_eq!(
125            load_input(dir.path(), &InputSpec::Path("f.json".into())).unwrap(),
126            vec![json!({"a": 1})]
127        );
128        assert_eq!(
129            load_input(dir.path(), &InputSpec::Path("f.yaml".into()))
130                .unwrap()
131                .len(),
132            2
133        );
134    }
135
136    #[test]
137    fn non_array_json_rejected() {
138        let dir = tempfile::tempdir().unwrap();
139        std::fs::write(dir.path().join("f.json"), r#"{"a":1}"#).unwrap();
140        let err = load_input(dir.path(), &InputSpec::Path("f.json".into()))
141            .unwrap_err()
142            .to_string();
143        assert!(err.contains("top-level array"), "{err}");
144        assert!(err.contains("an object"), "{err}");
145    }
146
147    #[test]
148    fn unknown_extension_and_missing_file_rejected() {
149        let dir = tempfile::tempdir().unwrap();
150        std::fs::write(dir.path().join("f.csv"), "a\n1\n").unwrap();
151        assert!(
152            load_input(dir.path(), &InputSpec::Path("f.csv".into()))
153                .unwrap_err()
154                .to_string()
155                .contains("must be .jsonl")
156        );
157        assert!(matches!(
158            load_input(dir.path(), &InputSpec::Path("missing.jsonl".into())),
159            Err(CliError::ReadConfig { .. })
160        ));
161    }
162}