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}