Skip to main content

stopgap_cli/
lib.rs

1use std::{fmt, fs, io::Write, path::Path};
2
3use anyhow::{Context, Result};
4use clap::{Parser, ValueEnum};
5use postgres::{Client, NoTls, Row};
6use regex::Regex;
7use serde_json::{Value, json};
8
9pub const EXIT_DB_CONNECT: u8 = 10;
10pub const EXIT_DB_QUERY: u8 = 11;
11pub const EXIT_RESPONSE_DECODE: u8 = 12;
12pub const EXIT_OUTPUT_FORMAT: u8 = 13;
13pub const EXIT_PROJECT_LAYOUT: u8 = 14;
14
15#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
16pub struct StopgapExport {
17    pub module_path: String,
18    pub export_name: String,
19    pub function_path: String,
20    pub kind: String,
21}
22
23#[derive(Debug, Clone, Copy, ValueEnum)]
24pub enum OutputMode {
25    Human,
26    Json,
27}
28
29impl fmt::Display for OutputMode {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Human => write!(f, "human"),
33            Self::Json => write!(f, "json"),
34        }
35    }
36}
37
38#[derive(Debug, Parser)]
39#[command(name = "stopgap", version, about = "Stopgap deployment CLI")]
40pub struct Cli {
41    #[arg(long, env = "STOPGAP_DB")]
42    pub db: String,
43
44    #[arg(long, value_enum, default_value_t = OutputMode::Human)]
45    pub output: OutputMode,
46
47    #[command(subcommand)]
48    pub command: Command,
49}
50
51#[derive(Debug, clap::Subcommand)]
52pub enum Command {
53    Deploy {
54        #[arg(long, default_value = "prod")]
55        env: String,
56        #[arg(long = "from-schema")]
57        from_schema: String,
58        #[arg(long)]
59        label: Option<String>,
60        #[arg(long)]
61        prune: bool,
62    },
63    Rollback {
64        #[arg(long, default_value = "prod")]
65        env: String,
66        #[arg(long, default_value_t = 1)]
67        steps: i32,
68        #[arg(long = "to")]
69        to_id: Option<i64>,
70    },
71    Status {
72        #[arg(long, default_value = "prod")]
73        env: String,
74    },
75    Deployments {
76        #[arg(long, default_value = "prod")]
77        env: String,
78    },
79    Diff {
80        #[arg(long, default_value = "prod")]
81        env: String,
82        #[arg(long = "from-schema")]
83        from_schema: String,
84    },
85}
86
87#[derive(Debug)]
88pub enum AppError {
89    DbConnect(anyhow::Error),
90    DbQuery(anyhow::Error),
91    Decode(anyhow::Error),
92    Print(anyhow::Error),
93    ProjectLayout(anyhow::Error),
94}
95
96impl AppError {
97    pub fn code(&self) -> u8 {
98        match self {
99            Self::DbConnect(_) => EXIT_DB_CONNECT,
100            Self::DbQuery(_) => EXIT_DB_QUERY,
101            Self::Decode(_) => EXIT_RESPONSE_DECODE,
102            Self::Print(_) => EXIT_OUTPUT_FORMAT,
103            Self::ProjectLayout(_) => EXIT_PROJECT_LAYOUT,
104        }
105    }
106}
107
108impl fmt::Display for AppError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            Self::DbConnect(err) => write!(f, "database connection failed: {err:#}"),
112            Self::DbQuery(err) => write!(f, "database command failed: {err:#}"),
113            Self::Decode(err) => write!(f, "invalid database response: {err:#}"),
114            Self::Print(err) => write!(f, "failed to print output: {err:#}"),
115            Self::ProjectLayout(err) => write!(f, "project layout check failed: {err:#}"),
116        }
117    }
118}
119
120pub trait StopgapApi {
121    fn deploy(
122        &mut self,
123        env: &str,
124        from_schema: &str,
125        label: Option<&str>,
126        prune: bool,
127        deploy_exports_json: Option<&str>,
128    ) -> Result<i64>;
129
130    fn rollback(&mut self, env: &str, steps: i32, to_id: Option<i64>) -> Result<i64>;
131
132    fn status(&mut self, env: &str) -> Result<Option<Value>>;
133
134    fn deployments(&mut self, env: &str) -> Result<Value>;
135
136    fn diff(&mut self, env: &str, from_schema: &str) -> Result<Value>;
137}
138
139pub struct PgStopgapApi {
140    client: Client,
141}
142
143impl PgStopgapApi {
144    pub fn connect(db: &str) -> std::result::Result<Self, AppError> {
145        let client = Client::connect(db, NoTls).map_err(|err| AppError::DbConnect(err.into()))?;
146        Ok(Self { client })
147    }
148}
149
150impl StopgapApi for PgStopgapApi {
151    fn deploy(
152        &mut self,
153        env: &str,
154        from_schema: &str,
155        label: Option<&str>,
156        prune: bool,
157        deploy_exports_json: Option<&str>,
158    ) -> Result<i64> {
159        let mut tx = self.client.build_transaction().start()?;
160        let prune_setting = if prune { "on" } else { "off" };
161        tx.batch_execute(&format!("SET LOCAL stopgap.prune = '{prune_setting}'"))?;
162        if let Some(raw_exports) = deploy_exports_json {
163            tx.execute("SELECT set_config('stopgap.deploy_exports', $1, true)", &[&raw_exports])?;
164        }
165        let row = tx.query_one(
166            "SELECT stopgap.deploy($1, $2, $3) AS deployment_id",
167            &[&env, &from_schema, &label],
168        )?;
169        tx.commit()?;
170        Ok(row.get("deployment_id"))
171    }
172
173    fn rollback(&mut self, env: &str, steps: i32, to_id: Option<i64>) -> Result<i64> {
174        let row = self.client.query_one(
175            "SELECT stopgap.rollback($1, $2, $3) AS deployment_id",
176            &[&env, &steps, &to_id],
177        )?;
178        Ok(row.get("deployment_id"))
179    }
180
181    fn status(&mut self, env: &str) -> Result<Option<Value>> {
182        let row = self.client.query_one("SELECT stopgap.status($1) AS status", &[&env])?;
183        read_json_column(&row, "status")
184    }
185
186    fn deployments(&mut self, env: &str) -> Result<Value> {
187        let row =
188            self.client.query_one("SELECT stopgap.deployments($1) AS deployments", &[&env])?;
189        read_required_json_column(&row, "deployments")
190    }
191
192    fn diff(&mut self, env: &str, from_schema: &str) -> Result<Value> {
193        let row =
194            self.client.query_one("SELECT stopgap.diff($1, $2) AS diff", &[&env, &from_schema])?;
195        read_required_json_column(&row, "diff")
196    }
197}
198
199pub fn run(cli: Cli, writer: &mut dyn Write) -> std::result::Result<(), AppError> {
200    let mut api = PgStopgapApi::connect(&cli.db)?;
201    execute_command(cli.command, cli.output, &mut api, writer)
202}
203
204pub fn execute_command(
205    command: Command,
206    output: OutputMode,
207    api: &mut dyn StopgapApi,
208    writer: &mut dyn Write,
209) -> std::result::Result<(), AppError> {
210    let project_root =
211        std::env::current_dir().map_err(|err| AppError::ProjectLayout(err.into()))?;
212    execute_command_with_project_root(command, output, api, writer, &project_root)
213}
214
215pub fn execute_command_with_project_root(
216    command: Command,
217    output: OutputMode,
218    api: &mut dyn StopgapApi,
219    writer: &mut dyn Write,
220    project_root: &Path,
221) -> std::result::Result<(), AppError> {
222    match command {
223        Command::Deploy { env, from_schema, label, prune } => {
224            let exports =
225                discover_stopgap_exports(project_root).map_err(AppError::ProjectLayout)?;
226            let mut module_paths =
227                exports.iter().map(|item| item.module_path.clone()).collect::<Vec<_>>();
228            module_paths.sort();
229            module_paths.dedup();
230            let function_paths =
231                exports.iter().map(|item| item.function_path.clone()).collect::<Vec<_>>();
232            let deploy_exports_json = serde_json::to_string(&exports)
233                .map_err(|err| AppError::ProjectLayout(err.into()))?;
234            let deployment_id = api
235                .deploy(
236                    &env,
237                    &from_schema,
238                    label.as_deref(),
239                    prune,
240                    Some(deploy_exports_json.as_str()),
241                )
242                .map_err(AppError::DbQuery)?;
243            let payload = json!({
244                "command": "deploy",
245                "env": env,
246                "from_schema": from_schema,
247                "source_root": "stopgap",
248                "module_count": module_paths.len(),
249                "module_paths": module_paths,
250                "function_count": exports.len(),
251                "function_paths": function_paths,
252                "deployment_id": deployment_id,
253                "prune": prune,
254            });
255            print_payload(output, payload, writer, || {
256                format!(
257                    "deployed env={} from_schema={} deployment_id={} prune={} module_count={} function_count={}",
258                    env,
259                    from_schema,
260                    deployment_id,
261                    prune,
262                    module_paths.len(),
263                    exports.len()
264                )
265            })
266        }
267        Command::Rollback { env, steps, to_id } => {
268            let deployment_id = api.rollback(&env, steps, to_id).map_err(AppError::DbQuery)?;
269            let payload = json!({
270                "command": "rollback",
271                "env": env,
272                "steps": steps,
273                "to_id": to_id,
274                "deployment_id": deployment_id,
275            });
276            print_payload(output, payload, writer, || {
277                format!(
278                    "rolled back env={} target_deployment_id={} steps={}{}",
279                    env,
280                    deployment_id,
281                    steps,
282                    to_id.map(|value| format!(" to_id={value}")).unwrap_or_default()
283                )
284            })
285        }
286        Command::Status { env } => {
287            let status = api.status(&env).map_err(AppError::DbQuery)?;
288            let payload = json!({
289                "command": "status",
290                "env": env,
291                "status": status,
292            });
293            print_payload(output, payload, writer, || {
294                status
295                    .as_ref()
296                    .map(|value| format!("status env={} {}", env, compact_json(value)))
297                    .unwrap_or_else(|| format!("status env={} none", env))
298            })
299        }
300        Command::Deployments { env } => {
301            let deployments = api.deployments(&env).map_err(AppError::DbQuery)?;
302            let count = deployments.as_array().map(|entries| entries.len()).unwrap_or(0);
303            let payload = json!({
304                "command": "deployments",
305                "env": env,
306                "count": count,
307                "deployments": deployments,
308            });
309            print_payload(output, payload, writer, || {
310                format!("deployments env={} count={}", env, count)
311            })
312        }
313        Command::Diff { env, from_schema } => {
314            let diff = api.diff(&env, &from_schema).map_err(AppError::DbQuery)?;
315            let payload = json!({
316                "command": "diff",
317                "env": env,
318                "from_schema": from_schema,
319                "diff": diff,
320            });
321            print_payload(output, payload, writer, || {
322                format!("diff env={} from_schema={}", env, from_schema)
323            })
324        }
325    }
326}
327
328pub fn discover_stopgap_modules(project_root: &Path) -> Result<Vec<String>> {
329    let exports = discover_stopgap_exports(project_root)?;
330    let mut modules = exports.into_iter().map(|item| item.module_path).collect::<Vec<_>>();
331    modules.sort();
332    modules.dedup();
333    Ok(modules)
334}
335
336pub fn discover_stopgap_exports(project_root: &Path) -> Result<Vec<StopgapExport>> {
337    let source_root = project_root.join("stopgap");
338    if !source_root.is_dir() {
339        anyhow::bail!(
340            "project not initialized: expected `stopgap/` directory at {}",
341            source_root.display()
342        );
343    }
344
345    let mut exports = Vec::new();
346    collect_stopgap_exports(&source_root, &source_root, &mut exports)?;
347    if exports.is_empty() {
348        anyhow::bail!(
349            "no deployable stopgap exports found under {}; expected `export const <name> = query(...)` or `mutation(...)`",
350            source_root.display()
351        );
352    }
353    exports.sort_by(|left, right| left.function_path.cmp(&right.function_path));
354    exports.dedup_by(|left, right| left.function_path == right.function_path);
355    Ok(exports)
356}
357
358fn collect_stopgap_exports(
359    root: &Path,
360    cursor: &Path,
361    exports: &mut Vec<StopgapExport>,
362) -> Result<()> {
363    let mut entries = Vec::new();
364    for entry in fs::read_dir(cursor)
365        .with_context(|| format!("failed to read stopgap source directory {}", cursor.display()))?
366    {
367        entries.push(entry?.path());
368    }
369    entries.sort();
370
371    for path in entries {
372        if path.is_dir() {
373            collect_stopgap_exports(root, &path, exports)?;
374            continue;
375        }
376
377        if !is_deployable_ts_module(&path) {
378            continue;
379        }
380
381        let module_path = normalize_module_path(root, &path)?;
382        let source = fs::read_to_string(&path)
383            .with_context(|| format!("failed to read stopgap module {}", path.display()))?;
384        let module_exports = parse_wrapped_exports(&source, &module_path, &path)?;
385        exports.extend(module_exports);
386    }
387
388    Ok(())
389}
390
391fn parse_wrapped_exports(
392    source: &str,
393    module_path: &str,
394    file_path: &Path,
395) -> Result<Vec<StopgapExport>> {
396    let wrapped_export_re = Regex::new(
397        r"(?m)^\s*export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*\.\s*)?(query|mutation)\s*(?:<[^\n>]*>)?\s*\(",
398    )
399    .expect("wrapped export regex should be valid");
400    let named_const_export_re =
401        Regex::new(r"(?m)^\s*export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=")
402            .expect("named export regex should be valid");
403
404    let mut wrapped_names = std::collections::BTreeSet::new();
405    let mut exports = Vec::new();
406    for capture in wrapped_export_re.captures_iter(source) {
407        let export_name = capture
408            .get(1)
409            .map(|value| value.as_str().to_string())
410            .expect("wrapped export regex always captures export name");
411        let kind = capture
412            .get(2)
413            .map(|value| value.as_str().to_string())
414            .expect("wrapped export regex always captures wrapper kind");
415        let function_path = format!("{module_path}.{export_name}");
416        wrapped_names.insert(export_name.clone());
417        exports.push(StopgapExport {
418            module_path: module_path.to_string(),
419            export_name,
420            function_path,
421            kind,
422        });
423    }
424
425    let non_wrapped_exports = named_const_export_re
426        .captures_iter(source)
427        .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string()))
428        .filter(|name| !wrapped_names.contains(name))
429        .collect::<Vec<_>>();
430
431    if !non_wrapped_exports.is_empty() {
432        anyhow::bail!(
433            "module {} exports non-wrapper symbols ({}) ; wrap exported handlers with query(...) or mutation(...)",
434            file_path.display(),
435            non_wrapped_exports.join(", ")
436        );
437    }
438
439    Ok(exports)
440}
441
442fn is_deployable_ts_module(path: &Path) -> bool {
443    let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
444        return false;
445    };
446
447    file_name.ends_with(".ts") && !file_name.ends_with(".d.ts")
448}
449
450fn normalize_module_path(source_root: &Path, module_path: &Path) -> Result<String> {
451    let relative = module_path.strip_prefix(source_root).with_context(|| {
452        format!("module path {} is not under stopgap root", module_path.display())
453    })?;
454
455    let mut segments = Vec::new();
456    for component in relative.components() {
457        let raw = component.as_os_str().to_str().with_context(|| {
458            format!("module path contains non-utf8 component: {}", module_path.display())
459        })?;
460
461        if raw.is_empty() {
462            continue;
463        }
464
465        if raw.ends_with(".ts") {
466            segments.push(raw.trim_end_matches(".ts").to_string());
467        } else {
468            segments.push(raw.to_string());
469        }
470    }
471
472    if segments.is_empty() {
473        anyhow::bail!("module path {} does not resolve to api namespace", module_path.display());
474    }
475
476    Ok(format!("api.{}", segments.join(".")))
477}
478
479fn print_payload<F>(
480    output: OutputMode,
481    payload: Value,
482    writer: &mut dyn Write,
483    human_builder: F,
484) -> std::result::Result<(), AppError>
485where
486    F: FnOnce() -> String,
487{
488    let rendered = match output {
489        OutputMode::Human => human_builder(),
490        OutputMode::Json => {
491            serde_json::to_string_pretty(&payload).map_err(|err| AppError::Print(err.into()))?
492        }
493    };
494    writeln!(writer, "{rendered}").map_err(|err| AppError::Print(err.into()))
495}
496
497fn read_json_column(row: &Row, column: &str) -> Result<Option<Value>> {
498    row.try_get(column).with_context(|| format!("column `{column}` is not valid jsonb"))
499}
500
501fn read_required_json_column(row: &Row, column: &str) -> Result<Value> {
502    read_json_column(row, column)?.with_context(|| format!("column `{column}` unexpectedly null"))
503}
504
505pub fn compact_json(value: &Value) -> String {
506    serde_json::to_string(value).unwrap_or_else(|_| "{\"error\":\"json-encode-failed\"}".into())
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use clap::CommandFactory;
513
514    #[test]
515    fn cli_exposes_expected_subcommands() {
516        let command = Cli::command();
517        let names: Vec<_> =
518            command.get_subcommands().map(|subcommand| subcommand.get_name().to_string()).collect();
519        assert_eq!(names, vec!["deploy", "rollback", "status", "deployments", "diff"]);
520    }
521
522    #[test]
523    fn compact_json_handles_objects() {
524        let rendered = compact_json(&json!({"key": "value"}));
525        assert_eq!(rendered, "{\"key\":\"value\"}");
526    }
527
528    #[test]
529    fn exit_codes_are_stable() {
530        assert_eq!(EXIT_DB_CONNECT, 10);
531        assert_eq!(EXIT_DB_QUERY, 11);
532        assert_eq!(EXIT_RESPONSE_DECODE, 12);
533        assert_eq!(EXIT_OUTPUT_FORMAT, 13);
534        assert_eq!(EXIT_PROJECT_LAYOUT, 14);
535    }
536
537    #[test]
538    fn parse_wrapped_exports_finds_query_and_mutation_handlers() {
539        let source = r#"
540            export const listUsers = query(v.object({}), async () => []);
541            export const createUser = mutation(v.object({}), async () => ({ ok: true }));
542        "#;
543
544        let exports = parse_wrapped_exports(source, "api.users", Path::new("stopgap/users.ts"))
545            .expect("wrapper exports should parse");
546        assert_eq!(exports.len(), 2);
547        assert_eq!(exports[0].function_path, "api.users.listUsers");
548        assert_eq!(exports[0].kind, "query");
549        assert_eq!(exports[1].function_path, "api.users.createUser");
550        assert_eq!(exports[1].kind, "mutation");
551    }
552
553    #[test]
554    fn parse_wrapped_exports_rejects_non_wrapper_named_exports() {
555        let source = r#"
556            export const helper = 1;
557            export const listUsers = query(v.object({}), async () => []);
558        "#;
559
560        let error = parse_wrapped_exports(source, "api.users", Path::new("stopgap/users.ts"))
561            .expect_err("non-wrapper named exports should fail preflight");
562        assert!(error.to_string().contains("exports non-wrapper symbols"));
563        assert!(error.to_string().contains("helper"));
564    }
565}