faucet_cli/catalog/
mod.rs1pub mod model;
21pub mod spec;
22
23pub use spec::CatalogSpec;
24
25use crate::error::{CliError, CliResult};
26use crate::serve::config::HistoryBackendSpec;
27use crate::serve::history::{self, RunHistory, catalog::CatalogUpdate};
28use std::sync::Arc;
29use std::time::Duration;
30
31pub const DEFAULT_SAMPLE_RECORDS: usize = 100;
34
35#[derive(Clone)]
37pub struct CatalogHandle {
38 pub store: Arc<dyn RunHistory>,
39 pub run_id: Option<String>,
43 pub sample_records: usize,
45}
46
47impl std::fmt::Debug for CatalogHandle {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct("CatalogHandle")
50 .field("run_id", &self.run_id)
51 .field("sample_records", &self.sample_records)
52 .finish_non_exhaustive()
53 }
54}
55
56pub async fn connect_from_spec(spec: &CatalogSpec) -> CliResult<CatalogHandle> {
61 let backend = parse_url(&spec.url)?;
62 let store = history::connect(
63 &backend,
64 Duration::from_secs(3600),
67 Duration::from_secs(30),
68 &uuid::Uuid::now_v7().to_string(),
69 )
70 .await?;
71 Ok(CatalogHandle {
72 store,
73 run_id: None,
74 sample_records: spec.sample_records,
75 })
76}
77
78fn parse_url(url: &str) -> CliResult<HistoryBackendSpec> {
80 match url {
81 "memory" => Ok(HistoryBackendSpec::Memory),
82 u if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
83 Ok(HistoryBackendSpec::Postgres(u.to_string()))
84 }
85 u if u.starts_with("sqlite:") => Ok(HistoryBackendSpec::Sqlite(u.to_string())),
86 other => Err(CliError::Config(format!(
87 "catalog.url '{other}' is not recognised — expected 'memory', 'sqlite:<path>', \
88 or a 'postgres://…' URL"
89 ))),
90 }
91}
92
93pub async fn record(handle: &CatalogHandle, update: &CatalogUpdate) {
96 if let Err(e) = handle.store.catalog_record(update).await {
97 tracing::warn!(
98 pipeline = %update.pipeline,
99 row = %update.row,
100 error = %e,
101 "catalog write failed — run unaffected"
102 );
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[tokio::test]
111 async fn connect_memory_and_reject_unknown_scheme() {
112 let handle = connect_from_spec(&CatalogSpec {
113 url: "memory".into(),
114 sample_records: 25,
115 })
116 .await
117 .unwrap();
118 assert_eq!(handle.sample_records, 25);
119 assert!(handle.run_id.is_none());
120
121 let err = connect_from_spec(&CatalogSpec {
122 url: "mysql://nope".into(),
123 sample_records: 100,
124 })
125 .await
126 .unwrap_err();
127 assert!(err.to_string().contains("catalog.url"), "{err}");
128 }
129
130 #[test]
131 fn parse_url_recognises_all_three_schemes() {
132 assert!(matches!(
133 parse_url("sqlite:./cat.db"),
134 Ok(HistoryBackendSpec::Sqlite(u)) if u == "sqlite:./cat.db"
135 ));
136 assert!(matches!(
137 parse_url("postgres://h/db"),
138 Ok(HistoryBackendSpec::Postgres(_))
139 ));
140 assert!(matches!(
141 parse_url("postgresql://h/db"),
142 Ok(HistoryBackendSpec::Postgres(_))
143 ));
144 assert!(matches!(
145 parse_url("memory"),
146 Ok(HistoryBackendSpec::Memory)
147 ));
148 assert!(parse_url("bogus").is_err());
149 }
150
151 #[tokio::test]
152 async fn handle_debug_never_prints_the_store() {
153 let handle = connect_from_spec(&CatalogSpec {
154 url: "memory".into(),
155 sample_records: 7,
156 })
157 .await
158 .unwrap();
159 let dbg = format!("{handle:?}");
160 assert!(dbg.contains("sample_records: 7"), "{dbg}");
161 assert!(dbg.contains(".."), "non-exhaustive marker expected: {dbg}");
162 }
163}