Skip to main content

cratefield_cli/
import.rs

1//! `fz import supabase inspect` (issue #658, ADR 0026): connects read-only
2//! to a Supabase project and writes the migration report.
3//!
4//! `--dispositions <FILE>` decides the report's needs-work and blocker items
5//! from a TOML file (ADR 0026, Decision 5);
6//! `fz import supabase dispositions init` writes a skeleton of that file.
7//!
8//! The engine is `cratefield-import-supabase`, which reads Postgres through
9//! sqlx and so is native-only. It is behind the `import-supabase` feature
10//! for the reason `postgres` and `push-send` are: a venture links this
11//! crate beside a wasm build, and that graph must never see sqlx or tokio.
12//! Without the feature the command exists and says what to install — loud
13//! absence, not a silently smaller `fz`.
14//!
15//! **Credentials.** The database URL comes from `--db-url` or
16//! `SUPABASE_DB_URL`, the Management API token from `--management-token`
17//! or `SUPABASE_ACCESS_TOKEN`, and `--classify` reads `TYPESAFE_API_KEY`
18//! through the `TypeSafe` adapter's own `from_env`. `fz` never prompts (ADR
19//! 0026, Decision 6), so a missing URL is a refusal naming the variable.
20//! The values are held cleared-on-drop, never printed, and the report names
21//! the database by host and database alone.
22
23use std::path::{Path, PathBuf};
24
25use crate::EnvVars;
26use cratefield_core::Config;
27
28/// The environment variable the database URL is read from.
29pub const DB_URL_VAR: &str = "SUPABASE_DB_URL";
30/// The environment variable the Management API token is read from.
31pub const ACCESS_TOKEN_VAR: &str = "SUPABASE_ACCESS_TOKEN";
32/// The environment variable the target Postgres URL is read from by
33/// default: the harness's own app-database variable, the one a venture's
34/// native runtime reads (`crates/runtime-native/README.md`).
35pub const DEFAULT_TARGET_ENV: &str = "DATABASE_URL";
36
37/// The report format.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Format {
40    /// Markdown, for a person.
41    Markdown,
42    /// JSON, for the later steps and the dashboard.
43    Json,
44}
45
46/// Everything `fz import supabase inspect` was given.
47#[derive(Debug, Clone)]
48pub struct InspectArgs {
49    /// The project ref.
50    pub project: String,
51    /// `--db-url`, if given.
52    pub db_url: Option<String>,
53    /// `--management-token`, if given.
54    pub management_token: Option<String>,
55    /// The format.
56    pub format: Format,
57    /// `--out`, if given.
58    pub out: Option<PathBuf>,
59    /// `--transfer-mbps`.
60    pub transfer_mbps: u32,
61    /// `--classify`.
62    pub classify: bool,
63    /// `--classify-threshold`.
64    pub classify_threshold: f32,
65    /// `--dispositions`, if given: the file applied to the report.
66    pub dispositions: Option<PathBuf>,
67}
68
69/// Everything `fz import supabase dispositions init` was given.
70#[derive(Debug, Clone)]
71pub struct DispositionsInitArgs {
72    /// The project ref.
73    pub project: String,
74    /// `--db-url`, if given.
75    pub db_url: Option<String>,
76    /// `--management-token`, if given.
77    pub management_token: Option<String>,
78    /// `--out`: where the skeleton goes.
79    pub out: PathBuf,
80    /// `--force`: overwrite an existing file.
81    pub force: bool,
82    /// `--dispositions`: skip items this file already decides.
83    pub dispositions: Option<PathBuf>,
84}
85
86impl DispositionsInitArgs {
87    /// The inspect args that reach the same project, for the shared
88    /// credential and report code. Only the engine build inspects.
89    #[cfg(feature = "import-supabase")]
90    fn as_inspect(&self) -> InspectArgs {
91        InspectArgs {
92            project: self.project.clone(),
93            db_url: self.db_url.clone(),
94            management_token: self.management_token.clone(),
95            format: Format::Markdown,
96            out: None,
97            transfer_mbps: 100,
98            classify: false,
99            classify_threshold: 0.8,
100            dispositions: None,
101        }
102    }
103}
104
105/// Where each credential came from — the flag or the environment — with
106/// the value itself never leaving this struct.
107#[derive(Debug)]
108pub struct Credentials {
109    /// The database URL.
110    pub db_url: Secret,
111    /// The Management API token, if any.
112    pub management_token: Option<Secret>,
113    /// Notes for stderr: a credential given as a flag is in the shell
114    /// history and the process list.
115    pub notes: Vec<String>,
116}
117
118/// A string that prints as `[redacted]`. The engine has its own; this one
119/// exists so the argument handling compiles without the feature.
120#[derive(Clone)]
121pub struct Secret(zeroize::Zeroizing<String>);
122
123impl Secret {
124    /// The value. Named `expose` so a reader has to notice.
125    #[must_use]
126    pub fn expose(&self) -> &str {
127        &self.0
128    }
129}
130
131impl std::fmt::Debug for Secret {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str("[redacted]")
134    }
135}
136
137/// Resolves the credentials from the flags, then `config` (the
138/// environment). Never prompts.
139///
140/// # Errors
141///
142/// A refusal naming `SUPABASE_DB_URL` when no URL was given either way.
143pub fn credentials(args: &InspectArgs, config: &dyn Config) -> Result<Credentials, String> {
144    resolve(
145        args.db_url.as_deref(),
146        args.management_token.as_deref(),
147        config,
148    )
149}
150
151/// [`credentials`] without an [`InspectArgs`], so the plan run reuses it.
152fn resolve(
153    db_url: Option<&str>,
154    management_token: Option<&str>,
155    config: &dyn Config,
156) -> Result<Credentials, String> {
157    let (db_url, note) = source_db_url(db_url, config)?;
158    let mut notes = note.into_iter().collect::<Vec<_>>();
159    let management_token = match management_token {
160        Some(token) => {
161            notes.push(format!(
162                "note: --management-token puts the token in your shell history and the process \
163                 list; prefer {ACCESS_TOKEN_VAR}"
164            ));
165            Some(token.to_owned())
166        }
167        None => config.get(ACCESS_TOKEN_VAR),
168    };
169    Ok(Credentials {
170        db_url,
171        management_token: management_token.map(|token| Secret(zeroize::Zeroizing::new(token))),
172        notes,
173    })
174}
175
176/// Resolves the source database URL from `--db-url`, then [`DB_URL_VAR`] in
177/// `config`. Never prompts. Shared by `inspect` and `fz import supabase
178/// users` (#659), which read the same source through the same variable.
179///
180/// Returns the note a flag earns, if any: a flag is in the shell history
181/// and the process list.
182///
183/// # Errors
184///
185/// A refusal naming [`DB_URL_VAR`] when no URL was given either way.
186pub fn source_db_url(
187    db_url: Option<&str>,
188    config: &dyn Config,
189) -> Result<(Secret, Option<String>), String> {
190    match db_url {
191        Some(url) => Ok((
192            Secret(zeroize::Zeroizing::new(url.to_owned())),
193            Some(format!(
194                "note: --db-url puts the password in your shell history and the process list; \
195                 prefer {DB_URL_VAR}"
196            )),
197        )),
198        None => config.get(DB_URL_VAR).map_or_else(
199            || {
200                Err(format!(
201                    "no database URL: set {DB_URL_VAR} to the read-only role's connection string \
202                     (docs/import/supabase.md), or pass --db-url. fz never prompts"
203                ))
204            },
205            |url| Ok((Secret(zeroize::Zeroizing::new(url)), None)),
206        ),
207    }
208}
209
210/// Runs the command: inspects, then writes the report to `--out` or
211/// stdout.
212///
213/// # Errors
214///
215/// A refusal or an access error (connection, permission, a refused token):
216/// the exit code is non-zero for those alone. Blockers are in the report
217/// and the exit code is zero.
218pub fn inspect(args: &InspectArgs) -> Result<(), String> {
219    let credentials = credentials(args, &EnvVars)?;
220    for note in &credentials.notes {
221        eprintln!("{note}");
222    }
223    let rendered = run(args, &credentials)?;
224    if let Some(path) = &args.out {
225        write_report(path, &rendered.text)?;
226        eprintln!("wrote {}: {}", path.display(), rendered.summary);
227    } else {
228        print!("{}", rendered.text);
229    }
230    Ok(())
231}
232
233fn write_report(path: &Path, text: &str) -> Result<(), String> {
234    std::fs::write(path, text).map_err(|err| format!("cannot write {}: {err}", path.display()))
235}
236
237/// The rendered report and its one-line summary.
238pub struct Rendered {
239    /// The report in the requested format.
240    pub text: String,
241    /// `N automatic, N needs work, N blockers, N undecided`.
242    pub summary: String,
243}
244
245/// The refusal every command prints when `fz` was built without the engine.
246#[cfg(not(feature = "import-supabase"))]
247const WITHOUT_FEATURE: &str = "this `fz` was built without the `import-supabase` feature, so it \
248     cannot reach Postgres. Install one that has it — `cargo install cratefield-cli --features \
249     import-supabase` — and run that binary (it needs no compiled-in harness).";
250
251/// Runs `dispositions init`: inspects, then writes the skeleton file.
252///
253/// # Errors
254///
255/// A refusal — an existing file without `--force`, a bad dispositions file, a
256/// connection or permission error.
257pub fn dispositions_init(args: &DispositionsInitArgs) -> Result<(), String> {
258    // Without the engine there is nothing to inspect, so refuse before the
259    // overwrite and credential checks (which would otherwise be reached).
260    #[cfg(not(feature = "import-supabase"))]
261    {
262        let _ = args;
263        Err(WITHOUT_FEATURE.to_owned())
264    }
265
266    #[cfg(feature = "import-supabase")]
267    {
268        if args.out.exists() && !args.force {
269            return Err(format!(
270                "{} exists; pass --force to overwrite it",
271                args.out.display()
272            ));
273        }
274        let inspect_args = args.as_inspect();
275        let credentials = credentials(&inspect_args, &EnvVars)?;
276        for note in &credentials.notes {
277            eprintln!("{note}");
278        }
279        write_skeleton(&inspect_args, &credentials, args)
280    }
281}
282
283#[cfg(feature = "import-supabase")]
284fn write_skeleton(
285    inspect_args: &InspectArgs,
286    credentials: &Credentials,
287    args: &DispositionsInitArgs,
288) -> Result<(), String> {
289    use cratefield_import_supabase as engine;
290
291    let mut report = inspect_report(inspect_args, credentials)?;
292    if let Some(path) = &args.dispositions {
293        let file = engine::DispositionsFile::load(path).map_err(|error| error.to_string())?;
294        engine::apply_dispositions(&mut report, &file);
295    }
296    let text = engine::skeleton(&report);
297    if let Some(parent) = args
298        .out
299        .parent()
300        .filter(|parent| !parent.as_os_str().is_empty())
301    {
302        std::fs::create_dir_all(parent)
303            .map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
304    }
305    std::fs::write(&args.out, &text)
306        .map_err(|error| format!("cannot write {}: {error}", args.out.display()))?;
307    println!(
308        "{} undecided item(s) written to {}",
309        report.summary.undecided,
310        args.out.display()
311    );
312    Ok(())
313}
314
315#[cfg(feature = "import-supabase")]
316fn engine_options(
317    project: &str,
318    credentials: &Credentials,
319    transfer_mbps: u32,
320    classify: bool,
321    classify_threshold: f32,
322) -> Result<cratefield_import_supabase::InspectOptions, String> {
323    use cratefield_import_supabase as engine;
324    use std::sync::Arc;
325
326    let clock: Arc<dyn cratefield_core::Clock> = Arc::new(cratefield_runtime_native::TokioClock);
327    let http: Arc<dyn cratefield_core::HttpClient> =
328        Arc::new(cratefield_core::BoundedHttpClient::new(
329            Arc::new(cratefield_runtime_native::ReqwestClient::new()),
330            Arc::clone(&clock),
331        ));
332    let mut options = engine::InspectOptions::new(
333        project.to_owned(),
334        engine::Secret::new(credentials.db_url.expose()),
335    );
336    options.transfer_mbps = transfer_mbps;
337    options.classify_threshold = classify_threshold;
338    options.management = credentials.management_token.as_ref().map(|token| {
339        engine::ManagementApi::new(
340            Arc::clone(&http),
341            engine::DEFAULT_API_BASE,
342            engine::Secret::new(token.expose()),
343        )
344    });
345    if classify {
346        // TypeSafe (its judge, Jev) through the adapter's own reader, so
347        // the key's name is the adapter's, not ours.
348        let classifier =
349            cratefield_adapter_typesafe::TypeSafe::from_env(Arc::clone(&http), Arc::clone(&clock))
350                .ok_or_else(|| {
351                    "--classify needs TYPESAFE_API_KEY (the TypeSafe adapter reads it); without \
352                     it, drop --classify and unplaced policies stay needs_review"
353                        .to_owned()
354                })?;
355        options.classifier = Some(Arc::new(classifier));
356    }
357    Ok(options)
358}
359
360#[cfg(feature = "import-supabase")]
361fn inspect_report(
362    args: &InspectArgs,
363    credentials: &Credentials,
364) -> Result<cratefield_import_supabase::Report, String> {
365    use cratefield_import_supabase as engine;
366
367    let options = engine_options(
368        &args.project,
369        credentials,
370        args.transfer_mbps,
371        args.classify,
372        args.classify_threshold,
373    )?;
374    let runtime = tokio::runtime::Builder::new_current_thread()
375        .enable_all()
376        .build()
377        .map_err(|err| format!("cannot start the async runtime inspect needs: {err}"))?;
378    let mut report = runtime
379        .block_on(engine::inspect(&options))
380        .map_err(|error| error.to_string())?;
381    if let Some(path) = &args.dispositions {
382        let file = engine::DispositionsFile::load(path).map_err(|error| error.to_string())?;
383        engine::apply_dispositions(&mut report, &file);
384    }
385    Ok(report)
386}
387
388#[cfg(feature = "import-supabase")]
389fn run(args: &InspectArgs, credentials: &Credentials) -> Result<Rendered, String> {
390    let report = inspect_report(args, credentials)?;
391    let s = &report.summary;
392    let summary = format!(
393        "{} automatic, {} needs work, {} blocker(s), {} undecided{}",
394        s.automatic,
395        s.needs_work,
396        s.blockers,
397        s.undecided,
398        if s.ready { "" } else { " — not ready" }
399    );
400    Ok(Rendered {
401        text: match args.format {
402            Format::Json => report.to_json(),
403            Format::Markdown => report.to_markdown(),
404        },
405        summary,
406    })
407}
408
409#[cfg(not(feature = "import-supabase"))]
410fn run(_args: &InspectArgs, _credentials: &Credentials) -> Result<Rendered, String> {
411    // As with `push-send`: an installed binary, never a feature on the
412    // venture's dependency, whose wasm build must not see sqlx.
413    Err(WITHOUT_FEATURE.to_owned())
414}
415
416/// Everything the bare `fz import supabase` and `fz import supabase plan`
417/// were given (issue #728).
418#[derive(Debug, Clone)]
419pub struct PlanArgs {
420    /// The project ref.
421    pub project: String,
422    /// `--db-url`, if given.
423    pub db_url: Option<String>,
424    /// `--management-token`, if given.
425    pub management_token: Option<String>,
426    /// `--target-env`: the variable the target Postgres URL is read from.
427    pub target_env: String,
428    /// `--dir`: where `report.json`, `report.md` and `plan.json` go.
429    pub dir: PathBuf,
430    /// `--transfer-mbps`.
431    pub transfer_mbps: u32,
432    /// `--classify`.
433    pub classify: bool,
434    /// `--classify-threshold`.
435    pub classify_threshold: f32,
436    /// `--apply`: apply an approved plan instead of writing one.
437    pub apply: bool,
438    /// `--plan`: the plan file to apply.
439    pub plan: Option<PathBuf>,
440}
441
442/// Runs the command: inspects the source read-only, reads the target
443/// read-only, and either writes `report.json`, `report.md` and `plan.json`
444/// to `--dir`, or re-checks an approved plan with `--apply --plan`.
445///
446/// # Errors
447///
448/// A refusal or an access error. Blockers are in the plan and the exit code
449/// is zero; `--apply` fails (non-zero) on an unknown plan version, a
450/// project mismatch, drift or a remaining blocker.
451pub fn plan(args: &PlanArgs) -> Result<(), String> {
452    let credentials = resolve(
453        args.db_url.as_deref(),
454        args.management_token.as_deref(),
455        &EnvVars,
456    )?;
457    for note in &credentials.notes {
458        eprintln!("{note}");
459    }
460    run_plan(args, &credentials)
461}
462
463#[cfg(feature = "import-supabase")]
464fn run_plan(args: &PlanArgs, credentials: &Credentials) -> Result<(), String> {
465    use cratefield_import_supabase as engine;
466
467    let options = engine_options(
468        &args.project,
469        credentials,
470        args.transfer_mbps,
471        args.classify,
472        args.classify_threshold,
473    )?;
474    let runtime = tokio::runtime::Builder::new_current_thread()
475        .enable_all()
476        .build()
477        .map_err(|err| format!("cannot start the async runtime the plan needs: {err}"))?;
478    runtime.block_on(async {
479        if args.apply {
480            let path = args
481                .plan
482                .as_ref()
483                .ok_or_else(|| "--apply needs --plan <FILE>".to_owned())?;
484            return apply_plan(args, path, &options).await;
485        }
486        let report = engine::inspect(&options)
487            .await
488            .map_err(|error| error.to_string())?;
489        let target = match EnvVars.get(&args.target_env) {
490            Some(url) => Some(
491                engine::read_target(&engine::Secret::new(url))
492                    .await
493                    .map_err(|error| format!("could not read the target database: {error}"))?,
494            ),
495            None => None,
496        };
497        let plan = engine::build_plan(&report, target.as_ref());
498        std::fs::create_dir_all(&args.dir)
499            .map_err(|err| format!("cannot create {}: {err}", args.dir.display()))?;
500        write_report(&args.dir.join("report.json"), &report.to_json())?;
501        write_report(&args.dir.join("report.md"), &report.to_markdown())?;
502        write_report(&args.dir.join("plan.json"), &plan.to_json())?;
503        eprintln!(
504            "wrote {}/report.json, report.md and plan.json: {} blocker(s) in the plan",
505            args.dir.display(),
506            plan.blockers.len()
507        );
508        for blocker in &plan.blockers {
509            eprintln!("  blocker {}: {}", blocker.code, blocker.message);
510        }
511        Ok(())
512    })
513}
514
515/// `--apply --plan FILE`: re-inspects and refuses unless the plan still
516/// matches. It writes nothing: the auth-users and data phases land with
517/// #659/#660.
518#[cfg(feature = "import-supabase")]
519async fn apply_plan(
520    args: &PlanArgs,
521    path: &Path,
522    options: &cratefield_import_supabase::InspectOptions,
523) -> Result<(), String> {
524    use cratefield_import_supabase as engine;
525
526    let text = std::fs::read_to_string(path)
527        .map_err(|err| format!("cannot read {}: {err}", path.display()))?;
528    let plan: engine::Plan = serde_json::from_str(&text)
529        .map_err(|err| format!("{} is not a plan file: {err}", path.display()))?;
530    if plan.plan_version != engine::PLAN_VERSION {
531        return Err(format!(
532            "{} is a version {} plan, and this fz applies version {}; re-plan with `fz import \
533             supabase plan`",
534            path.display(),
535            plan.plan_version,
536            engine::PLAN_VERSION
537        ));
538    }
539    if plan.project.project_ref != args.project {
540        return Err(format!(
541            "{} is a plan for project {}, not {}; re-plan with `fz import supabase plan --project \
542             {}`",
543            path.display(),
544            plan.project.project_ref,
545            args.project,
546            args.project
547        ));
548    }
549    let report = engine::inspect(options)
550        .await
551        .map_err(|error| error.to_string())?;
552    engine::check_drift(&plan, &report).map_err(|drift| drift.to_string())?;
553    if !plan.blockers.is_empty() {
554        let codes: Vec<&str> = plan.blockers.iter().map(|b| b.code.as_str()).collect();
555        return Err(format!(
556            "the plan still has {} blocker(s) ({}); resolve them and re-plan",
557            plan.blockers.len(),
558            codes.join(", ")
559        ));
560    }
561    eprintln!(
562        "the plan matches the source ({}); the auth-users and data phases land with #659/#660 — \
563         nothing was written to the target",
564        plan.inspection_hash
565    );
566    Ok(())
567}
568
569#[cfg(not(feature = "import-supabase"))]
570fn run_plan(_args: &PlanArgs, _credentials: &Credentials) -> Result<(), String> {
571    Err(
572        // The same refusal `inspect` gives without the engine.
573        "this `fz` was built without the `import-supabase` feature, so it cannot reach Postgres. \
574         Install one that has it — `cargo install cratefield-cli --features import-supabase` — \
575         and run that binary (it needs no compiled-in harness)."
576            .to_owned(),
577    )
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use std::collections::BTreeMap;
584
585    struct Vars(BTreeMap<&'static str, &'static str>);
586
587    impl Config for Vars {
588        fn get(&self, key: &str) -> Option<String> {
589            self.0.get(key).map(|value| (*value).to_owned())
590        }
591    }
592
593    fn args() -> InspectArgs {
594        InspectArgs {
595            project: "abcdefghijklmnopqrst".to_owned(),
596            db_url: None,
597            management_token: None,
598            format: Format::Markdown,
599            out: None,
600            transfer_mbps: 100,
601            classify: false,
602            classify_threshold: 0.8,
603            dispositions: None,
604        }
605    }
606
607    #[test]
608    fn a_missing_url_is_a_refusal_not_a_prompt() {
609        let error = credentials(&args(), &Vars(BTreeMap::new())).unwrap_err();
610        assert!(error.contains(DB_URL_VAR), "{error}");
611        assert!(error.contains("never prompts"), "{error}");
612    }
613
614    #[test]
615    fn the_environment_is_read_and_never_printed() {
616        let vars = Vars(BTreeMap::from([
617            (DB_URL_VAR, "postgres://ro:hunter2@db.example.test/postgres"),
618            (ACCESS_TOKEN_VAR, "sbp_secret_token"),
619        ]));
620        let credentials = credentials(&args(), &vars).expect("resolved");
621        assert!(credentials.notes.is_empty());
622        assert!(credentials.management_token.is_some());
623        let debug = format!("{credentials:?}");
624        assert!(
625            !debug.contains("hunter2") && !debug.contains("sbp_"),
626            "{debug}"
627        );
628    }
629
630    #[test]
631    fn a_flag_wins_and_earns_a_note() {
632        let mut given = args();
633        given.db_url = Some("postgres://ro:hunter2@db.example.test/postgres".to_owned());
634        let vars = Vars(BTreeMap::from([(DB_URL_VAR, "postgres://other")]));
635        let credentials = credentials(&given, &vars).expect("resolved");
636        assert_eq!(
637            credentials.db_url.expose(),
638            "postgres://ro:hunter2@db.example.test/postgres"
639        );
640        assert_eq!(credentials.notes.len(), 1);
641        assert!(!credentials.notes[0].contains("hunter2"));
642    }
643
644    #[test]
645    #[cfg(not(feature = "import-supabase"))]
646    fn without_the_feature_the_refusal_names_the_install() {
647        let mut given = args();
648        given.db_url = Some("postgres://ro:hunter2@db.example.test/postgres".to_owned());
649        let error = inspect(&given).unwrap_err();
650        assert!(
651            error.contains("cargo install cratefield-cli --features import-supabase"),
652            "{error}"
653        );
654        assert!(!error.contains("hunter2"));
655    }
656
657    #[test]
658    #[cfg(feature = "import-supabase")]
659    fn dispositions_init_refuses_to_overwrite_without_force() {
660        let dir = std::env::temp_dir().join(format!("fz-dispositions-{}", std::process::id()));
661        std::fs::create_dir_all(&dir).expect("a temp dir");
662        let out = dir.join("supabase-dispositions.toml");
663        std::fs::write(&out, "# mine\n").expect("write");
664        let given = DispositionsInitArgs {
665            project: "abcdefghijklmnopqrst".to_owned(),
666            db_url: None,
667            management_token: None,
668            out: out.clone(),
669            force: false,
670            dispositions: None,
671        };
672        let error = dispositions_init(&given).unwrap_err();
673        assert!(error.contains("--force"), "{error}");
674        assert!(error.contains(&out.display().to_string()), "{error}");
675        // It left the file alone.
676        assert_eq!(std::fs::read_to_string(&out).expect("read"), "# mine\n");
677        let _ = std::fs::remove_dir_all(&dir);
678    }
679
680    #[test]
681    #[cfg(feature = "import-supabase")]
682    fn an_unreachable_database_is_an_error_that_quotes_no_secret() {
683        let mut given = args();
684        given.db_url = Some("postgres://ro:hunter2@127.0.0.1:1/postgres".to_owned());
685        let error = inspect(&given).unwrap_err();
686        assert!(error.contains("could not connect"), "{error}");
687        assert!(!error.contains("hunter2"), "{error}");
688    }
689}