faucet_cli/commands/
contract.rs1use 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
11const PRODUCER: &str = concat!(
13 "https://github.com/faucet-hq/faucet-stream/tree/v",
14 env!("CARGO_PKG_VERSION")
15);
16
17pub 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 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
51pub 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
62fn 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}