Skip to main content

faucet_cli/commands/
contract.rs

1//! `faucet contract` — validate a config's `contract:` block and print a
2//! human summary, or export it in a machine-readable format (`--export
3//! contract | json-schema | openlineage`). Offline-safe: secrets are never
4//! fetched (a contract holds no credentials).
5
6use crate::cli::{ContractArgs, ContractExportFormat};
7use crate::config::PipelineConfig;
8use crate::error::{CliError, CliResult};
9use faucet_core::contract::{CompiledContract, ContractSpec, to_json_schema, to_openlineage_facet};
10
11/// Producer identifier embedded in the OpenLineage export.
12const PRODUCER: &str = concat!(
13    "https://github.com/faucet-hq/faucet-stream/tree/v",
14    env!("CARGO_PKG_VERSION")
15);
16
17/// Execute the `contract` subcommand.
18pub async fn run(args: ContractArgs) -> CliResult<()> {
19    let cwd = std::env::current_dir()?;
20    let env_path =
21        crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
22    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
23
24    let path = match args.config {
25        Some(p) => p,
26        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
27    };
28    let cfg = PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?;
29    let spec = cfg.pipeline.contract.as_ref().ok_or_else(|| {
30        CliError::Config(
31            "no `pipeline.contract:` block in this config — add one, or run \
32             `faucet schema contract` to see the block's JSON Schema"
33                .to_string(),
34        )
35    })?;
36    // Compile first so a malformed contract fails before anything is printed.
37    let compiled =
38        CompiledContract::compile(spec).map_err(|e| CliError::Config(format!("contract: {e}")))?;
39
40    match args.export {
41        None => print!("{}", render_summary(spec, &compiled)),
42        Some(format) => {
43            let doc = export(spec, format);
44            let body = serde_json::to_string_pretty(&doc).unwrap_or_else(|_| doc.to_string());
45            println!("{body}");
46        }
47    }
48    Ok(())
49}
50
51/// Render the exported document for the requested format. Pure.
52pub fn export(spec: &ContractSpec, format: ContractExportFormat) -> serde_json::Value {
53    match format {
54        ContractExportFormat::Contract => {
55            serde_json::to_value(spec).unwrap_or_else(|_| serde_json::json!({}))
56        }
57        ContractExportFormat::JsonSchema => to_json_schema(spec),
58        ContractExportFormat::Openlineage => to_openlineage_facet(spec, PRODUCER),
59    }
60}
61
62/// Render the human summary. Pure — returned as a string for testability.
63fn render_summary(spec: &ContractSpec, compiled: &CompiledContract) -> String {
64    use std::fmt::Write;
65    let mut out = String::new();
66    let _ = writeln!(
67        out,
68        "contract v{} — valid ({} field{})",
69        spec.version,
70        spec.fields.len(),
71        if spec.fields.len() == 1 { "" } else { "s" }
72    );
73    if let Some(d) = &spec.description {
74        let _ = writeln!(out, "  description: {d}");
75    }
76    if let Some(o) = &spec.owner {
77        let _ = writeln!(out, "  owner: {o}");
78    }
79    let _ = writeln!(out, "  on_breach: {}", spec.on_breach);
80    let _ = writeln!(out, "  allow_extra_fields: {}", spec.allow_extra_fields);
81    let _ = writeln!(out, "  fields:");
82    for f in &spec.fields {
83        let mut flags: Vec<String> = Vec::new();
84        if !f.required {
85            flags.push("optional".into());
86        }
87        if f.nullable {
88            flags.push("nullable".into());
89        }
90        if let Some(values) = &f.allowed_values {
91            flags.push(format!("enum[{}]", values.len()));
92        }
93        if f.pattern.is_some() {
94            flags.push("pattern".into());
95        }
96        if f.min.is_some() || f.max.is_some() {
97            flags.push("range".into());
98        }
99        if f.min_length.is_some() || f.max_length.is_some() {
100            flags.push("length".into());
101        }
102        let suffix = if flags.is_empty() {
103            String::new()
104        } else {
105            format!(" ({})", flags.join(", "))
106        };
107        let _ = writeln!(out, "    - {}: {}{}", f.name, f.field_type, suffix);
108    }
109    if compiled.requires_dlq() {
110        let _ = writeln!(
111            out,
112            "  note: on_breach=quarantine requires a `dlq:` block at run time"
113        );
114    }
115    out
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use serde_json::json;
122
123    fn spec() -> ContractSpec {
124        serde_json::from_value(json!({
125            "version": "1.2.0",
126            "owner": "data-platform",
127            "on_breach": "quarantine",
128            "allow_extra_fields": false,
129            "fields": [
130                { "name": "id", "type": "integer", "min": 0 },
131                { "name": "status", "type": "string", "enum": ["a", "b"],
132                  "required": false, "nullable": true }
133            ]
134        }))
135        .unwrap()
136    }
137
138    #[test]
139    fn summary_lists_fields_policy_and_dlq_note() {
140        let s = spec();
141        let compiled = CompiledContract::compile(&s).unwrap();
142        let out = render_summary(&s, &compiled);
143        assert!(out.contains("contract v1.2.0 — valid (2 fields)"), "{out}");
144        assert!(out.contains("owner: data-platform"), "{out}");
145        assert!(out.contains("on_breach: quarantine"), "{out}");
146        assert!(out.contains("allow_extra_fields: false"), "{out}");
147        assert!(out.contains("- id: integer (range)"), "{out}");
148        assert!(
149            out.contains("- status: string (optional, nullable, enum[2])"),
150            "{out}"
151        );
152        assert!(out.contains("requires a `dlq:` block"), "{out}");
153    }
154
155    #[test]
156    fn summary_omits_dlq_note_for_fail() {
157        let s: ContractSpec = serde_json::from_value(json!({
158            "version": "1",
159            "fields": [{ "name": "id", "type": "string" }]
160        }))
161        .unwrap();
162        let compiled = CompiledContract::compile(&s).unwrap();
163        let out = render_summary(&s, &compiled);
164        assert!(out.contains("(1 field)"), "{out}");
165        assert!(!out.contains("dlq"), "{out}");
166    }
167
168    #[test]
169    fn export_contract_round_trips_the_spec() {
170        let doc = export(&spec(), ContractExportFormat::Contract);
171        let back: ContractSpec = serde_json::from_value(doc).unwrap();
172        assert_eq!(back.version, "1.2.0");
173        assert_eq!(back.fields.len(), 2);
174    }
175
176    #[test]
177    fn export_json_schema_is_a_schema_document() {
178        let doc = export(&spec(), ContractExportFormat::JsonSchema);
179        assert_eq!(doc["x-faucet-contract-version"], "1.2.0");
180        assert_eq!(doc["type"], "object");
181        assert_eq!(doc["additionalProperties"], false);
182        assert!(doc["properties"]["id"].is_object());
183    }
184
185    #[test]
186    fn export_openlineage_is_a_schema_facet() {
187        let doc = export(&spec(), ContractExportFormat::Openlineage);
188        assert!(doc["_producer"].as_str().unwrap().contains("faucet-stream"));
189        assert_eq!(doc["fields"].as_array().unwrap().len(), 2);
190    }
191}