Skip to main content

reddb_server/cli/
commands.rs

1/// RedDB command definitions.
2///
3/// Defines the command tree, Flag and Route types used by help and completion
4/// generators, and the schema for each built-in command.
5use super::types::FlagSchema;
6
7// ============================================================================
8// Help-layer types (used by help.rs and complete.rs)
9// ============================================================================
10
11/// Lightweight flag descriptor used by the help generator.
12#[derive(Debug, Clone)]
13pub struct Flag {
14    pub short: Option<char>,
15    pub long: String,
16    pub description: String,
17    pub default: Option<String>,
18    pub arg: Option<String>,
19}
20
21impl Flag {
22    pub fn new(long: &str, desc: &str) -> Self {
23        Self {
24            short: None,
25            long: long.to_string(),
26            description: desc.to_string(),
27            default: None,
28            arg: None,
29        }
30    }
31
32    pub fn with_short(mut self, short: char) -> Self {
33        self.short = Some(short);
34        self
35    }
36
37    pub fn with_default(mut self, default: &str) -> Self {
38        self.default = Some(default.to_string());
39        self
40    }
41
42    pub fn with_arg(mut self, arg: &str) -> Self {
43        self.arg = Some(arg.to_string());
44        self
45    }
46}
47
48/// A single routable verb within a resource.
49#[derive(Debug, Clone)]
50pub struct Route {
51    pub verb: &'static str,
52    pub summary: &'static str,
53    pub usage: &'static str,
54}
55
56// ============================================================================
57// RedDB command definitions
58// ============================================================================
59
60/// Command descriptor for a top-level RedDB command.
61pub struct CommandDef {
62    pub name: &'static str,
63    pub summary: &'static str,
64    pub usage: &'static str,
65    pub flags: Vec<FlagSchema>,
66}
67
68/// Return all RedDB commands.
69pub fn all_commands() -> Vec<CommandDef> {
70    vec![
71    CommandDef {
72      name: "server",
73      summary: "Start the database server (router/HTTP/gRPC/wire)",
74      usage: "red server [--grpc] [--http] [--grpc-bind 127.0.0.1:55055] [--http-bind 127.0.0.1:5000] [--wire-bind 127.0.0.1:5050] [--path ./data/reddb.rdb]",
75      flags: server_flags(),
76    },
77    CommandDef {
78      name: "service",
79      summary: "Install or inspect a systemd service",
80      usage: "red service <install|print-unit> [--binary /usr/local/bin/red] [--grpc-bind 0.0.0.0:55055] [--http-bind 0.0.0.0:5000] [--path /var/lib/reddb/data.rdb]",
81      flags: service_flags(),
82    },
83    CommandDef {
84      name: "query",
85      summary: "Execute a query against the database",
86      usage: "red query \"SELECT * FROM users WHERE age > $1\" -p 21",
87      flags: query_flags(),
88    },
89    CommandDef {
90      name: "insert",
91      summary: "Insert an entity into a collection",
92      usage: "red insert users '{\"name\": \"Alice\", \"age\": 30}'",
93      flags: insert_flags(),
94    },
95    CommandDef {
96      name: "get",
97      summary: "Get an entity by ID from a collection",
98      usage: "red get users abc123",
99      flags: get_flags(),
100    },
101    CommandDef {
102      name: "delete",
103      summary: "Delete an entity by ID from a collection",
104      usage: "red delete users abc123",
105      flags: delete_flags(),
106    },
107    CommandDef {
108      name: "health",
109      summary: "Run a health check against the server",
110      usage: "red health [--bind 127.0.0.1:5050] [--grpc|--http]",
111      flags: health_flags(),
112    },
113    CommandDef {
114      name: "tick",
115      summary: "Run maintenance/reclaim tick operations",
116      usage: "red tick [--bind 127.0.0.1:5000] [--operations maintenance,retention,checkpoint] [--dry-run]",
117      flags: tick_flags(),
118    },
119    CommandDef {
120      name: "migrate-from-redis",
121      summary: "Validate Redis to Blob Cache migration readiness; dual-write uses the documented application-owned helper pattern",
122      usage: "red migrate-from-redis --dry-run --redis-url redis://127.0.0.1:6379/0 [--path ./data/reddb.rdb]",
123      flags: migrate_from_redis_flags(),
124    },
125    CommandDef {
126      name: "migrate-pager-zone",
127      summary: "Convert a closed legacy sidecar-backed store to the zoned .rdb format",
128      usage: "red migrate-pager-zone --path ./data/reddb.rdb [--json]",
129      flags: migrate_pager_zone_flags(),
130    },
131    CommandDef {
132      name: "salvage",
133      summary: "Extract verified data from a damaged embedded store into a fresh .rdb",
134      usage: "red salvage --source damaged.rdb --destination recovered.rdb [--json]",
135      flags: salvage_flags(),
136    },
137    CommandDef {
138      name: "replica",
139      summary: "Start as a read replica connected to a primary",
140      usage: "red replica --primary-addr http://primary:55055 [--grpc] [--http] [--grpc-bind 127.0.0.1:55055] [--http-bind 127.0.0.1:5000] [--path ./data/reddb.rdb]",
141      flags: replica_flags(),
142    },
143    CommandDef {
144      name: "status",
145      summary: "Show replication status",
146      usage: "red status [--bind 0.0.0.0:6380]",
147      flags: status_flags(),
148    },
149    CommandDef {
150      name: "inspect",
151      summary: "Inspect on-disk database state (catalog snapshot)",
152      usage: "red inspect catalog --path <FILE> [--at <SEQ>] [--json]",
153      flags: inspect_flags(),
154    },
155    CommandDef {
156      name: "mcp",
157      summary: "Start MCP server for AI agent integration",
158      usage: "red mcp [--path /data | --url <URI>] [--token <token>]",
159      flags: mcp_flags(),
160    },
161    CommandDef {
162      name: "auth",
163      summary: "Manage authentication (users, tokens, roles)",
164      usage: "red auth <subcommand>",
165      flags: auth_flags(),
166    },
167    CommandDef {
168      name: "connect",
169      summary: "Connect to a remote RedDB server (interactive REPL)",
170      usage: "red connect [--token <token>] [--query <sql>] <addr>",
171      flags: connect_flags(),
172    },
173    CommandDef {
174      name: "dump",
175      summary: "Export one or all collections as JSONL for backup/migration",
176      usage: "red dump [--path file] [--collection NAME] [-o FILE]",
177      flags: dump_flags(),
178    },
179    CommandDef {
180      name: "restore",
181      summary: "Import a previously dumped JSONL file into the database",
182      usage: "red restore [--path file] -i FILE [--collection NAME]",
183      flags: restore_flags(),
184    },
185    CommandDef {
186      name: "pitr-list",
187      summary: "List available point-in-time restore points from a snapshot archive",
188      usage: "red pitr-list --snapshot-prefix DIR --wal-prefix DIR",
189      flags: pitr_list_flags(),
190    },
191    CommandDef {
192      name: "pitr-restore",
193      summary: "Restore a database to a specific point in time from snapshots + WAL archive",
194      usage: "red pitr-restore --target-time UNIX_MS --dest PATH --snapshot-prefix DIR --wal-prefix DIR",
195      flags: pitr_restore_flags(),
196    },
197    CommandDef {
198      name: "doctor",
199      summary: "Health-check a running server against operator thresholds (PLAN.md Phase 5.5)",
200      usage: "red doctor [--bind 127.0.0.1:5000] [--token <admin>] [--json] [--backup-age-warn-secs 600] [--backup-age-crit-secs 3600] [--wal-lag-warn 1000] [--wal-lag-crit 10000]",
201      flags: doctor_flags(),
202    },
203    CommandDef {
204      name: "bootstrap",
205      summary: "One-shot first-admin bootstrap for headless containers / K8s Jobs",
206      usage: "red bootstrap --path PATH --vault [--username USER] [--password-stdin] [--print-certificate] [--json]",
207      flags: bootstrap_flags(),
208    },
209    CommandDef {
210      name: "version",
211      summary: "Show RedDB version information",
212      usage: "red version",
213      flags: vec![],
214    },
215    CommandDef {
216      name: "vcs",
217      summary: "Version-control operations (Git for Data)",
218      usage: "red vcs <commit|branch|branches|tag|tags|checkout|merge|log|status|lca|resolve> [args] [flags]",
219      flags: vcs_flags(),
220    },
221    CommandDef {
222      name: "ui",
223      summary: "Open a graphical UI against a local .rdb or a remote red:///reds:// instance over a RedWire-over-WS bridge",
224      usage: "red ui file://./data.rdb | red ui red://host:port [--token TOKEN] [--ui-dir DIR] [--port N] [--tls-ca PEM] [--no-browser]",
225      flags: ui_flags(),
226    },
227  ]
228}
229
230/// Return the help text for the main `red` command.
231pub fn main_help_text() -> String {
232    let mut out = String::with_capacity(1024);
233
234    out.push_str("reddb -- unified multi-model database engine\n");
235    out.push('\n');
236    out.push_str("Usage: red <command> [args] [flags]\n");
237    out.push('\n');
238
239    out.push_str("Commands:\n");
240    for cmd in all_commands() {
241        out.push_str(&format!("  {:<14} {}\n", cmd.name, cmd.summary));
242    }
243    out.push_str(&format!("  {:<14} {}\n", "help", "Show help for a command"));
244    out.push('\n');
245
246    out.push_str("Global flags:\n");
247    out.push_str(&format!("  {:<24} {}\n", "-h, --help", "Show help"));
248    out.push_str(&format!("  {:<24} {}\n", "-j, --json", "Force JSON output"));
249    out.push_str(&format!(
250        "  {:<24} {}\n",
251        "-o, --output FORMAT", "Output format [text|json|yaml]"
252    ));
253    out.push_str(&format!("  {:<24} {}\n", "-v, --verbose", "Verbose output"));
254    out.push_str(&format!(
255        "  {:<24} {}\n",
256        "    --no-color", "Disable colors"
257    ));
258    out.push_str(&format!("  {:<24} {}\n", "    --version", "Show version"));
259    out.push('\n');
260
261    out.push_str("Examples:\n");
262    out.push_str("  red server --path ./data/reddb.rdb\n");
263    out.push_str("  red server --grpc-bind 127.0.0.1:55055 --http-bind 127.0.0.1:5000 --path ./data/reddb.rdb\n");
264    out.push_str("  red server --wire-bind 127.0.0.1:5050 --path ./data/reddb.rdb\n");
265    out.push_str("  sudo red service install --binary /usr/local/bin/red --grpc-bind 0.0.0.0:55055 --http-bind 0.0.0.0:5000 --path /var/lib/reddb/data.rdb\n");
266    out.push_str("  red replica --primary-addr http://primary:55055 --path ./data/replica.rdb\n");
267    out.push_str("  red query \"SELECT * FROM users\"\n");
268    out.push_str("  red insert users '{\"name\": \"Alice\"}'\n");
269    out.push_str("  red get users abc123\n");
270    out.push_str("  red health\n");
271    out.push_str(
272        "  red tick --bind 127.0.0.1:5000 --operations maintenance,retention,checkpoint\n",
273    );
274    out.push_str("  red auth create-user alice --password secret --role admin\n");
275    out.push_str("  red auth create-api-key alice --name \"ci-token\" --role write\n");
276    out.push_str("  red auth list-users\n");
277    out.push_str("  red auth login alice --password secret\n");
278    out.push_str("  red connect 127.0.0.1:5050\n");
279    out.push_str("  red connect --query \"SELECT * FROM users\" 127.0.0.1:5050\n");
280    out.push('\n');
281
282    out.push_str("Run 'red <command> --help' for more information on a command.\n");
283    out
284}
285
286/// Return help text for a specific command.
287pub fn command_help_text(name: &str) -> Option<String> {
288    let cmds = all_commands();
289    let cmd = cmds.iter().find(|c| c.name == name)?;
290
291    let mut out = String::with_capacity(512);
292
293    out.push_str(&format!("red {} -- {}\n", cmd.name, cmd.summary));
294    out.push('\n');
295    out.push_str(&format!("Usage: {}\n", cmd.usage));
296    out.push('\n');
297
298    if !cmd.flags.is_empty() {
299        out.push_str("Flags:\n");
300        for flag in &cmd.flags {
301            let short_part = match flag.short {
302                Some(ch) => format!("-{}, ", ch),
303                None => "    ".to_string(),
304            };
305            let value_part = if flag.expects_value {
306                format!(" <{}>", flag.long.to_uppercase())
307            } else {
308                String::new()
309            };
310            let label = format!("{}--{}{}", short_part, flag.long, value_part);
311            let padding = if label.len() < 24 {
312                24 - label.len()
313            } else {
314                2
315            };
316            let default_text = match &flag.default {
317                Some(d) => format!(" (default: {})", d),
318                None => String::new(),
319            };
320            out.push_str(&format!(
321                "  {}{}{}{}\n",
322                label,
323                " ".repeat(padding),
324                flag.description,
325                default_text,
326            ));
327        }
328        out.push('\n');
329    }
330
331    Some(out)
332}
333
334// ============================================================================
335// Per-command flag schemas
336// ============================================================================
337
338fn server_flags() -> Vec<FlagSchema> {
339    vec![
340        FlagSchema::new("path")
341            .with_short('d')
342            .with_description("Persistent database file path (omit for in-memory)")
343            .with_default("./data/reddb.rdb"),
344        FlagSchema::new("bind").with_short('b').with_description(
345            "Bind address (host:port) for the routed front-door or legacy single-transport mode",
346        ),
347        FlagSchema::boolean("grpc").with_description("Enable the gRPC API"),
348        FlagSchema::boolean("http").with_description("Serve the HTTP API"),
349        FlagSchema::new("grpc-bind").with_description("Explicit gRPC bind address (host:port)"),
350        FlagSchema::new("http-bind").with_description("Explicit HTTP bind address (host:port)"),
351        FlagSchema::new("wire-bind")
352            .with_description("Explicit wire bind address (host:port or unix:///path/to/socket)"),
353        FlagSchema::new("wire-tls-bind")
354            .with_description("Explicit wire TLS bind address (host:port)"),
355        FlagSchema::new("wire-tls-cert")
356            .with_description("Path to TLS certificate PEM for wire TLS"),
357        FlagSchema::new("wire-tls-key")
358            .with_description("Path to TLS private key PEM for wire TLS"),
359        FlagSchema::new("pg-bind").with_description(
360            "PostgreSQL wire protocol bind address (enables psql / JDBC / DBeaver clients)",
361        ),
362        FlagSchema::new("role")
363            .with_short('r')
364            .with_description("Replication role")
365            .with_choices(&["standalone", "primary", "replica"])
366            .with_default("standalone"),
367        FlagSchema::new("primary-addr").with_description("Primary gRPC address for replica mode"),
368        FlagSchema::boolean("read-only").with_description("Open the database in read-only mode"),
369        FlagSchema::boolean("no-create-if-missing")
370            .with_description("Fail instead of creating the database file"),
371        FlagSchema::boolean("auth").with_description("Enable authentication for this boot"),
372        FlagSchema::boolean("require-auth")
373            .with_description("Reject anonymous requests; implies --auth"),
374        FlagSchema::new("vault")
375            .with_description("Enable encrypted auth vault (reserved pages in main .rdb file)")
376            .with_default("false"),
377        FlagSchema::boolean("no-auth").with_description(
378            "Hard-disable auth: anonymous access, ignores REDDB_USERNAME/PASSWORD/vault, \
379             prints a startup warning. Local-dev shortcut — NEVER use in production.",
380        ),
381        FlagSchema::boolean("dev")
382            .with_description("Alias for --no-auth (local development convenience)."),
383        FlagSchema::new("bootstrap-preset")
384            .with_description(
385                "First-boot preset. With --vault on a fresh --path, red server \
386                 self-bootstraps the paged vault in place (no separate `red bootstrap`) \
387                 then applies the preset and serves; a re-boot against the existing \
388                 vault just serves (idempotent — no re-bootstrap, no new certificate).",
389            )
390            .with_choices(&["simple", "production", "regulated", "cloud"]),
391        FlagSchema::new("bootstrap-manifest").with_description(
392            "Path to manifest-driven first boot JSON. Applied once on a fresh database \
393                 before serving; later boots observe bootstrap state and skip re-applying \
394                 the manifest (idempotent).",
395        ),
396        FlagSchema::new("bootstrap-admin")
397            .with_description("First admin username for production/cloud bootstrap"),
398        FlagSchema::new("bootstrap-admin-password").with_description(
399            "First admin password (DEV ONLY; prefer --bootstrap-admin-password-file)",
400        ),
401        FlagSchema::new("bootstrap-admin-password-file")
402            .with_description("File containing first admin password"),
403        FlagSchema::new("cloud-head-admin")
404            .with_description("Cloud preset head/platform admin username"),
405        FlagSchema::new("cloud-head-admin-password").with_description(
406            "Cloud preset head/platform admin password (DEV ONLY; prefer file flag)",
407        ),
408        FlagSchema::new("cloud-head-admin-password-file")
409            .with_description("File containing cloud head admin password"),
410        FlagSchema::new("customer-admin").with_description("Cloud preset customer admin username"),
411        FlagSchema::new("customer-admin-password")
412            .with_description("Cloud preset customer admin password (DEV ONLY; prefer file flag)"),
413        FlagSchema::new("customer-admin-password-file")
414            .with_description("File containing cloud customer admin password"),
415        FlagSchema::new("bootstrap-cert-out").with_description(
416            "Write the first-boot unseal certificate to this file (e.g. /run/reddb/cert.pem) \
417             in addition to stderr, so a distroless init can read it back for the next boot's \
418             REDDB_CERTIFICATE_FILE unseal. Written only on the bootstrap-creating boot; a \
419             re-boot against the existing vault does not rewrite it.",
420        ),
421        FlagSchema::new("log-dir").with_description(
422            "Directory for rotating log files (defaults to the parent of --path / ./logs)",
423        ),
424        FlagSchema::new("log-level")
425            .with_description(
426                "Log level filter — trace / debug / info / warn / error, or a RUST_LOG expression",
427            )
428            .with_default("info"),
429        FlagSchema::new("log-format")
430            .with_description("Log output format")
431            .with_choices(&["pretty", "json"])
432            .with_default("pretty"),
433        FlagSchema::new("log-keep-days")
434            .with_description("Number of rotated log files to keep")
435            .with_default("14"),
436        FlagSchema::boolean("no-log-file")
437            .with_description("Disable rotating file logs (stderr only)"),
438        FlagSchema::new("http-max-handlers").with_description(
439            "Max concurrent HTTP handler threads (env: REDDB_HTTP_MAX_HANDLERS; \
440             red_config: red.http.max_handlers; default: (2 x num_cpus).clamp(8, 256))",
441        ),
442        FlagSchema::new("http-handler-timeout-ms")
443            .with_description(
444                "Per-handler total-time budget in ms (env: REDDB_HTTP_HANDLER_TIMEOUT_MS; \
445             red_config: red.http.handler_timeout_ms)",
446            )
447            .with_default("30000"),
448        FlagSchema::new("http-retry-after-secs")
449            .with_description(
450                "Retry-After seconds on limiter 503 (env: REDDB_HTTP_RETRY_AFTER_SECS; \
451             red_config: red.http.retry_after_secs; clamped to [1, 30])",
452            )
453            .with_default("5"),
454        FlagSchema::new("http-max-inflight-per-principal").with_description(
455            "Max concurrent in-flight HTTP requests per principal; over-cap requests \
456             get a structured 429 (env: REDDB_HTTP_MAX_INFLIGHT_PER_PRINCIPAL; \
457             red_config: red.http.max_inflight_per_principal; 0 disables; default: 64)",
458        ),
459    ]
460}
461
462fn replica_flags() -> Vec<FlagSchema> {
463    vec![
464        FlagSchema::new("primary-addr")
465            .with_short('p')
466            .with_description("Primary gRPC address (e.g. http://primary:55055)"),
467        FlagSchema::new("path")
468            .with_short('d')
469            .with_description("Local replica database file path")
470            .with_default("./data/reddb.rdb"),
471        FlagSchema::new("bind").with_short('b').with_description(
472            "Bind address (host:port) for the routed front-door or legacy single-transport mode",
473        ),
474        FlagSchema::boolean("grpc").with_description("Enable the gRPC API"),
475        FlagSchema::boolean("http").with_description("Serve the HTTP API"),
476        FlagSchema::new("grpc-bind").with_description("Explicit gRPC bind address (host:port)"),
477        FlagSchema::new("http-bind").with_description("Explicit HTTP bind address (host:port)"),
478        FlagSchema::new("wire-bind")
479            .with_description("Explicit wire bind address (host:port or unix:///path/to/socket)"),
480        FlagSchema::boolean("auth").with_description("Enable authentication for this boot"),
481        FlagSchema::boolean("require-auth")
482            .with_description("Reject anonymous requests; implies --auth"),
483        FlagSchema::new("vault")
484            .with_description("Enable encrypted auth vault (reserved pages in main .rdb file)")
485            .with_default("false"),
486        FlagSchema::boolean("no-auth")
487            .with_description("Hard-disable auth: anonymous access, ignores vault"),
488    ]
489}
490
491fn ui_flags() -> Vec<FlagSchema> {
492    vec![
493        FlagSchema::boolean("server")
494            .with_description("Force the browser-served bridge path (skip the desktop deep link)"),
495        FlagSchema::boolean("desktop").with_description(
496            "Force the desktop app via the redui:// deep link (no browser fallback)",
497        ),
498        FlagSchema::new("ui-dir").with_description(
499            "Directory to serve the UI bundle from (defaults to the built-in fixture)",
500        ),
501        FlagSchema::new("port")
502            .with_description("Loopback port for the bridge (0 / omit picks an ephemeral port)"),
503        FlagSchema::new("tls-ca").with_description(
504            "PEM CA bundle to trust for a reds:// target (on top of system roots)",
505        ),
506        FlagSchema::new("token").with_short('t').with_description(
507            "Bearer token (session/API key). Held by red and injected into the \
508             RedWire handshake — the UI never sees it (env: RED_UI_TOKEN)",
509        ),
510        FlagSchema::boolean("no-browser").with_description(
511            "Do not open the default browser (also honoured via RED_UI_NO_BROWSER)",
512        ),
513    ]
514}
515
516fn vcs_flags() -> Vec<FlagSchema> {
517    vec![
518        FlagSchema::new("path")
519            .with_short('d')
520            .with_description("Persistent database file path (omit for in-memory)"),
521        FlagSchema::new("connection")
522            .with_short('c')
523            .with_description("Connection id for workset scoping")
524            .with_default("1"),
525        FlagSchema::new("branch").with_description("Branch name (for log/checkout/merge)"),
526        FlagSchema::new("from").with_description("Source ref or commit (branch create / merge)"),
527        FlagSchema::new("to").with_description("Upper bound for log range"),
528        FlagSchema::new("author")
529            .with_description("Commit author name")
530            .with_default("reddb"),
531        FlagSchema::new("email")
532            .with_description("Commit author email")
533            .with_default("reddb@localhost"),
534        FlagSchema::new("message")
535            .with_short('m')
536            .with_description("Commit message"),
537        FlagSchema::new("limit")
538            .with_description("Max log entries")
539            .with_default("20"),
540        FlagSchema::boolean("ff-only").with_description("Merge only if fast-forward"),
541        FlagSchema::boolean("no-ff").with_description("Always create a merge commit"),
542    ]
543}
544
545fn service_flags() -> Vec<FlagSchema> {
546    vec![
547        FlagSchema::new("binary")
548            .with_description("Path to the red binary")
549            .with_default("/usr/local/bin/red"),
550        FlagSchema::new("service-name")
551            .with_description("systemd unit name")
552            .with_default("reddb"),
553        FlagSchema::new("user")
554            .with_description("Service user")
555            .with_default("reddb"),
556        FlagSchema::new("group")
557            .with_description("Service group")
558            .with_default("reddb"),
559        FlagSchema::new("path")
560            .with_short('d')
561            .with_description("Persistent database file path")
562            .with_default(reddb_file::DEFAULT_SERVICE_DATABASE_PATH),
563        FlagSchema::new("bind").with_short('b').with_description(
564            "Bind address (host:port) for the routed front-door or legacy single-transport mode",
565        ),
566        FlagSchema::boolean("grpc").with_description("Enable the gRPC API in the service"),
567        FlagSchema::boolean("http").with_description("Install an HTTP service"),
568        FlagSchema::new("grpc-bind").with_description("Explicit gRPC bind address (host:port)"),
569        FlagSchema::new("http-bind").with_description("Explicit HTTP bind address (host:port)"),
570    ]
571}
572
573fn query_flags() -> Vec<FlagSchema> {
574    vec![
575        FlagSchema::new("bind")
576            .with_short('b')
577            .with_description("Server address")
578            .with_default("0.0.0.0:6380"),
579        FlagSchema::new("path").with_description("Open a local .rdb file in embedded mode"),
580        FlagSchema::new("param")
581            .with_short('p')
582            .with_description("Positional parameter for $1, $2, ... (repeatable)"),
583        FlagSchema::new("param-type").with_description("Type override for the preceding --param"),
584        FlagSchema::boolean("dry-run")
585            .with_description("Preview the statement via EXPLAIN without executing it"),
586    ]
587}
588
589fn insert_flags() -> Vec<FlagSchema> {
590    vec![FlagSchema::new("bind")
591        .with_short('b')
592        .with_description("Server address")
593        .with_default("0.0.0.0:6380")]
594}
595
596fn get_flags() -> Vec<FlagSchema> {
597    vec![FlagSchema::new("bind")
598        .with_short('b')
599        .with_description("Server address")
600        .with_default("0.0.0.0:6380")]
601}
602
603fn delete_flags() -> Vec<FlagSchema> {
604    vec![FlagSchema::new("bind")
605        .with_short('b')
606        .with_description("Server address")
607        .with_default("0.0.0.0:6380")]
608}
609
610fn health_flags() -> Vec<FlagSchema> {
611    vec![
612        FlagSchema::new("bind")
613            .with_short('b')
614            .with_description("Server address; defaults by transport"),
615        FlagSchema::boolean("grpc").with_description("Probe a gRPC listener (default transport)"),
616        FlagSchema::boolean("http").with_description("Probe an HTTP listener"),
617    ]
618}
619
620fn bootstrap_flags() -> Vec<FlagSchema> {
621    vec![
622        FlagSchema::new("path")
623            .with_short('d')
624            .with_description("Persistent database file path"),
625        FlagSchema::boolean("vault")
626            .with_description("Required: seal credentials in the encrypted vault"),
627        FlagSchema::new("username")
628            .with_short('u')
629            .with_description("Admin username (defaults to REDDB_USERNAME)"),
630        FlagSchema::new("password")
631            .with_description("Admin password (DEV ONLY; prefer --password-stdin)"),
632        FlagSchema::boolean("password-stdin")
633            .with_description("Read the admin password from stdin (one line)"),
634        FlagSchema::boolean("print-certificate")
635            .with_description("Print only the certificate to stdout"),
636    ]
637}
638
639fn doctor_flags() -> Vec<FlagSchema> {
640    vec![
641        FlagSchema::new("bind")
642            .with_description("HTTP address of the server to probe")
643            .with_default("127.0.0.1:5000"),
644        FlagSchema::new("token")
645            .with_description("Admin bearer token; defaults to RED_ADMIN_TOKEN env"),
646        FlagSchema::boolean("json")
647            .with_description("Emit a single JSON object instead of human text"),
648        FlagSchema::new("backup-age-warn-secs")
649            .with_description("Warn when last successful backup is older than N seconds")
650            .with_default("600"),
651        FlagSchema::new("backup-age-crit-secs")
652            .with_description("Critical when last successful backup is older than N seconds")
653            .with_default("3600"),
654        FlagSchema::new("wal-lag-warn")
655            .with_description("Warn when WAL archive lag exceeds N records")
656            .with_default("1000"),
657        FlagSchema::new("wal-lag-crit")
658            .with_description("Critical when WAL archive lag exceeds N records")
659            .with_default("10000"),
660    ]
661}
662
663fn dump_flags() -> Vec<FlagSchema> {
664    vec![
665        FlagSchema::new("path")
666            .with_description("Local database file to dump from")
667            .with_default("./data/reddb.rdb"),
668        FlagSchema::new("collection")
669            .with_short('c')
670            .with_description("Single collection to dump (omit for all)"),
671        FlagSchema::new("output")
672            .with_short('o')
673            .with_description("Destination file (defaults to stdout)"),
674    ]
675}
676
677fn restore_flags() -> Vec<FlagSchema> {
678    vec![
679        FlagSchema::new("path")
680            .with_description("Local database file to restore into")
681            .with_default("./data/reddb.rdb"),
682        FlagSchema::new("input")
683            .with_short('i')
684            .with_description("Dump file to read (required)"),
685        FlagSchema::new("collection")
686            .with_short('c')
687            .with_description("Override target collection name"),
688    ]
689}
690
691fn pitr_list_flags() -> Vec<FlagSchema> {
692    vec![
693        FlagSchema::new("snapshot-prefix")
694            .with_description("Directory (or remote prefix) holding .snapshot files"),
695        FlagSchema::new("wal-prefix")
696            .with_description("Directory (or remote prefix) holding archived WAL segments"),
697    ]
698}
699
700fn pitr_restore_flags() -> Vec<FlagSchema> {
701    vec![
702        FlagSchema::new("target-time")
703            .with_description("Recovery target — UNIX ms (0 = latest available)"),
704        FlagSchema::new("dest")
705            .with_description("Destination database file path for the restored DB"),
706        FlagSchema::new("snapshot-prefix")
707            .with_description("Directory (or remote prefix) holding .snapshot files"),
708        FlagSchema::new("wal-prefix")
709            .with_description("Directory (or remote prefix) holding archived WAL segments"),
710    ]
711}
712
713fn tick_flags() -> Vec<FlagSchema> {
714    vec![
715        FlagSchema::new("bind")
716            .with_short('b')
717            .with_description("Server HTTP bind address")
718            .with_default("127.0.0.1:5000"),
719        FlagSchema::new("operations")
720            .with_description("Comma-separated operations: maintenance,retention,checkpoint"),
721        FlagSchema::boolean("dry-run")
722            .with_description("Validate operations without applying changes"),
723    ]
724}
725
726fn migrate_from_redis_flags() -> Vec<FlagSchema> {
727    vec![
728        FlagSchema::boolean("dry-run")
729            .with_description("Validate Redis and RedDB connectivity without cache writes"),
730        FlagSchema::new("redis-url")
731            .with_description("Redis URL to validate, for example redis://127.0.0.1:6379/0"),
732        FlagSchema::new("path")
733            .with_short('d')
734            .with_description("Local RedDB .rdb file to open for connectivity validation"),
735        FlagSchema::new("phase")
736            .with_description("Migration phase: dry-run | dual-write")
737            .with_default("dry-run"),
738        FlagSchema::new("namespace")
739            .with_description("Blob Cache namespace recorded in dry-run output")
740            .with_default("redis-migration"),
741    ]
742}
743
744fn migrate_pager_zone_flags() -> Vec<FlagSchema> {
745    vec![FlagSchema::new("path")
746        .with_short('d')
747        .with_description("Closed legacy sidecar-backed .rdb file to convert")]
748}
749
750fn salvage_flags() -> Vec<FlagSchema> {
751    vec![
752        FlagSchema::new("source").with_description("Damaged source .rdb file to read"),
753        FlagSchema::new("destination").with_description("Fresh destination .rdb file to create"),
754    ]
755}
756
757fn status_flags() -> Vec<FlagSchema> {
758    vec![FlagSchema::new("bind")
759        .with_short('b')
760        .with_description("Server address")
761        .with_default("0.0.0.0:6380")]
762}
763
764fn inspect_flags() -> Vec<FlagSchema> {
765    vec![
766        FlagSchema::new("path")
767            .with_short('d')
768            .with_description("Path to the on-disk database file"),
769        FlagSchema::new("at")
770            .with_description("Catalog at snapshot sequence (requires metadata journal)"),
771    ]
772}
773
774fn mcp_flags() -> Vec<FlagSchema> {
775    vec![
776        FlagSchema::new("path")
777            .with_short('d')
778            .with_description("Data directory path (omit for in-memory)")
779            .with_default(""),
780        FlagSchema::new("url")
781            .with_description("Remote or embedded MCP connection URI; overrides REDDB_MCP_URI"),
782        FlagSchema::new("token")
783            .with_description("Bearer token fallback when --url has no userinfo"),
784    ]
785}
786
787fn connect_flags() -> Vec<FlagSchema> {
788    vec![
789        FlagSchema::new("token")
790            .with_short('t')
791            .with_description("Auth token (session or API key)"),
792        FlagSchema::new("query")
793            .with_short('q')
794            .with_description("Execute a single query and exit"),
795        FlagSchema::new("user")
796            .with_short('u')
797            .with_description("Username for login"),
798        FlagSchema::new("password")
799            .with_short('p')
800            .with_description("Password for login"),
801    ]
802}
803
804fn auth_flags() -> Vec<FlagSchema> {
805    vec![
806        FlagSchema::new("bind")
807            .with_short('b')
808            .with_description("Server address")
809            .with_default("0.0.0.0:6380"),
810        FlagSchema::new("password")
811            .with_short('p')
812            .with_description("User password"),
813        FlagSchema::new("role")
814            .with_short('r')
815            .with_description("User role")
816            .with_choices(&["read", "write", "admin"]),
817        FlagSchema::new("name")
818            .with_short('n')
819            .with_description("API key name"),
820        FlagSchema::new("user")
821            .with_short('u')
822            .with_description("Target username"),
823    ]
824}
825
826// ============================================================================
827// Completion data helpers
828// ============================================================================
829
830/// Return domain data for completion scripts.
831pub fn completion_domains() -> Vec<(String, Vec<String>)> {
832    vec![
833        ("server".to_string(), vec![]),
834        ("service".to_string(), vec![]),
835        ("replica".to_string(), vec![]),
836        ("tick".to_string(), vec![]),
837        ("query".to_string(), vec!["q".to_string()]),
838        ("insert".to_string(), vec!["i".to_string()]),
839        ("get".to_string(), vec![]),
840        ("delete".to_string(), vec!["del".to_string()]),
841        ("health".to_string(), vec![]),
842        ("status".to_string(), vec![]),
843        ("inspect".to_string(), vec![]),
844        ("migrate-from-redis".to_string(), vec![]),
845        ("salvage".to_string(), vec![]),
846        ("mcp".to_string(), vec![]),
847        ("auth".to_string(), vec![]),
848        ("connect".to_string(), vec![]),
849        ("version".to_string(), vec![]),
850    ]
851}
852
853/// Return global flag data for completion scripts.
854pub fn completion_global_flags() -> Vec<(&'static str, Option<char>)> {
855    vec![
856        ("help", Some('h')),
857        ("json", Some('j')),
858        ("output", Some('o')),
859        ("verbose", Some('v')),
860        ("no-color", None),
861        ("version", None),
862    ]
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    #[test]
870    fn test_all_commands_defined() {
871        let cmds = all_commands();
872        let names: Vec<&str> = cmds.iter().map(|c| c.name).collect();
873        assert!(names.contains(&"server"));
874        assert!(names.contains(&"query"));
875        assert!(names.contains(&"insert"));
876        assert!(names.contains(&"get"));
877        assert!(names.contains(&"delete"));
878        assert!(names.contains(&"health"));
879        assert!(names.contains(&"tick"));
880        assert!(names.contains(&"migrate-from-redis"));
881        assert!(names.contains(&"salvage"));
882        assert!(names.contains(&"status"));
883        assert!(names.contains(&"inspect"));
884        assert!(names.contains(&"connect"));
885        assert!(names.contains(&"version"));
886    }
887
888    #[test]
889    fn test_inspect_has_flags() {
890        let cmds = all_commands();
891        let inspect = cmds.iter().find(|c| c.name == "inspect").unwrap();
892        let flag_names: Vec<&str> = inspect.flags.iter().map(|f| f.long.as_str()).collect();
893        assert!(flag_names.contains(&"path"));
894        assert!(flag_names.contains(&"at"));
895    }
896
897    #[test]
898    fn test_server_has_flags() {
899        let cmds = all_commands();
900        let server = cmds.iter().find(|c| c.name == "server").unwrap();
901        let flag_names: Vec<&str> = server.flags.iter().map(|f| f.long.as_str()).collect();
902        assert!(flag_names.contains(&"path"));
903        assert!(flag_names.contains(&"bind"));
904        // Slice 5 of issue #574 — HTTP handler-pool knobs.
905        assert!(flag_names.contains(&"http-max-handlers"));
906        assert!(flag_names.contains(&"http-handler-timeout-ms"));
907        assert!(flag_names.contains(&"http-retry-after-secs"));
908    }
909
910    #[test]
911    fn test_server_help_text_lists_http_limit_flags() {
912        let help = command_help_text("server").unwrap();
913        assert!(help.contains("--http-max-handlers"));
914        assert!(help.contains("--http-handler-timeout-ms"));
915        assert!(help.contains("--http-retry-after-secs"));
916        assert!(help.contains("REDDB_HTTP_MAX_HANDLERS"));
917    }
918
919    #[test]
920    fn test_replica_has_flags() {
921        let cmds = all_commands();
922        let replica = cmds.iter().find(|c| c.name == "replica").unwrap();
923        let flag_names: Vec<&str> = replica.flags.iter().map(|f| f.long.as_str()).collect();
924        assert!(flag_names.contains(&"primary-addr"));
925        assert!(flag_names.contains(&"path"));
926        assert!(flag_names.contains(&"bind"));
927    }
928
929    #[test]
930    fn test_main_help_text() {
931        let help = main_help_text();
932        assert!(help.contains("reddb"));
933        assert!(help.contains("Usage: red"));
934        assert!(help.contains("Commands:"));
935        assert!(help.contains("server"));
936        assert!(help.contains("query"));
937        assert!(help.contains("Global flags:"));
938        assert!(help.contains("--help"));
939        assert!(help.contains("Examples:"));
940    }
941
942    #[test]
943    fn test_command_help_text() {
944        let help = command_help_text("server").unwrap();
945        assert!(help.contains("red server"));
946        assert!(help.contains("--path"));
947        assert!(help.contains("--bind"));
948    }
949
950    #[test]
951    fn test_replica_command_help() {
952        let help = command_help_text("replica").unwrap();
953        assert!(help.contains("red replica"));
954        assert!(help.contains("--primary-addr"));
955    }
956
957    #[test]
958    fn test_migrate_from_redis_command_help() {
959        let help = command_help_text("migrate-from-redis").unwrap();
960        assert!(help.contains("red migrate-from-redis"));
961        assert!(help.contains("--dry-run"));
962        assert!(help.contains("--redis-url"));
963        assert!(help.contains("application-owned helper"));
964    }
965
966    #[test]
967    fn test_migrate_pager_zone_command_help() {
968        let help = command_help_text("migrate-pager-zone").unwrap();
969        assert!(help.contains("red migrate-pager-zone"));
970        assert!(help.contains("--path"));
971        assert!(help.contains("legacy sidecar-backed"));
972    }
973
974    #[test]
975    fn test_command_help_text_unknown() {
976        assert!(command_help_text("nonexistent").is_none());
977    }
978
979    #[test]
980    fn test_flag_builder() {
981        let flag = Flag::new("output", "Output format")
982            .with_short('o')
983            .with_default("text")
984            .with_arg("FORMAT");
985
986        assert_eq!(flag.long, "output");
987        assert_eq!(flag.short, Some('o'));
988        assert_eq!(flag.description, "Output format");
989        assert_eq!(flag.default, Some("text".to_string()));
990        assert_eq!(flag.arg, Some("FORMAT".to_string()));
991    }
992
993    #[test]
994    fn test_completion_domains() {
995        let domains = completion_domains();
996        let names: Vec<&str> = domains.iter().map(|(n, _)| n.as_str()).collect();
997        assert!(names.contains(&"server"));
998        assert!(names.contains(&"query"));
999        assert!(names.contains(&"health"));
1000    }
1001
1002    #[test]
1003    fn test_completion_global_flags() {
1004        let flags = completion_global_flags();
1005        assert!(flags.contains(&("help", Some('h'))));
1006        assert!(flags.contains(&("json", Some('j'))));
1007        assert!(flags.contains(&("verbose", Some('v'))));
1008        assert!(flags.contains(&("no-color", None)));
1009    }
1010}