Skip to main content

stackless_stripe_projects/
surface.rs

1//! Capture of the `stripe projects` command surface — the top-level help banner
2//! plus every subcommand's `--help` — as a deterministic, committable text
3//! snapshot. Diffing this artifact across plugin versions is the changelog
4//! Stripe does not publish: added/removed/renamed commands and flags show up as
5//! line diffs. Paired with the catalog fixture and the pinned version file, it
6//! is regenerated by the `STRIPE_PROJECTS_REFRESH=1` bless path (see `stripe.rs`).
7
8use crate::error::ProjectsError;
9use crate::stripe::{CommandRunner, StripeProjects};
10
11/// Every `stripe projects` invocation we snapshot, as the argv *after* `projects`.
12/// The order is fixed in code, not discovered from the plugin, so the snapshot
13/// never self-reorders — a newly shipped command surfaces as a diff in the
14/// top-level `--help` block and is then added here in the same change. Groups
15/// (`services`/`env`/`billing`) are listed immediately before their children.
16/// Verified against plugin 0.30.0.
17const TRAVERSAL: &[&[&str]] = &[
18    &[],
19    // GET STARTED
20    &["init"],
21    &["build"],
22    &["list"],
23    &["pull"],
24    &["status"],
25    &["services"],
26    &["services", "list"],
27    &["catalog"],
28    &["search"],
29    &["switch-account"],
30    // MANAGE SERVICES
31    &["add"],
32    &["update"],
33    &["upgrade"],
34    &["downgrade"],
35    &["remove"],
36    &["rotate"],
37    &["link"],
38    &["unlink"],
39    &["open"],
40    // ENVIRONMENT
41    &["env"],
42    &["env", "list"],
43    &["env", "show"],
44    &["env", "create"],
45    &["env", "use"],
46    &["env", "update"],
47    &["env", "delete"],
48    &["env", "add"],
49    &["env", "remove"],
50    &["variables"],
51    &["variables", "set"],
52    &["variables", "list"],
53    &["variables", "delete"],
54    &["llm-context"],
55    // BILLING
56    &["billing"],
57    &["billing", "show"],
58    &["billing", "add"],
59    &["billing", "update"],
60    &["spend"],
61    // FEEDBACK
62    &["feedback"],
63];
64
65const HEADER_VERSION_PREFIX: &str = "# plugin-version:";
66
67/// Probe the installed plugin version via `stripe projects --version`
68/// (which prints a bare `X.Y.Z` line).
69pub async fn plugin_version<R: CommandRunner>(
70    stripe: &StripeProjects<R>,
71) -> Result<String, ProjectsError> {
72    let out = stripe.plain(&["--version"]).await?;
73    out.stdout
74        .lines()
75        .map(str::trim)
76        .find(|line| line.starts_with(|c: char| c.is_ascii_digit()))
77        .map(str::to_owned)
78        .ok_or_else(|| ProjectsError::Unavailable {
79            detail: format!(
80                "`stripe projects --version` printed no version line: {:?}",
81                out.stdout
82            ),
83        })
84}
85
86/// Capture the full command surface (body only — compose with [`surface_header`]
87/// / [`render_surface`]). Each block is the normalized `--help` of one command,
88/// introduced by a stable `===== … =====` separator. Output is byte-stable
89/// across machines: trailing whitespace stripped, leading/trailing blank lines
90/// dropped, line order preserved as the plugin emits it (never re-sorted).
91pub async fn command_surface<R: CommandRunner>(
92    stripe: &StripeProjects<R>,
93) -> Result<String, ProjectsError> {
94    let mut out = String::new();
95    for path in TRAVERSAL {
96        let mut args: Vec<&str> = path.to_vec();
97        args.extend_from_slice(&["--help", "--color", "off"]);
98        let result = stripe.plain(&args).await?;
99        out.push_str(&format!("===== {} =====\n", label(path)));
100        out.push_str(&normalize_block(&result.stdout));
101        out.push_str("\n\n");
102    }
103    Ok(out.trim_end().to_owned())
104}
105
106/// The comment header that carries the pinned version (read back by the offline
107/// coherence test via [`parse_header_version`]).
108pub fn surface_header(version: &str) -> String {
109    format!(
110        "# stripe projects command surface\n\
111         {HEADER_VERSION_PREFIX} {version}\n\
112         # DO NOT EDIT — regenerated by CI (mise run stripe-refresh)\n\n"
113    )
114}
115
116/// The full committed file: header + captured body + a single trailing newline.
117pub fn render_surface(version: &str, body: &str) -> String {
118    format!("{}{}\n", surface_header(version), body)
119}
120
121/// Read the `plugin-version:` value out of a committed surface file's header.
122pub fn parse_header_version(text: &str) -> Option<String> {
123    text.lines()
124        .find_map(|line| line.trim().strip_prefix(HEADER_VERSION_PREFIX))
125        .map(|value| value.trim().to_owned())
126}
127
128/// The `===== … =====` separator lines this code expects, in order — the
129/// source of truth the offline coherence test compares the committed file
130/// against (so a hand-edit or a `TRAVERSAL` change without a re-bless fails CI).
131pub fn command_separators() -> Vec<String> {
132    TRAVERSAL
133        .iter()
134        .map(|path| format!("===== {} =====", label(path)))
135        .collect()
136}
137
138fn label(path: &[&str]) -> String {
139    if path.is_empty() {
140        "stripe projects --help".to_owned()
141    } else {
142        format!("stripe projects {} --help", path.join(" "))
143    }
144}
145
146fn normalize_block(raw: &str) -> String {
147    let mut lines: Vec<&str> = raw.lines().map(str::trim_end).collect();
148    while lines.first().is_some_and(|line| line.is_empty()) {
149        lines.remove(0);
150    }
151    while lines.last().is_some_and(|line| line.is_empty()) {
152        lines.pop();
153    }
154    lines.join("\n")
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::stripe::CommandOutput;
161    use async_trait::async_trait;
162    use std::path::Path;
163
164    /// Returns canned help text echoing the argv it was called with, so we can
165    /// assert the assembly logic without scripting one output per command.
166    struct EchoRunner;
167
168    #[async_trait]
169    impl CommandRunner for EchoRunner {
170        async fn run(&self, args: &[String], _cwd: &Path) -> Result<CommandOutput, ProjectsError> {
171            Ok(CommandOutput {
172                status: 0,
173                // Pad with blank lines + trailing spaces to exercise normalization.
174                stdout: format!("\n\nhelp for: {}   \n\n", args.join(" ")),
175                stderr: String::new(),
176            })
177        }
178    }
179
180    fn driver() -> StripeProjects<EchoRunner> {
181        StripeProjects::new(EchoRunner, std::env::temp_dir())
182    }
183
184    #[test]
185    fn label_handles_top_level_and_nested() {
186        assert_eq!(label(&[]), "stripe projects --help");
187        assert_eq!(label(&["add"]), "stripe projects add --help");
188        assert_eq!(
189            label(&["billing", "update"]),
190            "stripe projects billing update --help"
191        );
192    }
193
194    #[test]
195    fn normalize_strips_trailing_ws_and_blank_edges() {
196        assert_eq!(normalize_block("\n\n  body   \n\n"), "  body");
197        assert_eq!(normalize_block("a   \nb\t\n"), "a\nb");
198    }
199
200    #[test]
201    fn header_round_trips_version() {
202        let text = render_surface("0.19.0", "===== stripe projects --help =====\nbody");
203        assert_eq!(parse_header_version(&text).as_deref(), Some("0.19.0"));
204        assert!(text.ends_with("body\n"));
205    }
206
207    #[tokio::test]
208    async fn surface_has_one_block_per_traversal_entry_in_order() {
209        let body = command_surface(&driver()).await.unwrap();
210        let seps: Vec<&str> = body.lines().filter(|l| l.starts_with("===== ")).collect();
211        assert_eq!(seps, command_separators());
212        // Top-level uses the bare label and the echo carries the normalized args.
213        assert!(body.starts_with("===== stripe projects --help =====\n"));
214        assert!(body.contains("help for: --help --color off"));
215        assert!(body.contains("help for: env list --help --color off"));
216        // Trimmed: no trailing blank line.
217        assert!(!body.ends_with('\n'));
218    }
219}