Skip to main content

faucet_cli/commands/
catalog.rs

1//! `faucet catalog` — browse the Data Movement Catalog (#279) accumulated by
2//! a config's `catalog:` store: datasets, schema timelines, volume/freshness,
3//! and the lineage graph. Read-only; the same store `faucet run` / `schedule`
4//! / `replicate` write into (point `faucet serve --history` at the same URL to
5//! browse it in the control plane / web console instead).
6
7use crate::catalog::CatalogHandle;
8use crate::cli::{
9    CatalogArgs, CatalogCommand, CatalogConfigArgs, CatalogDatasetsArgs, CatalogLineageArgs,
10    CatalogShowArgs,
11};
12use crate::config::PipelineConfig;
13use crate::error::{CliError, CliResult};
14use crate::serve::history::catalog::{
15    CatalogDataset, CatalogDatasetDetail, CatalogLineageEdge, CatalogListFilter,
16};
17
18/// Pretty-print any serializable value (JSON output mode).
19fn to_pretty<T: serde::Serialize>(value: &T) -> CliResult<String> {
20    serde_json::to_string_pretty(value)
21        .map_err(|e| CliError::Internal(format!("rendering catalog JSON: {e}")))
22}
23
24/// Execute the `catalog` subcommand.
25pub async fn run(args: CatalogArgs) -> CliResult<()> {
26    match args.command {
27        CatalogCommand::Datasets(a) => datasets(a).await,
28        CatalogCommand::Show(a) => show(a).await,
29        CatalogCommand::Lineage(a) => lineage(a).await,
30    }
31}
32
33/// Load the config named by the shared flags and connect its `catalog:` store.
34async fn connect(common: &CatalogConfigArgs) -> CliResult<CatalogHandle> {
35    let cwd = std::env::current_dir()?;
36    let env_path =
37        crate::env_loader::resolve_env_file(common.env_file.as_deref(), common.no_env_file, &cwd)?;
38    crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
39    let path = match &common.config {
40        Some(p) => p.clone(),
41        None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
42    };
43    let cfg = PipelineConfig::from_path_async(&path, common.profile.as_deref()).await?;
44    let spec = cfg.catalog.as_ref().ok_or_else(|| {
45        CliError::Config(
46            "no `catalog:` block in this config — add one naming the store (e.g. \
47             `catalog: { url: sqlite:./faucet-catalog.db }`), or run \
48             `faucet schema catalog` for the block's JSON Schema"
49                .to_string(),
50        )
51    })?;
52    crate::catalog::connect_from_spec(spec).await
53}
54
55async fn datasets(args: CatalogDatasetsArgs) -> CliResult<()> {
56    let handle = connect(&args.common).await?;
57    let page = handle
58        .store
59        .catalog_list_datasets(&CatalogListFilter {
60            kind: args.kind,
61            q: args.q,
62            limit: args.limit.max(1),
63            cursor: None,
64        })
65        .await
66        .map_err(|e| CliError::Internal(format!("catalog read: {e}")))?;
67    if args.common.json {
68        println!("{}", to_pretty(&page)?);
69        return Ok(());
70    }
71    if page.datasets.is_empty() {
72        println!("catalog is empty — run a pipeline with this `catalog:` store first");
73        return Ok(());
74    }
75    println!(
76        "{:<16}  {:<12}  {:<12}  {:>5}  {:>12}  {:<20}  URI",
77        "ID", "KIND", "ROLES", "RUNS", "ROWS (LAST)", "LAST SUCCESS"
78    );
79    for d in &page.datasets {
80        println!(
81            "{:<16}  {:<12}  {:<12}  {:>5}  {:>12}  {:<20}  {}",
82            d.id,
83            d.kind,
84            d.roles.join(","),
85            d.runs,
86            d.last_records,
87            d.last_success.format("%Y-%m-%dT%H:%M:%SZ"),
88            d.uri
89        );
90    }
91    if page.next_cursor.is_some() {
92        println!("… more — raise --limit to see the rest");
93    }
94    Ok(())
95}
96
97/// Resolve `id` against the store, accepting a unique prefix of a dataset id.
98async fn resolve_dataset(
99    handle: &CatalogHandle,
100    id: &str,
101) -> CliResult<Option<CatalogDatasetDetail>> {
102    if let Some(detail) = handle
103        .store
104        .catalog_get_dataset(id)
105        .await
106        .map_err(|e| CliError::Internal(format!("catalog read: {e}")))?
107    {
108        return Ok(Some(detail));
109    }
110    // Prefix match over the (bounded) dataset list.
111    let page = handle
112        .store
113        .catalog_list_datasets(&CatalogListFilter {
114            limit: 1000,
115            ..Default::default()
116        })
117        .await
118        .map_err(|e| CliError::Internal(format!("catalog read: {e}")))?;
119    let matches: Vec<&CatalogDataset> = page
120        .datasets
121        .iter()
122        .filter(|d| d.id.starts_with(id))
123        .collect();
124    match matches.as_slice() {
125        [one] => {
126            let full = one.id.clone();
127            handle
128                .store
129                .catalog_get_dataset(&full)
130                .await
131                .map_err(|e| CliError::Internal(format!("catalog read: {e}")))
132        }
133        [] => Ok(None),
134        many => Err(CliError::Config(format!(
135            "dataset id prefix '{id}' is ambiguous ({} matches) — use the full id",
136            many.len()
137        ))),
138    }
139}
140
141async fn show(args: CatalogShowArgs) -> CliResult<()> {
142    let handle = connect(&args.common).await?;
143    let detail = resolve_dataset(&handle, &args.id).await?.ok_or_else(|| {
144        CliError::Config(format!(
145            "no catalogued dataset with id '{}' — list ids with `faucet catalog datasets`",
146            args.id
147        ))
148    })?;
149    if args.common.json {
150        println!("{}", to_pretty(&detail)?);
151        return Ok(());
152    }
153    let d = &detail.dataset;
154    println!("dataset  {}", d.uri);
155    println!("id       {}", d.id);
156    println!("kind     {}   roles {}", d.kind, d.roles.join(","));
157    println!("pipeline {}   last run {}", d.pipeline, d.last_run_id);
158    println!(
159        "runs     {}   rows {} (last) / {} (total)",
160        d.runs, d.last_records, d.total_records
161    );
162    println!(
163        "seen     {} → {}   last success {}",
164        d.first_seen.format("%Y-%m-%dT%H:%M:%SZ"),
165        d.last_seen.format("%Y-%m-%dT%H:%M:%SZ"),
166        d.last_success.format("%Y-%m-%dT%H:%M:%SZ"),
167    );
168
169    println!(
170        "\nschema timeline ({} versions):",
171        detail.schema_timeline.len()
172    );
173    for v in &detail.schema_timeline {
174        let cols = v.schema["properties"]
175            .as_object()
176            .map(|p| p.len())
177            .unwrap_or(0);
178        print!(
179            "  v{}  {}  {} column(s)  run {}",
180            v.version,
181            v.recorded_at.format("%Y-%m-%dT%H:%M:%SZ"),
182            cols,
183            v.run_id
184        );
185        if let Some(diff) = &v.diff {
186            let names = |key: &str| -> Vec<String> {
187                diff[key]
188                    .as_array()
189                    .map(|a| {
190                        a.iter()
191                            .filter_map(|c| {
192                                c.get("column")
193                                    .or(Some(c))
194                                    .and_then(|v| v.as_str())
195                                    .map(String::from)
196                            })
197                            .collect()
198                    })
199                    .unwrap_or_default()
200            };
201            let mut parts = Vec::new();
202            for (label, key) in [
203                ("+", "added"),
204                ("~", "widened"),
205                ("!", "changed"),
206                ("-", "removed"),
207            ] {
208                let n = names(key);
209                if !n.is_empty() {
210                    parts.push(format!("{label}{}", n.join(&format!(" {label}"))));
211                }
212            }
213            if !parts.is_empty() {
214                print!("  [{}]", parts.join("  "));
215            }
216        }
217        println!();
218    }
219
220    println!("\nrecent volume (newest first):");
221    for p in detail.stats.iter().take(10) {
222        println!(
223            "  {}  {:>10} row(s)  run {}",
224            p.recorded_at.format("%Y-%m-%dT%H:%M:%SZ"),
225            p.records,
226            p.run_id
227        );
228    }
229
230    println!("\nupstream:");
231    if detail.upstream.is_empty() {
232        println!("  (none)");
233    }
234    for e in &detail.upstream {
235        println!(
236            "  {}  ({} run(s), pipeline {})",
237            e.src_uri, e.runs, e.pipeline
238        );
239    }
240    println!("downstream:");
241    if detail.downstream.is_empty() {
242        println!("  (none)");
243    }
244    for e in &detail.downstream {
245        println!(
246            "  {}  ({} run(s), pipeline {})",
247            e.dst_uri, e.runs, e.pipeline
248        );
249    }
250    Ok(())
251}
252
253async fn lineage(args: CatalogLineageArgs) -> CliResult<()> {
254    let handle = connect(&args.common).await?;
255    let edges = handle
256        .store
257        .catalog_lineage(args.root.as_deref(), args.depth.max(1))
258        .await
259        .map_err(|e| CliError::Internal(format!("catalog read: {e}")))?;
260    if args.common.json {
261        println!("{}", to_pretty(&serde_json::json!({ "edges": edges }))?);
262        return Ok(());
263    }
264    if edges.is_empty() {
265        println!(
266            "no lineage edges recorded{}",
267            match &args.root {
268                Some(r) => format!(" around '{r}'"),
269                None => String::new(),
270            }
271        );
272        return Ok(());
273    }
274    print!("{}", render_edges(&edges));
275    Ok(())
276}
277
278/// Human rendering of the edge list: `src → dst` grouped lines.
279fn render_edges(edges: &[CatalogLineageEdge]) -> String {
280    let mut out = String::new();
281    for e in edges {
282        out.push_str(&format!(
283            "{}  →  {}\n    pipeline {} (row {}), {} run(s), {} row(s) last, last seen {}{}\n",
284            e.src_uri,
285            e.dst_uri,
286            e.pipeline,
287            e.row,
288            e.runs,
289            e.last_records,
290            e.last_seen.format("%Y-%m-%dT%H:%M:%SZ"),
291            if e.column_lineage.is_some() {
292                ", column lineage recorded"
293            } else {
294                ""
295            }
296        ));
297    }
298    out
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::serve::history::catalog::{
305        CatalogUpdate, DatasetObservation, DatasetRole, apply_edge,
306    };
307
308    #[test]
309    fn render_edges_lists_each_edge_with_context() {
310        let update = CatalogUpdate {
311            run_id: "r9".into(),
312            pipeline: "p".into(),
313            row: "default".into(),
314            recorded_at: chrono::Utc::now(),
315            sources: vec![DatasetObservation {
316                uri: "csv://./in.csv".into(),
317                kind: "csv".into(),
318                role: DatasetRole::Source,
319                schema: None,
320                records: 4,
321            }],
322            sink: DatasetObservation {
323                uri: "jsonl://./out.jsonl".into(),
324                kind: "jsonl".into(),
325                role: DatasetRole::Sink,
326                schema: None,
327                records: 4,
328            },
329            column_lineage: Some(serde_json::json!({"fields": {}})),
330        };
331        let edge = apply_edge(None, &update, &update.sources[0]);
332        let text = render_edges(&[edge]);
333        assert!(
334            text.contains("csv://./in.csv  →  jsonl://./out.jsonl"),
335            "{text}"
336        );
337        assert!(
338            text.contains("pipeline p (row default), 1 run(s)"),
339            "{text}"
340        );
341        assert!(text.contains("column lineage recorded"), "{text}");
342    }
343}