pgevolve/cli.rs
1//! `clap`-derived CLI surface. Spec §10.
2
3use std::path::PathBuf;
4
5use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
6
7/// `pgevolve` — Postgres declarative schema management.
8#[derive(Parser, Debug)]
9#[command(name = "pgevolve", version, about, long_about = None)]
10pub struct Cli {
11 /// Path to `pgevolve.toml`. Defaults to `./pgevolve.toml`.
12 #[arg(long, global = true)]
13 pub config: Option<PathBuf>,
14
15 /// Output format. `sql` is only meaningful for `diff`.
16 #[arg(long, value_enum, global = true, default_value_t = OutputFormat::Human)]
17 pub format: OutputFormat,
18
19 /// Increase verbosity (`-v`, `-vv`).
20 #[arg(short, long, global = true, action = ArgAction::Count)]
21 pub verbose: u8,
22
23 /// Quiet mode: errors only.
24 #[arg(short, long, global = true)]
25 pub quiet: bool,
26
27 /// Subcommand to invoke.
28 #[command(subcommand)]
29 pub cmd: Command,
30}
31
32/// The full set of pgevolve commands (v0.1 surface + v0.2 readiness additions).
33#[derive(Subcommand, Debug)]
34pub enum Command {
35 /// Scaffold a new pgevolve project.
36 Init(InitArgs),
37 /// Lint the source tree against the configured layout profile.
38 Lint(LintArgs),
39 /// Validate the source tree; with `--shadow`, round-trip through ephemeral PG.
40 Validate(ValidateArgs),
41 /// Show the diff between the source IR and a live database.
42 Diff(DiffArgs),
43 /// Produce a plan directory for a live database.
44 Plan(PlanArgs),
45 /// Apply a plan directory to a live database.
46 Apply(ApplyArgs),
47 /// Show recent applies and per-step state.
48 Status(StatusArgs),
49 /// Introspect a live database and write source SQL files.
50 Dump(DumpArgs),
51 /// Install or upgrade the `pgevolve` metadata schema.
52 Bootstrap(BootstrapArgs),
53 /// Report project health: bootstrap status, drift, recent failures.
54 Doctor {
55 /// Environment name (looked up in `[environments.<name>]`).
56 #[arg(long)]
57 db: String,
58 /// Override the resolved DSN.
59 #[arg(long)]
60 url: Option<String>,
61 },
62 /// Destructive table rewrite (v0.2 skeleton; implementation lands later).
63 RewriteTable {
64 /// Qualified table name (e.g., `app.users`).
65 qname: String,
66 /// Environment name.
67 #[arg(long)]
68 db: String,
69 /// Override the resolved DSN.
70 #[arg(long)]
71 url: Option<String>,
72 /// Required confirmation — without it the command refuses to run.
73 #[arg(long)]
74 confirm_rewrite: bool,
75 },
76 /// Cluster-level commands (roles; future: tablespaces, GUCs, etc.).
77 Cluster(ClusterArgs),
78 /// Render the dep graph (name-derived + AST-derived edges).
79 Graph {
80 /// Graph output format (dot or mermaid). Note: the global `--format`
81 /// flag is for human/json/sql output; this flag controls the graph
82 /// renderer.
83 #[arg(long = "graph-format", value_enum, default_value_t = GraphFormat::Dot)]
84 graph_format: GraphFormat,
85 /// Write to file instead of stdout.
86 #[arg(short = 'o', long)]
87 out: Option<PathBuf>,
88 /// Plan directory to render the post-plan dep graph. When absent,
89 /// renders the current source graph.
90 #[arg(long)]
91 plan: Option<PathBuf>,
92 },
93}
94
95/// Output format for `pgevolve graph`.
96#[derive(Debug, Clone, Copy, clap::ValueEnum)]
97pub enum GraphFormat {
98 /// DOT format (for use with graphviz).
99 Dot,
100 /// Mermaid graph format.
101 Mermaid,
102}
103
104/// Top-level output format.
105#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
106pub enum OutputFormat {
107 /// Human-readable hierarchical output. Default.
108 Human,
109 /// Stable JSON for automation.
110 Json,
111 /// Naive ALTER SQL — only valid for `diff`.
112 Sql,
113}
114
115/// `cluster` top-level arguments.
116#[derive(Args, Debug)]
117pub struct ClusterArgs {
118 /// Path to `pgevolve-cluster.toml`. Defaults to `./pgevolve-cluster.toml`.
119 #[arg(long, global = true)]
120 pub config: Option<PathBuf>,
121
122 /// Cluster subcommand to invoke.
123 #[command(subcommand)]
124 pub cmd: ClusterCommand,
125}
126
127/// Subcommands for `pgevolve cluster`.
128#[derive(Subcommand, Debug)]
129pub enum ClusterCommand {
130 /// Scaffold a new cluster project (`pgevolve-cluster.toml` + `roles/`).
131 Init {
132 /// Directory to initialize. Defaults to the current directory.
133 path: Option<PathBuf>,
134 },
135 /// Show the diff between the source roles and the live cluster.
136 Diff,
137 /// Produce a cluster plan directory (`cluster-plans/<id>/`).
138 Plan,
139 /// Apply a cluster plan directory.
140 Apply {
141 /// Plan id to apply. When omitted, applies the most recently created plan.
142 plan_id: Option<String>,
143 },
144 /// List cluster plans under `cluster-plans/`.
145 Status,
146}
147
148/// `init` arguments.
149#[derive(Args, Debug)]
150pub struct InitArgs {
151 /// Directory to initialize. Defaults to the current directory.
152 #[arg(long)]
153 pub dir: Option<PathBuf>,
154 /// Overwrite an existing `pgevolve.toml`.
155 #[arg(long)]
156 pub force: bool,
157}
158
159/// `lint` arguments.
160#[derive(Args, Debug)]
161pub struct LintArgs {}
162
163/// `validate` arguments.
164#[derive(Args, Debug)]
165pub struct ValidateArgs {
166 /// Round-trip the source through an ephemeral PG. Phase 12 wires the logic.
167 #[arg(long)]
168 pub shadow: bool,
169 /// Cross-check the source IR against an ephemeral or DSN-supplied
170 /// shadow Postgres after apply. Optional; arch spec Decision 12.
171 #[arg(long)]
172 pub shadow_validate: bool,
173 /// When --shadow-validate is set, treat warnings as errors.
174 #[arg(long, requires = "shadow_validate")]
175 pub shadow_strict: bool,
176}
177
178/// `diff` arguments.
179#[derive(Args, Debug)]
180pub struct DiffArgs {
181 /// Environment name (looked up in `[environments.<name>]`).
182 #[arg(long)]
183 pub db: String,
184 /// Override the resolved DSN.
185 #[arg(long)]
186 pub url: Option<String>,
187 /// Cross-check the source IR against an ephemeral or DSN-supplied
188 /// shadow Postgres after apply. Optional; arch spec Decision 12.
189 #[arg(long)]
190 pub shadow_validate: bool,
191 /// When --shadow-validate is set, treat warnings as errors.
192 #[arg(long, requires = "shadow_validate")]
193 pub shadow_strict: bool,
194}
195
196/// `plan` arguments.
197#[derive(Args, Debug)]
198pub struct PlanArgs {
199 /// Environment name.
200 #[arg(long)]
201 pub db: String,
202 /// Override the resolved DSN.
203 #[arg(long)]
204 pub url: Option<String>,
205 /// Output plan directory. Defaults to `<plan_dir>/<YYYY-MM-DD>-<id>`.
206 #[arg(short, long)]
207 pub output: Option<PathBuf>,
208 /// Cross-check the source IR against an ephemeral or DSN-supplied
209 /// shadow Postgres after apply. Optional; arch spec Decision 12.
210 #[arg(long)]
211 pub shadow_validate: bool,
212 /// When --shadow-validate is set, treat warnings as errors.
213 #[arg(long, requires = "shadow_validate")]
214 pub shadow_strict: bool,
215}
216
217/// `apply` arguments.
218#[derive(Args, Debug)]
219pub struct ApplyArgs {
220 /// Path to a plan directory written by `pgevolve plan`.
221 pub plan_dir: PathBuf,
222 /// Environment name.
223 #[arg(long)]
224 pub db: String,
225 /// Override the resolved DSN.
226 #[arg(long)]
227 pub url: Option<String>,
228 /// Skip the target-identity check (use only when re-targeting intentionally).
229 #[arg(long)]
230 pub allow_different_target: bool,
231 /// Skip the drift recheck (use only when re-applying after out-of-band changes).
232 #[arg(long)]
233 pub allow_drift: bool,
234}
235
236/// `status` arguments.
237#[derive(Args, Debug)]
238pub struct StatusArgs {
239 /// Environment name.
240 #[arg(long)]
241 pub db: String,
242 /// Override the resolved DSN.
243 #[arg(long)]
244 pub url: Option<String>,
245 /// Show per-step detail for one apply by id (UUID).
246 #[arg(long)]
247 pub apply_id: Option<String>,
248 /// Limit the recent-applies list (default 10).
249 #[arg(long, default_value_t = 10)]
250 pub limit: u32,
251}
252
253/// `dump` arguments.
254#[derive(Args, Debug)]
255pub struct DumpArgs {
256 /// Environment name.
257 #[arg(long)]
258 pub db: String,
259 /// Override the resolved DSN.
260 #[arg(long)]
261 pub url: Option<String>,
262 /// Output directory.
263 #[arg(short, long)]
264 pub output: PathBuf,
265}
266
267/// `bootstrap` arguments.
268#[derive(Args, Debug)]
269pub struct BootstrapArgs {
270 /// Environment name.
271 #[arg(long)]
272 pub db: String,
273 /// Override the resolved DSN.
274 #[arg(long)]
275 pub url: Option<String>,
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn parses_diff_command() {
284 let cli = Cli::try_parse_from(["pgevolve", "diff", "--db", "dev"]).unwrap();
285 match cli.cmd {
286 Command::Diff(a) => assert_eq!(a.db, "dev"),
287 _ => panic!("expected diff"),
288 }
289 }
290
291 #[test]
292 fn parses_apply_with_plan_dir() {
293 let cli = Cli::try_parse_from(["pgevolve", "apply", "/tmp/plan", "--db", "dev"]).unwrap();
294 match cli.cmd {
295 Command::Apply(a) => {
296 assert_eq!(a.plan_dir, PathBuf::from("/tmp/plan"));
297 assert_eq!(a.db, "dev");
298 assert!(!a.allow_drift);
299 }
300 _ => panic!("expected apply"),
301 }
302 }
303
304 #[test]
305 fn rejects_missing_db_argument() {
306 assert!(Cli::try_parse_from(["pgevolve", "diff"]).is_err());
307 }
308
309 #[test]
310 fn parses_global_format_flag() {
311 let cli =
312 Cli::try_parse_from(["pgevolve", "--format", "json", "diff", "--db", "x"]).unwrap();
313 assert_eq!(cli.format, OutputFormat::Json);
314 }
315
316 #[test]
317 fn parses_verbosity_count() {
318 let cli = Cli::try_parse_from(["pgevolve", "-vv", "diff", "--db", "x"]).unwrap();
319 assert_eq!(cli.verbose, 2);
320 }
321}