stackless-stripe-projects 0.1.71

Stripe Projects CLI driver for stackless
Documentation
//! Capture of the `stripe projects` command surface — the top-level help banner
//! plus every subcommand's `--help` — as a deterministic, committable text
//! snapshot. Diffing this artifact across plugin versions is the changelog
//! Stripe does not publish: added/removed/renamed commands and flags show up as
//! line diffs. Paired with the catalog fixture and the pinned version file, it
//! is regenerated by the `STRIPE_PROJECTS_REFRESH=1` bless path (see `stripe.rs`).

use crate::error::ProjectsError;
use crate::stripe::{CommandRunner, StripeProjects};

/// Every `stripe projects` invocation we snapshot, as the argv *after* `projects`.
/// The order is fixed in code, not discovered from the plugin, so the snapshot
/// never self-reorders — a newly shipped command surfaces as a diff in the
/// top-level `--help` block and is then added here in the same change. Groups
/// (`services`/`env`/`billing`) are listed immediately before their children.
/// Verified against plugin 0.25.0.
const TRAVERSAL: &[&[&str]] = &[
    &[],
    // GET STARTED
    &["init"],
    &["build"],
    &["list"],
    &["pull"],
    &["status"],
    &["services"],
    &["services", "list"],
    &["catalog"],
    &["search"],
    &["switch-account"],
    // MANAGE SERVICES
    &["add"],
    &["update"],
    &["upgrade"],
    &["downgrade"],
    &["remove"],
    &["rotate"],
    &["link"],
    &["unlink"],
    &["open"],
    // ENVIRONMENT
    &["env"],
    &["env", "list"],
    &["env", "show"],
    &["env", "create"],
    &["env", "use"],
    &["env", "update"],
    &["env", "delete"],
    &["env", "add"],
    &["env", "remove"],
    &["variables"],
    &["variables", "set"],
    &["variables", "list"],
    &["variables", "delete"],
    &["llm-context"],
    // BILLING
    &["billing"],
    &["billing", "show"],
    &["billing", "add"],
    &["billing", "update"],
    &["spend"],
    // FEEDBACK
    &["feedback"],
];

const HEADER_VERSION_PREFIX: &str = "# plugin-version:";

/// Probe the installed plugin version via `stripe projects --version`
/// (which prints a bare `X.Y.Z` line).
pub async fn plugin_version<R: CommandRunner>(
    stripe: &StripeProjects<R>,
) -> Result<String, ProjectsError> {
    let out = stripe.plain(&["--version"]).await?;
    out.stdout
        .lines()
        .map(str::trim)
        .find(|line| line.starts_with(|c: char| c.is_ascii_digit()))
        .map(str::to_owned)
        .ok_or_else(|| ProjectsError::Unavailable {
            detail: format!(
                "`stripe projects --version` printed no version line: {:?}",
                out.stdout
            ),
        })
}

/// Capture the full command surface (body only — compose with [`surface_header`]
/// / [`render_surface`]). Each block is the normalized `--help` of one command,
/// introduced by a stable `===== … =====` separator. Output is byte-stable
/// across machines: trailing whitespace stripped, leading/trailing blank lines
/// dropped, line order preserved as the plugin emits it (never re-sorted).
pub async fn command_surface<R: CommandRunner>(
    stripe: &StripeProjects<R>,
) -> Result<String, ProjectsError> {
    let mut out = String::new();
    for path in TRAVERSAL {
        let mut args: Vec<&str> = path.to_vec();
        args.extend_from_slice(&["--help", "--color", "off"]);
        let result = stripe.plain(&args).await?;
        out.push_str(&format!("===== {} =====\n", label(path)));
        out.push_str(&normalize_block(&result.stdout));
        out.push_str("\n\n");
    }
    Ok(out.trim_end().to_owned())
}

