Skip to main content

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}