Skip to main content

faucet_cli/catalog/
model.rs

1//! Pure helpers for building catalog observations (#279): dataset-URI
2//! canonicalization (fold `${now.*}`-derived segments back to their tokens,
3//! redact credentials) and schema inference over the run's record samples.
4
5use chrono::{DateTime, FixedOffset};
6use serde_json::Value;
7
8/// Canonicalize a concrete dataset URI for use as the catalog identity key.
9///
10/// 1. **`${now.*}` folding** — a dated path like `./out/dt=2026-07-06/x.jsonl`
11///    produced by a `${now.date}` template would otherwise mint a new catalog
12///    dataset every day (the cardinality trap called out in #279). For every
13///    `${now.*}` token that appears in the connector's *raw* (pre-resolution)
14///    config, the token's rendered value is replaced back with the token text
15///    in the URI, so all runs of the template converge on one dataset.
16/// 2. **Credential redaction** — via `faucet_core::redact_uri_credentials`,
17///    so a `postgres://user:pass@host/db` URI never lands in the store.
18pub 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    // Each token is folded back **anchored to the literal characters that
23    // surround it in the config template** — not by a blind global replace of
24    // the rendered value (audit #321 L8). A short rendering like `${now.month}`
25    // → "07" would otherwise fold a coincidental `bucket-07` on days whose
26    // digits collide, splitting one physical sink into several catalog datasets
27    // with unstable identity. Anchoring `/07/` (the token's real `.../${now.month}/…`
28    // context) leaves `bucket-07` untouched.
29    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    // Dedup identical (search, replace) folds, then apply longest-search first
52    // so `${now.datetime}` wins over the `${now.year}` embedded within it.
53    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
64/// A `${now.*}` token together with the literal characters that immediately
65/// precede and follow it in its config string (used as fold anchors). `None`
66/// when the token sits at the very start / end of the string.
67struct TokenCtx {
68    token: String,
69    left: Option<char>,
70    right: Option<char>,
71}
72
73/// Collect every `${now.…}` token in the string values of `v`, each with its
74/// surrounding-character context. The context anchors keep a short rendered
75/// value from folding coincidental look-alike substrings elsewhere in the URI.
76fn 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; // index of '}'
86                        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
106/// Infer an `infer_schema`-shaped record schema from a run's sample, `None`
107/// when nothing was sampled (empty run, or non-object records only).
108pub 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    // A sample of non-object records infers no properties — not a schema
114    // worth a timeline entry.
115    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        // Unterminated token is ignored, not a panic.
169        out.clear();
170        collect_now_token_contexts(&json!("broken ${now.date"), &mut out);
171        assert!(out.is_empty());
172        // Tokens inside arrays (e.g. a list-valued config field) are found too.
173        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        // #321 L8: a short `${now.month}` → "07" must fold only the real
183        // `.../07/...` path segment, not a coincidental `bucket-07`.
184        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}