/// The comment header that carries the pinned version (read back by the offline
/// coherence test via [`parse_header_version`]).
pub fn surface_header(version: &str) -> String {
    format!(
        "# stripe projects command surface\n\
         {HEADER_VERSION_PREFIX} {version}\n\
         # DO NOT EDIT — regenerated by CI (mise run stripe-refresh)\n\n"
    )
}

/// The full committed file: header + captured body + a single trailing newline.
pub fn render_surface(version: &str, body: &str) -> String {
    format!("{}{}\n", surface_header(version), body)
}

/// Read the `plugin-version:` value out of a committed surface file's header.
pub fn parse_header_version(text: &str) -> Option<String> {
    text.lines()
        .find_map(|line| line.trim().strip_prefix(HEADER_VERSION_PREFIX))
        .map(|value| value.trim().to_owned())
}

/// The `===== … =====` separator lines this code expects, in order — the
/// source of truth the offline coherence test compares the committed file
/// against (so a hand-edit or a `TRAVERSAL` change without a re-bless fails CI).
pub fn command_separators() -> Vec<String> {
    TRAVERSAL
        .iter()
        .map(|path| format!("===== {} =====", label(path)))
        .collect()
}

fn label(path: &[&str]) -> String {
    if path.is_empty() {
        "stripe projects --help".to_owned()
    } else {
        format!("stripe projects {} --help", path.join(" "))
    }
}

fn normalize_block(raw: &str) -> String {
    let mut lines: Vec<&str> = raw.lines().map(str::trim_end).collect();
    while lines.first().is_some_and(|line| line.is_empty()) {
        lines.remove(0);
    }
    while lines.last().is_some_and(|line| line.is_empty()) {
        lines.pop();
    }
    lines.join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::stripe::CommandOutput;
    use async_trait::async_trait;
    use std::path::Path;

    /// Returns canned help text echoing the argv it was called with, so we can
    /// assert the assembly logic without scripting one output per command.
    struct EchoRunner;

    #[async_trait]
    impl CommandRunner for EchoRunner {
        async fn run(&self, args: &[String], _cwd: &Path) -> Result<CommandOutput, ProjectsError> {
            Ok(CommandOutput {
                status: 0,
                // Pad with blank lines + trailing spaces to exercise normalization.
                stdout: format!("\n\nhelp for: {}   \n\n", args.join(" ")),
                stderr: String::new(),
            })
        }
    }

    fn driver() -> StripeProjects<EchoRunner> {
        StripeProjects::new(EchoRunner, std::env::temp_dir())
    }

    #[test]
    fn label_handles_top_level_and_nested() {
        assert_eq!(label(&[]), "stripe projects --help");
        assert_eq!(label(&["add"]), "stripe projects add --help");
        assert_eq!(
            label(&["billing", "update"]),
            "stripe projects billing update --help"
        );
    }

    #[test]
    fn normalize_strips_trailing_ws_and_blank_edges() {
        assert_eq!(normalize_block("\n\n  body   \n\n"), "  body");
        assert_eq!(normalize_block("a   \nb\t\n"), "a\nb");
    }

    #[test]
    fn header_round_trips_version() {
        let text = render_surface("0.19.0", "===== stripe projects --help =====\nbody");
        assert_eq!(parse_header_version(&text).as_deref(), Some("0.19.0"));
        assert!(text.ends_with("body\n"));
    }

    #[tokio::test]
    async fn surface_has_one_block_per_traversal_entry_in_order() {
        let body = command_surface(&driver()).await.unwrap();
        let seps: Vec<&str> = body.lines().filter(|l| l.starts_with("===== ")).collect();
        assert_eq!(seps, command_separators());
        // Top-level uses the bare label and the echo carries the normalized args.
        assert!(body.starts_with("===== stripe projects --help =====\n"));
        assert!(body.contains("help for: --help --color off"));
        assert!(body.contains("help for: env list --help --color off"));
        // Trimmed: no trailing blank line.
        assert!(!body.ends_with('\n'));
    }
}