Skip to main content

cratefield_cli/
lib.rs

1//! `fz` — the Factory Zero venture CLI (issue #8): `fz migrations
2//! collect`, `fz migrations apply` (issue #18), `fz doctor`, `fz
3//! modules`.
4//!
5//! `fz` is linked into the venture as a bin target so it can see the
6//! compiled-in harness. The documented pattern (venture template):
7//!
8//! ```text
9//! // src/lib.rs
10//! pub use harness::harness;
11//! // Cargo.toml
12//! [[bin]]
13//! name = "fz"
14//! path = "src/fz_main.rs"
15//! // src/fz_main.rs
16//! fn main() { cratefield_cli::main_for(venture::harness); }
17//! ```
18//!
19//! Then `cargo run -p venture --bin fz -- migrations collect`.
20
21#![doc = include_str!("../README.md")]
22#![forbid(unsafe_code)]
23
24pub mod apply;
25mod collect;
26pub mod data;
27mod doctor;
28pub mod lint;
29mod lock;
30pub mod sidecars;
31
32use clap::{Parser, Subcommand};
33use cratefield_core::Harness;
34use std::path::PathBuf;
35use std::process::ExitCode;
36
37#[derive(Parser)]
38#[command(name = "fz", about = "Factory Zero venture CLI")]
39struct Cli {
40    /// The sidecar mount table, as the runtime reads it from config:
41    /// `{"module-name":"BINDING"}`. Defaults to the `HARNESS_SIDECARS`
42    /// environment variable. Commands that walk the compiled-in modules
43    /// use it to say what they cannot see (issue #66).
44    #[arg(long, global = true, value_name = "JSON")]
45    sidecars: Option<String>,
46    #[command(subcommand)]
47    command: Command,
48}
49
50#[derive(Subcommand)]
51enum Command {
52    /// Writes wrangler-compatible migration files and maintains the
53    /// `.harness-lock.json` pinning module migrations to file names.
54    Migrations {
55        #[command(subcommand)]
56        command: MigrationsCommand,
57    },
58    /// Validates the harness, the production-captcha rule, the lockfile
59    /// and the portable-SQL lint.
60    Doctor {
61        /// Migration directory (default `migrations`).
62        #[arg(long, default_value = "migrations")]
63        out: PathBuf,
64        /// Accept a production venture without a Captcha port for the
65        /// stated reason; prints a warning instead of failing.
66        #[arg(long, value_name = "REASON")]
67        allow_no_captcha: Option<String>,
68    },
69    /// Prints modules, versions, route prefixes, emitted events, tables.
70    Modules,
71    /// Moves venture data between engines (issue #21): export D1/SQLite
72    /// data to JSONL with a manifest, import into Postgres.
73    Data {
74        #[command(subcommand)]
75        command: DataCommand,
76    },
77}
78
79#[derive(Subcommand)]
80enum DataCommand {
81    /// Exports the venture's tables from a SQLite database (the D1
82    /// stand-in — `docs/DATA-MOVE.md`) to manifest + JSONL records.
83    Export {
84        /// The SQLite database file to read.
85        #[arg(long, value_name = "PATH")]
86        db: PathBuf,
87        /// Export without the tables of any sidecar-mounted module,
88        /// recording the omission in the manifest. Without this, an
89        /// export refuses to run when `HARNESS_SIDECARS` is set, rather
90        /// than producing an artifact that looks complete (issue #66).
91        #[arg(long)]
92        without_sidecar_tables: bool,
93        /// The JSONL file to write (manifest line first).
94        #[arg(long, value_name = "FILE")]
95        out: PathBuf,
96        /// Prints the per-table summary without writing the file.
97        #[arg(long)]
98        plan: bool,
99    },
100    /// Loads an export file into a Postgres database in lock order,
101    /// verifying sha256 and row counts against the manifest. Requires
102    /// the `postgres` feature.
103    Import {
104        /// Connection string of the target Postgres database.
105        #[arg(long, value_name = "URL")]
106        url: String,
107        /// The export file written by `fz data export`.
108        #[arg(value_name = "FILE")]
109        file: PathBuf,
110        /// Adds to non-empty tables instead of refusing them.
111        #[arg(long)]
112        append: bool,
113        /// Prints what would be imported and the target state, writing
114        /// nothing.
115        #[arg(long)]
116        plan: bool,
117    },
118}
119
120#[derive(Subcommand)]
121enum MigrationsCommand {
122    /// Collects module migrations into wrangler-ordered files.
123    Collect {
124        /// SQL dialect of the collected files (sqlite for wrangler/D1).
125        #[arg(long, default_value = "sqlite")]
126        dialect: String,
127        /// Output directory for wrangler `migrations_dir`.
128        #[arg(long, default_value = "migrations")]
129        out: PathBuf,
130    },
131    /// Applies the harness's migrations directly to a database — the
132    /// native counterpart of `wrangler d1 migrations apply` (issue #18).
133    Apply {
134        /// Target SQL dialect: postgres.
135        #[arg(long, default_value = "postgres")]
136        dialect: String,
137        /// Connection string of the target database, e.g.
138        /// `postgres://user:pass@host:5432/venture`.
139        #[arg(long, value_name = "URL")]
140        url: String,
141    },
142}
143
144/// Runs `fz` with the venture's harness and the given arguments.
145///
146/// # Panics
147///
148/// Never; failures are reported on stderr with a non-zero exit code.
149#[must_use = "call process::exit with the returned ExitCode"]
150pub fn run(build: impl Fn() -> Harness, args: impl IntoIterator<Item = String>) -> ExitCode {
151    let mut full_argv: Vec<String> = Vec::with_capacity(8);
152    full_argv.push("fz".to_string());
153    full_argv.extend(args);
154    let cli = Cli::parse_from(full_argv);
155    let harness = build();
156    let sidecars = crate::sidecars::from_cli_or_env(cli.sidecars.as_deref());
157    let result = match cli.command {
158        Command::Migrations {
159            command: MigrationsCommand::Collect { dialect, out },
160        } => collect::collect(&harness, &dialect, &out),
161        Command::Migrations {
162            command: MigrationsCommand::Apply { dialect, url },
163        } => apply::apply(&harness, &dialect, &url),
164        Command::Doctor {
165            out,
166            allow_no_captcha,
167        } => doctor::doctor(
168            &harness,
169            &out,
170            allow_no_captcha.as_deref(),
171            sidecars.as_deref(),
172        ),
173        Command::Modules => {
174            print_modules(&harness);
175            Ok(())
176        }
177        Command::Data {
178            command:
179                DataCommand::Export {
180                    db,
181                    out,
182                    plan,
183                    without_sidecar_tables,
184                },
185        } => data::export(
186            &harness,
187            &db,
188            &out,
189            plan,
190            without_sidecar_tables,
191            sidecars.as_deref(),
192        ),
193        Command::Data {
194            command:
195                DataCommand::Import {
196                    url,
197                    file,
198                    append,
199                    plan,
200                },
201        } => data::import(&harness, &file, &url, append, plan),
202    };
203    match result {
204        Ok(()) => ExitCode::SUCCESS,
205        Err(message) => {
206            eprintln!("fz: {message}");
207            ExitCode::FAILURE
208        }
209    }
210}
211
212/// Entry point for the venture-side `fz` bin (see the crate docs).
213pub fn main_for(build: fn() -> Harness) -> ! {
214    let code = run(build, std::env::args().skip(1));
215    std::process::exit(match code {
216        ExitCode::SUCCESS => 0,
217        _ => 1,
218    });
219}
220
221fn print_modules(harness: &Harness) {
222    for module in harness.modules() {
223        println!(
224            "{} {} /v1/{} emits=[{}] tables=[{}]",
225            module.name(),
226            module.version(),
227            module.name(),
228            module.emits().join(", "),
229            module.tables().join(", "),
230        );
231    }
232}