1use 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
18fn 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
24pub 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
33async 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
97async 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 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
278fn 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}