Skip to main content

stackless_stripe_projects/
stripe.rs

1//! The Stripe Projects CLI driver (ARCHITECTURE.md §4).
2//!
3//! Drives the `stripe projects` plugin non-interactively. The driver is
4//! generic over a [`CommandRunner`] so tests inject canned CLI envelopes.
5
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use async_trait::async_trait;
10use serde::Deserialize;
11
12use crate::error::ProjectsError;
13
14const STRIPE_LOCK_BUDGET: Duration = Duration::from_secs(30 * 60);
15
16#[derive(Debug, Clone)]
17pub struct CommandOutput {
18    pub status: i32,
19    pub stdout: String,
20    pub stderr: String,
21}
22
23#[async_trait]
24pub trait CommandRunner: Send + Sync {
25    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError>;
26}
27
28#[async_trait]
29impl<T: CommandRunner + ?Sized> CommandRunner for &T {
30    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
31        (**self).run(args, cwd).await
32    }
33}
34
35#[derive(Debug, Default)]
36pub struct TokioRunner;
37
38#[async_trait]
39impl CommandRunner for TokioRunner {
40    async fn run(&self, args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
41        let args = args.to_vec();
42        let cwd: PathBuf = cwd.to_path_buf();
43        tokio::task::spawn_blocking(move || run_stripe_locked(&args, &cwd))
44            .await
45            .map_err(|err| ProjectsError::Unavailable {
46                detail: format!("stripe task panicked: {err}"),
47            })?
48    }
49}
50
51fn run_stripe_locked(args: &[String], cwd: &Path) -> Result<CommandOutput, ProjectsError> {
52    let lock_path = stackless_core::lockfile::FileLock::stripe_lock_path(cwd);
53    let _guard =
54        stackless_core::lockfile::FileLock::acquire_with_wait(&lock_path, STRIPE_LOCK_BUDGET)
55            .map_err(|err| ProjectsError::LockHeld {
56                definition_dir: cwd.display().to_string(),
57                detail: err.to_string(),
58            })?;
59    let output = std::process::Command::new("stripe")
60        .arg("projects")
61        .args(args)
62        .current_dir(cwd)
63        .output()
64        .map_err(|err| ProjectsError::Unavailable {
65            detail: format!("could not run `stripe`: {err}"),
66        })?;
67    Ok(CommandOutput {
68        status: output.status.code().unwrap_or(-1),
69        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
70        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
71    })
72}
73
74#[derive(Debug, Deserialize)]
75struct Envelope {
76    ok: bool,
77    #[serde(default)]
78    error: Option<EnvelopeError>,
79    #[serde(default)]
80    data: Option<serde_json::Value>,
81    #[serde(default)]
82    meta: Option<EnvelopeMeta>,
83}
84
85#[derive(Debug, Deserialize)]
86struct EnvelopeError {
87    #[serde(default)]
88    code: Option<String>,
89    #[serde(default)]
90    message: Option<String>,
91    #[serde(default)]
92    details: Option<serde_json::Value>,
93}
94
95#[derive(Debug, Deserialize)]
96struct EnvelopeMeta {
97    #[serde(default)]
98    authenticated: Option<bool>,
99}
100
101#[derive(Debug, Clone)]
102pub struct StripeResult {
103    pub ok: bool,
104    pub error_code: Option<String>,
105    pub error_message: Option<String>,
106    pub error_details: Option<serde_json::Value>,
107    pub authenticated: bool,
108    pub data: serde_json::Value,
109}
110
111const PLAIN_FALLBACK_CODES: &[&str] = &[
112    "JSON_REQUIRES_CONFIRMATION",
113    "JSON_REQUIRES_AUTH",
114    "DIRECTORY_SELECTION_REQUIRED",
115];
116
117pub struct StripeProjects<R: CommandRunner> {
118    runner: R,
119    dir: PathBuf,
120}
121
122impl<R: CommandRunner> std::fmt::Debug for StripeProjects<R> {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("StripeProjects")
125            .field("dir", &self.dir)
126            .finish_non_exhaustive()
127    }
128}
129
130impl<R: CommandRunner> StripeProjects<R> {
131    pub fn new(runner: R, dir: impl Into<PathBuf>) -> Self {
132        Self {
133            runner,
134            dir: dir.into(),
135        }
136    }
137
138    pub fn dir(&self) -> &Path {
139        &self.dir
140    }
141
142    /// Borrow as a `StripeProjects<&dyn CommandRunner>` so callers holding a
143    /// `dyn` dispatch target can run commands without being generic over `R`
144    /// (`impl CommandRunner for &T` makes the erased runner a valid runner).
145    pub fn as_dyn(&self) -> StripeProjects<&'_ dyn CommandRunner> {
146        StripeProjects {
147            runner: &self.runner as &dyn CommandRunner,
148            dir: self.dir.clone(),
149        }
150    }
151
152    #[cfg(test)]
153    fn runner(&self) -> &R {
154        &self.runner
155    }
156
157    pub async fn json(&self, args: &[&str]) -> Result<StripeResult, ProjectsError> {
158        let mut argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
159        argv.push("--json".into());
160        let out = self.runner.run(&argv, &self.dir).await?;
161        let Some(start) = out.stdout.find('{') else {
162            let stderr = out.stderr.trim();
163            return Err(ProjectsError::Unavailable {
164                detail: format!(
165                    "`stripe projects {}` produced no JSON output{}",
166                    args.join(" "),
167                    if stderr.is_empty() {
168                        String::new()
169                    } else {
170                        format!(" (stderr: {stderr})")
171                    }
172                ),
173            });
174        };
175        let envelope: Envelope = serde_json::from_str(&out.stdout[start..]).map_err(|err| {
176            ProjectsError::Unavailable {
177                detail: format!(
178                    "`stripe projects {}` emitted malformed JSON: {err}",
179                    args.join(" ")
180                ),
181            }
182        })?;
183        Ok(StripeResult {
184            ok: envelope.ok,
185            error_code: envelope.error.as_ref().and_then(|e| e.code.clone()),
186            error_message: envelope.error.as_ref().and_then(|e| e.message.clone()),
187            error_details: envelope.error.as_ref().and_then(|e| e.details.clone()),
188            authenticated: envelope
189                .meta
190                .as_ref()
191                .and_then(|m| m.authenticated)
192                .unwrap_or(true),
193            data: envelope.data.unwrap_or(serde_json::Value::Null),
194        })
195    }
196
197    /// Fetch and type the provider catalog (`stripe projects catalog --json`).
198    pub async fn catalog(&self) -> Result<crate::catalog::Catalog, ProjectsError> {
199        let data = self.run_ok("catalog", &["catalog"], &[]).await?;
200        serde_json::from_value(data).map_err(|err| ProjectsError::Unavailable {
201            detail: format!("`stripe projects catalog` returned an unmodeled catalog: {err}"),
202        })
203    }
204
205    pub async fn plain(&self, args: &[&str]) -> Result<CommandOutput, ProjectsError> {
206        let argv: Vec<String> = args.iter().map(|a| (*a).to_owned()).collect();
207        self.runner.run(&argv, &self.dir).await
208    }
209
210    pub fn classify_failure(&self, command: &str, result: &StripeResult) -> ProjectsError {
211        let message = result
212            .error_message
213            .clone()
214            .unwrap_or_else(|| "unknown error".into());
215        let code = result.error_code.as_deref().unwrap_or("");
216        let auth_like = !result.authenticated
217            || code == "JSON_REQUIRES_AUTH"
218            || message.to_ascii_lowercase().contains("not authenticated")
219            || message.to_ascii_lowercase().contains("log in");
220        if auth_like {
221            ProjectsError::Auth { detail: message }
222        } else {
223            ProjectsError::Failed {
224                command: command.to_owned(),
225                detail: format!(
226                    "{message}{}",
227                    if code.is_empty() {
228                        String::new()
229                    } else {
230                        format!(" ({code})")
231                    }
232                ),
233            }
234        }
235    }
236
237    pub async fn run_ok(
238        &self,
239        command: &str,
240        args: &[&str],
241        plain_extra: &[&str],
242    ) -> Result<serde_json::Value, ProjectsError> {
243        let result = self.json(args).await?;
244        if result.ok {
245            return Ok(result.data);
246        }
247        let code = result.error_code.as_deref().unwrap_or("");
248        let message = result.error_message.clone().unwrap_or_default();
249        let live_mode = message.to_ascii_lowercase().contains("live mode");
250        if PLAIN_FALLBACK_CODES.contains(&code) || live_mode {
251            let mut plain_args: Vec<&str> = args.to_vec();
252            plain_args.extend_from_slice(plain_extra);
253            let out = self.plain(&plain_args).await?;
254            if out.status != 0 || out.stdout.contains('✗') || out.stderr.contains('✗') {
255                return Err(ProjectsError::Failed {
256                    command: command.to_owned(),
257                    detail: merge_output(&out),
258                });
259            }
260            return Ok(serde_json::Value::Null);
261        }
262        Err(self.classify_failure(command, &result))
263    }
264}
265
266fn merge_output(out: &CommandOutput) -> String {
267    let merged = format!("{}{}", out.stdout.trim(), out.stderr.trim());
268    merged.trim().to_owned()
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use stackless_core::fault::{Fault, codes};
275    use std::sync::Mutex;
276
277    struct ScriptRunner {
278        outputs: Mutex<std::collections::VecDeque<CommandOutput>>,
279        calls: Mutex<Vec<Vec<String>>>,
280    }
281
282    impl ScriptRunner {
283        fn new(outputs: Vec<CommandOutput>) -> Self {
284            Self {
285                outputs: Mutex::new(outputs.into()),
286                calls: Mutex::new(Vec::new()),
287            }
288        }
289
290        fn calls(&self) -> Vec<Vec<String>> {
291            self.calls.lock().unwrap().clone()
292        }
293    }
294
295    #[async_trait]
296    impl CommandRunner for ScriptRunner {
297        async fn run(&self, args: &[String], _cwd: &Path) -> Result<CommandOutput, ProjectsError> {
298            self.calls.lock().unwrap().push(args.to_vec());
299            self.outputs
300                .lock()
301                .unwrap()
302                .pop_front()
303                .ok_or_else(|| ProjectsError::Unavailable {
304                    detail: "ScriptRunner exhausted".into(),
305                })
306        }
307    }
308
309    fn out(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
310        CommandOutput {
311            status,
312            stdout: stdout.to_owned(),
313            stderr: stderr.to_owned(),
314        }
315    }
316
317    fn driver(outputs: Vec<CommandOutput>) -> StripeProjects<ScriptRunner> {
318        StripeProjects::new(ScriptRunner::new(outputs), std::env::temp_dir())
319    }
320
321    #[tokio::test]
322    async fn parses_ok_envelope() {
323        let d = driver(vec![out(
324            0,
325            r#"{"ok":true,"command":"status","version":"0.19.0","data":{"project":{"id":"proj_1"}}}"#,
326            "",
327        )]);
328        let result = d.json(&["status"]).await.unwrap();
329        assert!(result.ok);
330        assert_eq!(result.data["project"]["id"], "proj_1");
331    }
332
333    #[tokio::test]
334    async fn no_json_is_unavailable() {
335        let d = driver(vec![out(127, "", "command not found: stripe")]);
336        let err = d.json(&["status"]).await.unwrap_err();
337        assert_eq!(err.code(), codes::STRIPE_PROJECTS_UNAVAILABLE);
338    }
339
340    #[tokio::test]
341    async fn unauthenticated_envelope_is_auth_fault() {
342        let d = driver(vec![out(
343            0,
344            r#"{"ok":false,"error":{"code":"SOMETHING","message":"please log in"},"meta":{"authenticated":false}}"#,
345            "",
346        )]);
347        let err = d.run_ok("status", &["status"], &[]).await.unwrap_err();
348        assert_eq!(err.code(), codes::STRIPE_PROJECTS_AUTH);
349    }
350
351    /// Opt-in live check (`STRIPE_CATALOG_LIVE=1`): run the real
352    /// `stripe projects catalog --json` and assert the typed model still fully
353    /// covers it. No-op in CI; the canonical way to catch a stale fixture.
354    #[tokio::test]
355    async fn live_catalog_matches_model() {
356        if std::env::var("STRIPE_CATALOG_LIVE").as_deref() != Ok("1") {
357            return;
358        }
359        let dir = std::env::current_dir().unwrap();
360        let stripe = StripeProjects::new(TokioRunner, dir);
361        let catalog = stripe.catalog().await.expect("live catalog should fetch");
362        let report = catalog.drift_report();
363        assert!(
364            report.is_empty(),
365            "LIVE catalog drift — refresh tests/fixtures/catalog.json and update the model:\n{}",
366            report.join("\n")
367        );
368    }
369
370    /// The per-catalog-state content version (`data.last_updated`) is stable
371    /// across requests but bumps whenever Stripe republishes the catalog —
372    /// noise unrelated to a real service/schema change. Normalize it so the
373    /// committed `catalog.json` only diffs on content we actually model.
374    const NORMALIZED_TIMESTAMP: &str = "1970-01-01T00:00:00.000Z";
375
376    /// Bless mode (`STRIPE_PROJECTS_REFRESH=1`): regenerate the three committed
377    /// snapshots — `plugin-version.txt`, `catalog.json`, `command-surface.txt` —
378    /// from the LOCALLY INSTALLED plugin. The only path that writes fixtures;
379    /// invoked by `mise run stripe-refresh` and the CI watcher. A no-op (and
380    /// needs no `stripe`) otherwise, so it stays inert in the hermetic gate.
381    #[tokio::test]
382    async fn refresh_blesses_snapshots() {
383        if std::env::var("STRIPE_PROJECTS_REFRESH").as_deref() != Ok("1") {
384            return;
385        }
386        let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
387        let stripe = StripeProjects::new(TokioRunner, std::env::current_dir().unwrap());
388
389        // 1. Pinned plugin version.
390        let version = crate::surface::plugin_version(&stripe)
391            .await
392            .expect("probe `stripe projects --version`");
393
394        // 2. Catalog envelope — refuse to bless anything the typed model can't
395        //    fully represent (forces a src/catalog.rs update first).
396        let raw = stripe
397            .plain(&["catalog", "--json"])
398            .await
399            .expect("run `stripe projects catalog --json`");
400        let start = raw.stdout.find('{').expect("catalog produced JSON");
401        let json = &raw.stdout[start..];
402        let report = crate::catalog::Catalog::from_json_envelope(json)
403            .expect("catalog envelope parses")
404            .drift_report();
405        assert!(
406            report.is_empty(),
407            "live catalog has unmodeled drift — update src/catalog.rs before blessing:\n{}",
408            report.join("\n")
409        );
410        let mut envelope: serde_json::Value =
411            serde_json::from_str(json).expect("catalog envelope is JSON");
412        if let Some(data) = envelope
413            .get_mut("data")
414            .and_then(serde_json::Value::as_object_mut)
415            && data.contains_key("last_updated")
416        {
417            data.insert(
418                "last_updated".into(),
419                serde_json::Value::String(NORMALIZED_TIMESTAMP.into()),
420            );
421        }
422        let catalog_pretty = format!(
423            "{}\n",
424            serde_json::to_string_pretty(&envelope).expect("serialize catalog")
425        );
426
427        // 3. Command surface (header carries the pinned version).
428        let body = crate::surface::command_surface(&stripe)
429            .await
430            .expect("capture command surface");
431        let surface = crate::surface::render_surface(&version, &body);
432
433        std::fs::write(fixtures.join("catalog.json"), catalog_pretty).unwrap();
434        std::fs::write(fixtures.join("command-surface.txt"), surface).unwrap();
435        std::fs::write(fixtures.join("plugin-version.txt"), format!("{version}\n")).unwrap();
436        eprintln!("blessed snapshots for stripe projects plugin v{version}");
437    }
438
439    #[tokio::test]
440    async fn confirmation_code_falls_back_to_plain_mode() {
441        let d = driver(vec![
442            out(
443                0,
444                r#"{"ok":false,"error":{"code":"JSON_REQUIRES_CONFIRMATION","message":"needs confirmation"}}"#,
445                "",
446            ),
447            out(0, "✓ created project", ""),
448        ]);
449        d.run_ok(
450            "init",
451            &["init", "atto", "--skip-skills", "--accept-tos"],
452            &["--accept-tos", "--yes"],
453        )
454        .await
455        .unwrap();
456        let calls = d.runner().calls();
457        assert_eq!(calls.len(), 2);
458        assert!(calls[0].contains(&"--json".to_owned()));
459        assert!(!calls[1].contains(&"--json".to_owned()));
460        assert!(calls[1].contains(&"--yes".to_owned()));
461    }
462}