faucet_cli/catalog/
model.rs1use chrono::{DateTime, FixedOffset};
6use serde_json::Value;
7
8pub fn canonicalize_uri(uri: &str, raw_config: &Value, clock: DateTime<FixedOffset>) -> String {
19 let mut ctxs: Vec<TokenCtx> = Vec::new();
20 collect_now_token_contexts(raw_config, &mut ctxs);
21
22 let mut pairs: Vec<(String, String)> = ctxs
30 .into_iter()
31 .filter_map(|c| {
32 let rendered = crate::interpolate::resolve_now(&c.token, clock).ok()?;
33 if rendered.is_empty() || rendered == c.token {
34 return None;
35 }
36 let mut search = String::new();
37 let mut replace = String::new();
38 if let Some(l) = c.left {
39 search.push(l);
40 replace.push(l);
41 }
42 search.push_str(&rendered);
43 replace.push_str(&c.token);
44 if let Some(r) = c.right {
45 search.push(r);
46 replace.push(r);
47 }
48 Some((search, replace))
49 })
50 .collect();
51 pairs.sort();
54 pairs.dedup();
55 pairs.sort_by_key(|(search, _)| std::cmp::Reverse(search.len()));
56
57 let mut out = uri.to_string();
58 for (search, replace) in pairs {
59 out = out.replace(&search, &replace);
60 }
61 faucet_core::redact_uri_credentials(&out)
62}
63
64struct TokenCtx {
68 token: String,
69 left: Option<char>,
70 right: Option<char>,
71}
72
73fn collect_now_token_contexts(v: &Value, out: &mut Vec<TokenCtx>) {
77 match v {
78 Value::String(s) => {
79 let mut search_from = 0;
80 while let Some(rel) = s[search_from..].find("${now.") {
81 let start = search_from + rel;
82 let tail = &s[start..];
83 match tail.find('}') {
84 Some(end_rel) => {
85 let end = start + end_rel; let token = s[start..=end].to_string();
87 let left = s[..start].chars().next_back();
88 let right = s[end + 1..].chars().next();
89 out.push(TokenCtx { token, left, right });
90 search_from = end + 1;
91 }
92 None => break,
93 }
94 }
95 }
96 Value::Array(items) => items
97 .iter()
98 .for_each(|i| collect_now_token_contexts(i, out)),
99 Value::Object(map) => map
100 .values()
101 .for_each(|i| collect_now_token_contexts(i, out)),
102 _ => {}
103 }
104}
105
106pub fn schema_from_samples(samples: &[Value]) -> Option<Value> {
109 if samples.is_empty() {
110 return None;
111 }
112 let schema = faucet_core::schema::infer_schema(samples);
113 schema
116 .get("properties")
117 .and_then(Value::as_object)
118 .is_some_and(|p| !p.is_empty())
119 .then_some(schema)
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use serde_json::json;
126
127 fn clock() -> DateTime<FixedOffset> {
128 DateTime::parse_from_rfc3339("2026-07-06T10:30:00Z").unwrap()
129 }
130
131 #[test]
132 fn folds_now_tokens_back_into_the_uri() {
133 let raw = json!({"path": "./out/dt=${now.date}/part.jsonl"});
134 let uri = canonicalize_uri("file://./out/dt=2026-07-06/part.jsonl", &raw, clock());
135 assert_eq!(uri, "file://./out/dt=${now.date}/part.jsonl");
136 }
137
138 #[test]
139 fn folds_strftime_tokens_and_ignores_unused_ones() {
140 let raw = json!({"path": "./out/${now.strftime.%Y/%m}/x.jsonl", "other": "${now.unix}"});
141 let uri = canonicalize_uri("file://./out/2026/07/x.jsonl", &raw, clock());
142 assert_eq!(uri, "file://./out/${now.strftime.%Y/%m}/x.jsonl");
143 }
144
145 #[test]
146 fn without_now_tokens_the_uri_is_untouched_except_redaction() {
147 let raw = json!({"connection_url": "postgres://u:pw@h:5432/db"});
148 let uri = canonicalize_uri("postgres://u:pw@h:5432/db/public.users", &raw, clock());
149 assert!(!uri.contains("pw"), "credentials must be redacted: {uri}");
150 assert!(uri.contains("h:5432"));
151 }
152
153 #[test]
154 fn collect_finds_tokens_with_surrounding_context() {
155 let mut out = Vec::new();
156 collect_now_token_contexts(&json!("a-${now.year}-${now.month}-b"), &mut out);
157 let got: Vec<(&str, Option<char>, Option<char>)> = out
158 .iter()
159 .map(|c| (c.token.as_str(), c.left, c.right))
160 .collect();
161 assert_eq!(
162 got,
163 vec![
164 ("${now.year}", Some('-'), Some('-')),
165 ("${now.month}", Some('-'), Some('-')),
166 ]
167 );
168 out.clear();
170 collect_now_token_contexts(&json!("broken ${now.date"), &mut out);
171 assert!(out.is_empty());
172 out.clear();
174 collect_now_token_contexts(&json!({"paths": ["x", "${now.date}"]}), &mut out);
175 assert_eq!(out.len(), 1);
176 assert_eq!(out[0].token, "${now.date}");
177 assert_eq!((out[0].left, out[0].right), (None, None));
178 }
179
180 #[test]
181 fn anchored_fold_does_not_touch_coincidental_substrings() {
182 let raw = json!({"path": "s3://bucket-07/data/${now.month}/part.jsonl"});
185 let uri = canonicalize_uri("s3://bucket-07/data/07/part.jsonl", &raw, clock());
186 assert_eq!(uri, "s3://bucket-07/data/${now.month}/part.jsonl");
187 }
188
189 #[test]
190 fn schema_from_samples_requires_object_records() {
191 assert!(schema_from_samples(&[]).is_none());
192 assert!(schema_from_samples(&[json!("scalar")]).is_none());
193 let schema = schema_from_samples(&[json!({"id": 1, "name": "a"})]).unwrap();
194 assert_eq!(schema["type"], "object");
195 assert!(schema["properties"]["id"].is_object());
196 }
197}