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}