1use std::path::{Path, PathBuf};
20
21use crate::EnvVars;
22use cratefield_core::Config;
23
24pub const DB_URL_VAR: &str = "SUPABASE_DB_URL";
26pub const ACCESS_TOKEN_VAR: &str = "SUPABASE_ACCESS_TOKEN";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Format {
32 Markdown,
34 Json,
36}
37
38#[derive(Debug, Clone)]
40pub struct InspectArgs {
41 pub project: String,
43 pub db_url: Option<String>,
45 pub management_token: Option<String>,
47 pub format: Format,
49 pub out: Option<PathBuf>,
51 pub transfer_mbps: u32,
53 pub classify: bool,
55 pub classify_threshold: f32,
57}
58
59#[derive(Debug)]
62pub struct Credentials {
63 pub db_url: Secret,
65 pub management_token: Option<Secret>,
67 pub notes: Vec<String>,
70}
71
72#[derive(Clone)]
75pub struct Secret(zeroize::Zeroizing<String>);
76
77impl Secret {
78 #[must_use]
80 pub fn expose(&self) -> &str {
81 &self.0
82 }
83}
84
85impl std::fmt::Debug for Secret {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.write_str("[redacted]")
88 }
89}
90
91pub fn credentials(args: &InspectArgs, config: &dyn Config) -> Result<Credentials, String> {
98 let mut notes = Vec::new();
99 let db_url = match &args.db_url {
100 Some(url) => {
101 notes.push(format!(
102 "note: --db-url puts the password in your shell history and the process list; \
103 prefer {DB_URL_VAR}"
104 ));
105 url.clone()
106 }
107 None => config.get(DB_URL_VAR).ok_or_else(|| {
108 format!(
109 "no database URL: set {DB_URL_VAR} to the read-only role's connection string \
110 (docs/import/supabase.md), or pass --db-url. fz never prompts"
111 )
112 })?,
113 };
114 let management_token = match &args.management_token {
115 Some(token) => {
116 notes.push(format!(
117 "note: --management-token puts the token in your shell history and the process \
118 list; prefer {ACCESS_TOKEN_VAR}"
119 ));
120 Some(token.clone())
121 }
122 None => config.get(ACCESS_TOKEN_VAR),
123 };
124 Ok(Credentials {
125 db_url: Secret(zeroize::Zeroizing::new(db_url)),
126 management_token: management_token.map(|token| Secret(zeroize::Zeroizing::new(token))),
127 notes,
128 })
129}
130
131pub fn inspect(args: &InspectArgs) -> Result<(), String> {
140 let credentials = credentials(args, &EnvVars)?;
141 for note in &credentials.notes {
142 eprintln!("{note}");
143 }
144 let rendered = run(args, &credentials)?;
145 if let Some(path) = &args.out {
146 write_report(path, &rendered.text)?;
147 eprintln!("wrote {}: {}", path.display(), rendered.summary);
148 } else {
149 print!("{}", rendered.text);
150 }
151 Ok(())
152}
153
154fn write_report(path: &Path, text: &str) -> Result<(), String> {
155 std::fs::write(path, text).map_err(|err| format!("cannot write {}: {err}", path.display()))
156}
157
158pub struct Rendered {
160 pub text: String,
162 pub summary: String,
164}
165
166#[cfg(feature = "import-supabase")]
167fn run(args: &InspectArgs, credentials: &Credentials) -> Result<Rendered, String> {
168 use cratefield_import_supabase as engine;
169 use std::sync::Arc;
170
171 let clock: Arc<dyn cratefield_core::Clock> = Arc::new(cratefield_runtime_native::TokioClock);
172 let http: Arc<dyn cratefield_core::HttpClient> =
173 Arc::new(cratefield_core::BoundedHttpClient::new(
174 Arc::new(cratefield_runtime_native::ReqwestClient::new()),
175 Arc::clone(&clock),
176 ));
177 let mut options = engine::InspectOptions::new(
178 args.project.clone(),
179 engine::Secret::new(credentials.db_url.expose()),
180 );
181 options.transfer_mbps = args.transfer_mbps;
182 options.classify_threshold = args.classify_threshold;
183 options.management = credentials.management_token.as_ref().map(|token| {
184 engine::ManagementApi::new(
185 Arc::clone(&http),
186 engine::DEFAULT_API_BASE,
187 engine::Secret::new(token.expose()),
188 )
189 });
190 if args.classify {
191 let classifier = cratefield_adapter_typesafe::TypeSafe::from_env(
194 Arc::clone(&http),
195 Arc::clone(&clock),
196 )
197 .ok_or_else(|| {
198 "--classify needs TYPESAFE_API_KEY (the TypeSafe adapter reads it); without it, \
199 drop --classify and unplaced policies stay needs_review"
200 .to_owned()
201 })?;
202 options.classifier = Some(Arc::new(classifier));
203 }
204 let runtime = tokio::runtime::Builder::new_current_thread()
205 .enable_all()
206 .build()
207 .map_err(|err| format!("cannot start the async runtime inspect needs: {err}"))?;
208 let report = runtime
209 .block_on(engine::inspect(&options))
210 .map_err(|error| error.to_string())?;
211 let s = &report.summary;
212 let summary = format!(
213 "{} automatic, {} needs work, {} blocker(s){}",
214 s.automatic,
215 s.needs_work,
216 s.blockers,
217 if s.ready { "" } else { " — not ready" }
218 );
219 Ok(Rendered {
220 text: match args.format {
221 Format::Json => report.to_json(),
222 Format::Markdown => report.to_markdown(),
223 },
224 summary,
225 })
226}
227
228#[cfg(not(feature = "import-supabase"))]
229fn run(_args: &InspectArgs, _credentials: &Credentials) -> Result<Rendered, String> {
230 Err(
231 "this `fz` was built without the `import-supabase` feature, so it cannot reach Postgres. \
234 Install one that has it — `cargo install cratefield-cli --features import-supabase` — \
235 and run that binary (it needs no compiled-in harness)."
236 .to_owned(),
237 )
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use std::collections::BTreeMap;
244
245 struct Vars(BTreeMap<&'static str, &'static str>);
246
247 impl Config for Vars {
248 fn get(&self, key: &str) -> Option<String> {
249 self.0.get(key).map(|value| (*value).to_owned())
250 }
251 }
252
253 fn args() -> InspectArgs {
254 InspectArgs {
255 project: "abcdefghijklmnopqrst".to_owned(),
256 db_url: None,
257 management_token: None,
258 format: Format::Markdown,
259 out: None,
260 transfer_mbps: 100,
261 classify: false,
262 classify_threshold: 0.8,
263 }
264 }
265
266 #[test]
267 fn a_missing_url_is_a_refusal_not_a_prompt() {
268 let error = credentials(&args(), &Vars(BTreeMap::new())).unwrap_err();
269 assert!(error.contains(DB_URL_VAR), "{error}");
270 assert!(error.contains("never prompts"), "{error}");
271 }
272
273 #[test]
274 fn the_environment_is_read_and_never_printed() {
275 let vars = Vars(BTreeMap::from([
276 (DB_URL_VAR, "postgres://ro:hunter2@db.example.test/postgres"),
277 (ACCESS_TOKEN_VAR, "sbp_secret_token"),
278 ]));
279 let credentials = credentials(&args(), &vars).expect("resolved");
280 assert!(credentials.notes.is_empty());
281 assert!(credentials.management_token.is_some());
282 let debug = format!("{credentials:?}");
283 assert!(
284 !debug.contains("hunter2") && !debug.contains("sbp_"),
285 "{debug}"
286 );
287 }
288
289 #[test]
290 fn a_flag_wins_and_earns_a_note() {
291 let mut given = args();
292 given.db_url = Some("postgres://ro:hunter2@db.example.test/postgres".to_owned());
293 let vars = Vars(BTreeMap::from([(DB_URL_VAR, "postgres://other")]));
294 let credentials = credentials(&given, &vars).expect("resolved");
295 assert_eq!(
296 credentials.db_url.expose(),
297 "postgres://ro:hunter2@db.example.test/postgres"
298 );
299 assert_eq!(credentials.notes.len(), 1);
300 assert!(!credentials.notes[0].contains("hunter2"));
301 }
302
303 #[test]
304 #[cfg(not(feature = "import-supabase"))]
305 fn without_the_feature_the_refusal_names_the_install() {
306 let mut given = args();
307 given.db_url = Some("postgres://ro:hunter2@db.example.test/postgres".to_owned());
308 let error = inspect(&given).unwrap_err();
309 assert!(
310 error.contains("cargo install cratefield-cli --features import-supabase"),
311 "{error}"
312 );
313 assert!(!error.contains("hunter2"));
314 }
315
316 #[test]
317 #[cfg(feature = "import-supabase")]
318 fn an_unreachable_database_is_an_error_that_quotes_no_secret() {
319 let mut given = args();
320 given.db_url = Some("postgres://ro:hunter2@127.0.0.1:1/postgres".to_owned());
321 let error = inspect(&given).unwrap_err();
322 assert!(error.contains("could not connect"), "{error}");
323 assert!(!error.contains("hunter2"), "{error}");
324 }
325}