1use std::path::{Path, PathBuf};
24
25use crate::EnvVars;
26use cratefield_core::Config;
27
28pub const DB_URL_VAR: &str = "SUPABASE_DB_URL";
30pub const ACCESS_TOKEN_VAR: &str = "SUPABASE_ACCESS_TOKEN";
32pub const DEFAULT_TARGET_ENV: &str = "DATABASE_URL";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Format {
40 Markdown,
42 Json,
44}
45
46#[derive(Debug, Clone)]
48pub struct InspectArgs {
49 pub project: String,
51 pub db_url: Option<String>,
53 pub management_token: Option<String>,
55 pub format: Format,
57 pub out: Option<PathBuf>,
59 pub transfer_mbps: u32,
61 pub classify: bool,
63 pub classify_threshold: f32,
65 pub dispositions: Option<PathBuf>,
67}
68
69#[derive(Debug, Clone)]
71pub struct DispositionsInitArgs {
72 pub project: String,
74 pub db_url: Option<String>,
76 pub management_token: Option<String>,
78 pub out: PathBuf,
80 pub force: bool,
82 pub dispositions: Option<PathBuf>,
84}
85
86impl DispositionsInitArgs {
87 #[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#[derive(Debug)]
108pub struct Credentials {
109 pub db_url: Secret,
111 pub management_token: Option<Secret>,
113 pub notes: Vec<String>,
116}
117
118#[derive(Clone)]
121pub struct Secret(zeroize::Zeroizing<String>);
122
123impl Secret {
124 #[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
137pub 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
151fn 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
176pub 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
210pub 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
237pub struct Rendered {
239 pub text: String,
241 pub summary: String,
243}
244
245#[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
251pub fn dispositions_init(args: &DispositionsInitArgs) -> Result<(), String> {
258 #[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 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 Err(WITHOUT_FEATURE.to_owned())
414}
415
416#[derive(Debug, Clone)]
419pub struct PlanArgs {
420 pub project: String,
422 pub db_url: Option<String>,
424 pub management_token: Option<String>,
426 pub target_env: String,
428 pub dir: PathBuf,
430 pub transfer_mbps: u32,
432 pub classify: bool,
434 pub classify_threshold: f32,
436 pub apply: bool,
438 pub plan: Option<PathBuf>,
440}
441
442pub 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#[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 "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 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}