Skip to main content

redisctl_core/cloud/
quick_database.rs

1//! Idempotent free-tier provisioning — the engine behind `redisctl cloud workflow
2//! quick-database` and the `cloud_quick_database` MCP tool.
3//!
4//! [`provision`] is pure: given a [`redis_cloud::CloudClient`] and [`QuickDatabaseParams`] it
5//! creates (or reuses) a free Essentials subscription + database and writes the connection
6//! string to a dotenv file (via [`super::env_delivery`]). It has no front-end concerns — no
7//! clap, no exit codes, no spinner — so both the CLI and the MCP server call it directly.
8//!
9//! The connection string is written only to the credentials file, never returned in a form
10//! that a caller is expected to print. Failures are typed ([`QuickDatabaseError`]) so front
11//! ends can map them to their own contracts (CLI exit codes / MCP tool errors).
12//!
13//! Recognizability / safety: the subscription is always named `redisctl-<name>`. That prefix
14//! is how a re-run finds and reuses its own resource, and it guarantees we never touch a
15//! subscription a human created under a bare name (D6).
16
17use std::path::PathBuf;
18use std::time::Duration;
19
20use redis_cloud::fixed::databases::{FixedDatabase, FixedDatabaseCreateRequest};
21use redis_cloud::fixed::subscriptions::{FixedSubscription, FixedSubscriptionCreateRequest};
22use redis_cloud::{CloudClient, CloudError};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::error::CoreError;
27use crate::progress::poll_task;
28
29const SUBSCRIPTION_PREFIX: &str = "redisctl-";
30const DEFAULT_USER: &str = "default";
31
32/// Branchable failures for the quick-database flow. Front-ends map these to their own
33/// contracts (see the CLI's `StructuredError` / the MCP tool error).
34#[derive(Debug, Error)]
35pub enum QuickDatabaseError {
36    #[error("{0}")]
37    InvalidName(String),
38    #[error("{0}")]
39    NameConflict(String),
40    #[error("{0}")]
41    FreeDbExists(String),
42    #[error("{0}")]
43    QuotaExceeded(String),
44    #[error("{0}")]
45    NotAuthenticated(String),
46    #[error("{0}")]
47    Transient(String),
48    #[error("{0}")]
49    RateLimited(String),
50    #[error("{0}")]
51    Other(String),
52}
53
54type QResult<T> = std::result::Result<T, QuickDatabaseError>;
55
56/// Inputs to [`provision`]. Plain data — front-ends build it from their own arg parsing.
57#[derive(Debug, Clone)]
58pub struct QuickDatabaseParams {
59    /// Database name (also used, prefixed with `redisctl-`, for the subscription).
60    pub name: String,
61    /// File to write the credentials into.
62    pub output_credentials: PathBuf,
63    /// Primary URL variable name (e.g. `REDIS_URL`); broken-out fields derive their prefix.
64    pub variable: String,
65    /// Max seconds to wait for each async operation.
66    pub wait_timeout: u32,
67    /// Polling interval in seconds.
68    pub wait_interval: u32,
69}
70
71impl QuickDatabaseParams {
72    /// Params for `name` with the conventional defaults (`./.env`, `REDIS_URL`, 600s/5s).
73    pub fn new(name: impl Into<String>) -> Self {
74        Self {
75            name: name.into(),
76            output_credentials: PathBuf::from("./.env"),
77            variable: "REDIS_URL".to_string(),
78            wait_timeout: 600,
79            wait_interval: 5,
80        }
81    }
82}
83
84/// PRD §5.2 report — the agent contract. Serialized verbatim by both front-ends.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct QuickDatabaseReport {
87    /// `ok` for fresh/resumed provisioning, `reused` when an existing DB was returned.
88    pub status: String,
89    pub database: DatabaseSummary,
90    pub credentials_written_to: String,
91    pub credentials_variable: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct DatabaseSummary {
96    pub id: String,
97    pub name: String,
98    pub region: Option<String>,
99    pub plan: String,
100    pub tls: bool,
101}
102
103/// Create or reuse a free database for `params.name` and deliver its credentials to a file.
104///
105/// The `client` must already be authenticated (the caller builds it from its own credential
106/// source); `provision` never touches login state.
107pub async fn provision(
108    client: &CloudClient,
109    params: &QuickDatabaseParams,
110) -> QResult<QuickDatabaseReport> {
111    validate_name(&params.name)?;
112    validate_variable(&params.variable)?;
113    let sub_name = format!("{SUBSCRIPTION_PREFIX}{}", params.name);
114
115    // Find our subscription (idempotent re-run / crash resume).
116    let (subscription_id, database_id, status) = match find_subscription(client, &sub_name).await? {
117        Some((sub_id, sub)) => {
118            ensure_free_plan(client, &sub, &sub_name).await?;
119            match database_to_reuse(client, sub_id, &params.name).await? {
120                Some(db_id) => (sub_id, db_id, "reused"),
121                // Half-provisioned: subscription created but DB create never finished.
122                None => (sub_id, create_database(client, sub_id, params).await?, "ok"),
123            }
124        }
125        None => {
126            let sub_id = create_subscription(client, &sub_name, params).await?;
127            (sub_id, create_database(client, sub_id, params).await?, "ok")
128        }
129    };
130
131    // Fetch full details, polling until the public endpoint is populated (it can lag a few
132    // seconds behind task completion on a fresh create).
133    let db = fetch_ready_database(client, subscription_id, database_id, params).await?;
134    deliver_and_report(&db, params, database_id, status, "free")
135}
136
137/// Write an EXISTING Essentials database's credentials to the file, without provisioning.
138/// Identified by subscription + database id; `params` supplies the output path / variable /
139/// wait settings (its `name` is only a report fallback, overridden by the database's name).
140pub async fn existing_database_report(
141    client: &CloudClient,
142    subscription_id: i32,
143    database_id: i32,
144    params: &QuickDatabaseParams,
145) -> QResult<QuickDatabaseReport> {
146    validate_variable(&params.variable)?;
147    let db = fetch_ready_database(client, subscription_id, database_id, params).await?;
148    deliver_and_report(&db, params, database_id, "existing", "essentials")
149}
150
151/// Shared tail: build the connection parts, write the URL + broken-out fields to the
152/// credentials file, and assemble the report. Used by both `provision` and
153/// `existing_database_report`.
154fn deliver_and_report(
155    db: &FixedDatabase,
156    params: &QuickDatabaseParams,
157    database_id: i32,
158    status: &str,
159    plan: &str,
160) -> QResult<QuickDatabaseReport> {
161    let parts = connection_parts(db)?;
162
163    // Deliver the primary URL plus broken-out fields so apps can read whichever form they
164    // expect. The discrete fields share a prefix derived from the URL var name.
165    let prefix = params
166        .variable
167        .strip_suffix("_URL")
168        .unwrap_or(&params.variable);
169    let host_key = format!("{prefix}_HOST");
170    let port_key = format!("{prefix}_PORT");
171    let password_key = format!("{prefix}_PASSWORD");
172    let username_key = format!("{prefix}_USERNAME");
173    let tls_key = format!("{prefix}_TLS");
174    let tls_val = parts.tls.to_string();
175    let vars: Vec<(&str, &str)> = vec![
176        (params.variable.as_str(), parts.url.as_str()),
177        (host_key.as_str(), parts.host.as_str()),
178        (port_key.as_str(), parts.port.as_str()),
179        (password_key.as_str(), parts.password.as_str()),
180        (username_key.as_str(), parts.username.as_str()),
181        (tls_key.as_str(), tls_val.as_str()),
182    ];
183    let outcome = super::env_delivery::deliver_vars(&params.output_credentials, &vars)
184        .map_err(|e| QuickDatabaseError::Other(format!("failed to write credentials file: {e}")))?;
185    let _ = super::env_delivery::ensure_gitignored(&params.output_credentials);
186
187    Ok(QuickDatabaseReport {
188        status: status.to_string(),
189        database: DatabaseSummary {
190            id: database_id.to_string(),
191            name: db.name.clone().unwrap_or_else(|| params.name.clone()),
192            region: db.region.clone(),
193            plan: plan.to_string(),
194            tls: parts.tls,
195        },
196        credentials_written_to: outcome.path.display().to_string(),
197        credentials_variable: outcome.variable,
198    })
199}
200
201/// PRD §5.1.1 name rules: lowercase alnum + hyphens, 3–40 chars, no leading/trailing hyphen,
202/// no `--`.
203/// An env-var name, so it cannot carry a newline into the credentials file and inject lines.
204fn validate_variable(variable: &str) -> QResult<()> {
205    if super::env_delivery::is_env_var_name(variable) {
206        Ok(())
207    } else {
208        Err(QuickDatabaseError::InvalidName(format!(
209            "'{variable}' is not a usable environment variable name: use letters, digits and \
210             underscores, starting with a letter or underscore"
211        )))
212    }
213}
214
215fn validate_name(name: &str) -> QResult<()> {
216    let ok = (3..=40).contains(&name.len())
217        && name
218            .chars()
219            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
220        && name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
221        && name
222            .chars()
223            .last()
224            .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
225        && !name.contains("--");
226    if !ok {
227        return Err(QuickDatabaseError::InvalidName(format!(
228            "invalid database name '{name}': must be 3-40 chars, lowercase letters/digits/hyphens, \
229             start with a letter, end with a letter or digit, and contain no '--'"
230        )));
231    }
232    Ok(())
233}
234
235async fn find_subscription(
236    client: &CloudClient,
237    sub_name: &str,
238) -> QResult<Option<(i32, FixedSubscription)>> {
239    let subs = client
240        .fixed_subscriptions()
241        .list()
242        .await
243        .map_err(|e| classify_cloud_error("list subscriptions", e))?;
244    Ok(subs
245        .subscriptions
246        .unwrap_or_default()
247        .into_iter()
248        .find(|s| {
249            s.name
250                .as_deref()
251                .is_some_and(|n| n.eq_ignore_ascii_case(sub_name))
252        })
253        .and_then(|s| s.id.map(|id| (id, s))))
254}
255
256/// Refuse a subscription that carries our prefix but is not on the free plan.
257///
258/// The caller asked for a free database, and the report says `plan: "free"` unconditionally, so
259/// adopting a billable subscription would bill them while telling them otherwise. Refused only on
260/// positive evidence that the plan costs money: reuse by name is the documented behaviour, so an
261/// unreadable or silent plan has to leave it standing.
262async fn ensure_free_plan(
263    client: &CloudClient,
264    sub: &FixedSubscription,
265    sub_name: &str,
266) -> QResult<()> {
267    // The subscription reports its own price, which settles it without another call.
268    if let Some(price) = sub.price {
269        return if price == 0 {
270            Ok(())
271        } else {
272            Err(not_free(sub, sub_name))
273        };
274    }
275    // No price reported: fall back to the plan id, and refuse only a plan the list positively
276    // prices above zero. Absence is not evidence here — `/fixed/plans` is the account's
277    // *creatable* options, so the free plan drops off it once the account holds its one free
278    // subscription (hence `pick_free_plan`'s "no free Essentials plan is available on this
279    // account"). Treating a missing id as billable would refuse to reuse our own subscription,
280    // naming the free plan it sits on as "not free". A listing we cannot read says nothing
281    // either, so a failure there leaves reuse alone rather than aborting it.
282    let Some(plan_id) = sub.plan_id else {
283        return Ok(());
284    };
285    match paid_plan_ids(client).await {
286        Ok(paid) if paid.contains(&plan_id) => Err(not_free(sub, sub_name)),
287        _ => Ok(()),
288    }
289}
290
291fn not_free(sub: &FixedSubscription, sub_name: &str) -> QuickDatabaseError {
292    QuickDatabaseError::NameConflict(format!(
293        "subscription '{sub_name}' already exists on the {} plan, which is not free. Choose a \
294         different --name, or read that database's credentials with \
295         `redisctl cloud workflow database-credentials`.",
296        sub.plan_name.as_deref().unwrap_or("current")
297    ))
298}
299
300/// The database a re-run should reuse: the one carrying `name`, or the only one present.
301///
302/// List order decides nothing once a subscription can hold more than one database. The
303/// single-database fallback keeps a database renamed outside `redisctl` reusable, which is what
304/// our own subscription looks like after a console edit.
305async fn database_to_reuse(
306    client: &CloudClient,
307    subscription_id: i32,
308    name: &str,
309) -> QResult<Option<i32>> {
310    let list = client
311        .fixed_databases()
312        .list(subscription_id, None, None)
313        .await
314        .map_err(|e| classify_cloud_error("list databases", e))?;
315    let databases = list
316        .subscription
317        .map(|info| info.databases)
318        .unwrap_or_default();
319
320    // Matched the way `find_subscription` matches the subscription. Comparing case-sensitively
321    // here while the subscription lookup ignores case makes the two halves disagree about what
322    // is ours: `redisctl-MyApp` is adopted, then its `MyApp` database is treated as a stranger.
323    if let Some(named) = databases.iter().find(|d| {
324        d.name
325            .as_deref()
326            .is_some_and(|n| n.eq_ignore_ascii_case(name))
327    }) {
328        // The database we asked for, listed without an id. Its own case, not the "none is named
329        // '{name}'" one below — that message would name it in the very list it says lacks it,
330        // and falling through with a single entry would create a second database of that name.
331        let id = named.database_id.ok_or_else(|| {
332            QuickDatabaseError::Transient(format!(
333                "the database named '{name}' is listed without an id, so it cannot be read yet; \
334                 it may still be provisioning — retry in a moment"
335            ))
336        })?;
337        return Ok(Some(id));
338    }
339    match databases.as_slice() {
340        [] => Ok(None),
341        [only] => Ok(only.database_id),
342        many => Err(QuickDatabaseError::NameConflict(format!(
343            "the subscription for '{name}' holds {} databases and none is named '{name}': {}. \
344             Read the one you want with `redisctl cloud workflow database-credentials`.",
345            many.len(),
346            many.iter()
347                .map(|d| d.name.as_deref().unwrap_or("<unnamed>"))
348                .collect::<Vec<_>>()
349                .join(", ")
350        ))),
351    }
352}
353
354async fn create_subscription(
355    client: &CloudClient,
356    sub_name: &str,
357    params: &QuickDatabaseParams,
358) -> QResult<i32> {
359    let plan_id = pick_free_plan(client).await?;
360    // Free plan: no paymentMethod / paymentMethodId sent (a non-null value is rejected).
361    let request = FixedSubscriptionCreateRequest::builder()
362        .name(sub_name.to_string())
363        .plan_id(plan_id)
364        .build();
365    let task = client
366        .fixed_subscriptions()
367        .create(&request)
368        .await
369        .map_err(classify_create_error)?;
370    run_task(client, task.task_id, params).await
371}
372
373async fn create_database(
374    client: &CloudClient,
375    subscription_id: i32,
376    params: &QuickDatabaseParams,
377) -> QResult<i32> {
378    let request = FixedDatabaseCreateRequest::builder()
379        .name(params.name.clone())
380        .build();
381    let task = client
382        .fixed_databases()
383        .create(subscription_id, &request)
384        .await
385        .map_err(|e| classify_cloud_error("create database", e))?;
386    run_task(client, task.task_id, params).await
387}
388
389/// Poll a create task to completion (via core's [`poll_task`]) and return the resource id.
390/// Task-level failures are re-classified so the one-free-sub limit maps to `free_db_exists`.
391async fn run_task(
392    client: &CloudClient,
393    task_id: Option<String>,
394    params: &QuickDatabaseParams,
395) -> QResult<i32> {
396    let task_id = task_id.ok_or_else(|| {
397        QuickDatabaseError::Other("create response did not include a task id".to_string())
398    })?;
399    let timeout = Duration::from_secs(params.wait_timeout as u64);
400    let interval = Duration::from_secs(params.wait_interval.max(1) as u64);
401
402    match poll_task(client, &task_id, timeout, interval, None).await {
403        Ok(completed) => completed
404            .response
405            .and_then(|r| r.resource_id)
406            .ok_or_else(|| {
407                QuickDatabaseError::Other("completed task did not return a resource id".to_string())
408            }),
409        Err(CoreError::TaskFailed(msg)) => Err(classify_task_error(&msg)),
410        Err(CoreError::TaskTimeout(_)) => Err(QuickDatabaseError::Transient(format!(
411            "operation timed out after {}s; retry in a moment",
412            params.wait_timeout
413        ))),
414        Err(CoreError::Cloud(e)) => Err(classify_cloud_error("poll task", e)),
415        Err(other) => Err(QuickDatabaseError::Other(format!("task failed: {other}"))),
416    }
417}
418
419/// Choose a free Essentials plan (`price == 0`). The region is server-chosen for the free
420/// tier, so the first free plan is fine.
421async fn pick_free_plan(client: &CloudClient) -> QResult<i32> {
422    free_plan_ids(client)
423        .await?
424        .into_iter()
425        .next()
426        .ok_or_else(|| {
427            QuickDatabaseError::Other(
428                "no free Essentials plan is available on this account".to_string(),
429            )
430        })
431}
432
433/// Every zero-price plan id. Essentials plans are per provider and region, so a free
434/// subscription can sit on any one of them.
435async fn free_plan_ids(client: &CloudClient) -> QResult<Vec<i32>> {
436    plan_ids_priced(client, |price| price == 0).await
437}
438
439/// Every plan id the list prices above zero — what [`ensure_free_plan`] refuses on.
440async fn paid_plan_ids(client: &CloudClient) -> QResult<Vec<i32>> {
441    plan_ids_priced(client, |price| price != 0).await
442}
443
444/// Plan ids whose *reported* price satisfies `wanted`. A plan with no price is in neither set:
445/// it is not known to be free and not known to cost anything.
446async fn plan_ids_priced(client: &CloudClient, wanted: fn(i32) -> bool) -> QResult<Vec<i32>> {
447    let plans = client
448        .fixed_subscriptions()
449        .list_plans(None, None)
450        .await
451        .map_err(|e| classify_cloud_error("list plans", e))?;
452    Ok(plans
453        .plans
454        .unwrap_or_default()
455        .into_iter()
456        .filter(|p| p.price.is_some_and(wanted))
457        .filter_map(|p| p.id)
458        .collect())
459}
460
461/// Read the database, polling until its `public_endpoint` is populated. A persistent absence
462/// past `wait_timeout` is reported as transient (retryable) rather than an unknown error.
463async fn fetch_ready_database(
464    client: &CloudClient,
465    subscription_id: i32,
466    database_id: i32,
467    params: &QuickDatabaseParams,
468) -> QResult<FixedDatabase> {
469    let start = std::time::Instant::now();
470    let timeout = Duration::from_secs(params.wait_timeout as u64);
471    let interval = Duration::from_secs(params.wait_interval.max(1) as u64);
472
473    loop {
474        let db = client
475            .fixed_databases()
476            .get_by_id(subscription_id, database_id)
477            .await
478            .map_err(|e| classify_cloud_error("read database details", e))?;
479        if db.public_endpoint.as_deref().is_some_and(|s| !s.is_empty()) {
480            return Ok(db);
481        }
482        if start.elapsed() > timeout {
483            return Err(QuickDatabaseError::Transient(format!(
484                "database {database_id} has no public endpoint after {}s; it may still be \
485                 provisioning — retry in a moment",
486                params.wait_timeout
487            )));
488        }
489        tokio::time::sleep(interval).await;
490    }
491}
492
493/// Connection details pulled from a database read, for credential-file delivery.
494struct ConnParts {
495    url: String,
496    host: String,
497    port: String,
498    password: String,
499    username: String,
500    tls: bool,
501}
502
503/// Extract connection parts (URL + broken-out fields) from a database read. The password is
504/// only present on the GET response, not the list.
505fn connection_parts(db: &FixedDatabase) -> QResult<ConnParts> {
506    let endpoint = db
507        .public_endpoint
508        .as_deref()
509        .filter(|s| !s.is_empty())
510        .ok_or_else(|| {
511            QuickDatabaseError::Other("database has no public endpoint yet".to_string())
512        })?;
513    let security = db.security.as_ref();
514    let tls = security.and_then(|s| s.enable_tls).unwrap_or(false);
515    let password = security
516        .and_then(|s| s.password.as_deref())
517        .ok_or_else(|| {
518            QuickDatabaseError::Other(
519                "database password was not returned by the API (the account may have \
520             'enable-capi-return-empty-bdb-passwords' set); cannot build a connection string"
521                    .to_string(),
522            )
523        })?;
524    let (host, port) = endpoint.rsplit_once(':').unwrap_or((endpoint, ""));
525    let scheme = if tls { "rediss" } else { "redis" };
526    // Percent-encode the password for the URL userinfo: a raw `@`, `:`, `/`, `#`, `%`, … would
527    // otherwise break authority parsing or be mis-decoded by the client. The broken-out
528    // `password` field below stays raw (apps use it directly, not inside a URL).
529    let encoded_password = urlencoding::encode(password);
530    Ok(ConnParts {
531        url: format!("{scheme}://{DEFAULT_USER}:{encoded_password}@{endpoint}"),
532        host: host.to_string(),
533        port: port.to_string(),
534        password: password.to_string(),
535        username: DEFAULT_USER.to_string(),
536        tls,
537    })
538}
539
540/// Classify a subscription-create failure. The public CAPI rejects free-plan creation when
541/// the account has no payment method and the caller is not on the trusted User-Agent
542/// allowlist — treated as "a free database already exists / is not permitted".
543fn classify_create_error(err: CloudError) -> QuickDatabaseError {
544    if err.to_string().to_uppercase().contains("PAYMENT") {
545        QuickDatabaseError::FreeDbExists(
546            "free database creation was rejected: the account either already has a free \
547             database, or is not eligible for the free tier (a payment method may be \
548             required). Check the Redis Cloud console."
549                .to_string(),
550        )
551    } else {
552        classify_cloud_error("create subscription", err)
553    }
554}
555
556/// Classify an async task failure. The one-free-subscription limit and payment/free-plan
557/// rejections come back here (as a task `ProcessingError`), not as a synchronous 4xx, so they
558/// must map to the same `free_db_exists` / `quota_exceeded` codes rather than `unknown`.
559fn classify_task_error(msg: &str) -> QuickDatabaseError {
560    let up = msg.to_uppercase();
561    if up.contains("FREE PLAN") || up.contains("FREE-PLAN") || up.contains("PAYMENT") {
562        QuickDatabaseError::FreeDbExists(format!("task failed: {msg}"))
563    } else if is_quota_message(msg) {
564        QuickDatabaseError::QuotaExceeded(format!("task failed: {msg}"))
565    } else {
566        QuickDatabaseError::Other(format!("task failed: {msg}"))
567    }
568}
569
570/// Map a generic CAPI error to a branchable class. 5xx / network / connection are transient;
571/// 429 is rate-limited; quota messages map to quota_exceeded; everything else is `Other`.
572fn classify_cloud_error(action: &str, err: CloudError) -> QuickDatabaseError {
573    let relay = |text: &str| format!("{action}: {}", crate::bound_upstream_text(text));
574    match err {
575        CloudError::RateLimited { message } => QuickDatabaseError::RateLimited(relay(&message)),
576        CloudError::ServiceUnavailable { message }
577        | CloudError::InternalServerError { message } => {
578            QuickDatabaseError::Transient(relay(&message))
579        }
580        CloudError::Request(m) | CloudError::ConnectionError(m) => {
581            QuickDatabaseError::Transient(relay(&m))
582        }
583        CloudError::ApiError { code, message } if (500..=599).contains(&code) => {
584            QuickDatabaseError::Transient(relay(&message))
585        }
586        CloudError::ApiError { code: 429, message } => {
587            QuickDatabaseError::RateLimited(relay(&message))
588        }
589        CloudError::BadRequest { message } if is_quota_message(&message) => {
590            QuickDatabaseError::QuotaExceeded(relay(&message))
591        }
592        other => QuickDatabaseError::Other(relay(&other.to_string())),
593    }
594}
595
596fn is_quota_message(message: &str) -> bool {
597    let m = message.to_uppercase();
598    m.contains("QUOTA") || m.contains("LIMIT") || m.contains("EXCEED")
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn accepts_valid_names() {
607        for n in ["abc", "my-db", "test-123", "a1b2c3"] {
608            assert!(validate_name(n).is_ok(), "{n} should be valid");
609        }
610    }
611
612    #[test]
613    fn rejects_invalid_names() {
614        for n in [
615            "ab",
616            "-abc",
617            "abc-",
618            "ab--cd",
619            "Abc",
620            "my_db",
621            "1abc",
622            &"a".repeat(41),
623        ] {
624            assert!(validate_name(n).is_err(), "{n} should be rejected");
625        }
626    }
627
628    #[test]
629    fn connection_parts_splits_host_and_port() {
630        let db: FixedDatabase = serde_json::from_value(serde_json::json!({
631            "publicEndpoint": "host.example.com:12000",
632            "security": { "enableTls": true, "password": "s3cr3t" }
633        }))
634        .unwrap();
635        let p = connection_parts(&db).unwrap();
636        assert_eq!(p.host, "host.example.com");
637        assert_eq!(p.port, "12000");
638        assert_eq!(p.username, "default");
639        assert!(p.tls);
640        assert_eq!(p.url, "rediss://default:s3cr3t@host.example.com:12000");
641    }
642
643    /// An absent `enableTls` must not be read as TLS. Free Essentials plans report
644    /// `supportSsl: false`, so claiming `rediss://` would tell the user their traffic is
645    /// encrypted when it is not.
646    /// The variable name is caller-supplied — an MCP client can set it — and is interpolated into
647    /// the credentials file, so a newline would inject lines of its own.
648    #[test]
649    fn variable_name_must_be_an_env_var_name() {
650        for ok in ["REDIS_URL", "_X", "a1", "MY_APP_REDIS_URL"] {
651            assert!(validate_variable(ok).is_ok(), "{ok} should be accepted");
652        }
653        for bad in [
654            "",
655            "1LEADING",
656            "HAS-DASH",
657            "HAS SPACE",
658            "REDIS_URL=x",
659            "A\nINJECTED=1",
660            "A\r\nINJECTED=1",
661        ] {
662            assert!(
663                validate_variable(bad).is_err(),
664                "{bad:?} should be rejected"
665            );
666        }
667    }
668
669    #[test]
670    fn connection_parts_does_not_assume_tls_when_the_field_is_absent() {
671        let db: FixedDatabase = serde_json::from_value(serde_json::json!({
672            "publicEndpoint": "host.example.com:12000",
673            "security": { "password": "s3cr3t" }
674        }))
675        .unwrap();
676        let p = connection_parts(&db).unwrap();
677        assert!(!p.tls);
678        assert_eq!(p.url, "redis://default:s3cr3t@host.example.com:12000");
679    }
680
681    #[test]
682    fn connection_parts_reports_tls_when_the_api_says_so() {
683        let db: FixedDatabase = serde_json::from_value(serde_json::json!({
684            "publicEndpoint": "host.example.com:12000",
685            "security": { "enableTls": false, "password": "s3cr3t" }
686        }))
687        .unwrap();
688        assert!(!connection_parts(&db).unwrap().tls);
689    }
690
691    #[test]
692    fn password_is_percent_encoded_in_url_but_raw_in_field() {
693        // A password with URL-reserved characters must not corrupt the connection string.
694        let db: FixedDatabase = serde_json::from_value(serde_json::json!({
695            "publicEndpoint": "h:1",
696            "security": { "enableTls": true, "password": "p@ss/w#rd%20x" }
697        }))
698        .unwrap();
699        let p = connection_parts(&db).unwrap();
700        // URL userinfo is percent-encoded (@ / # % all escaped), so the authority isn't
701        // corrupted and the host/port stay intact after the userinfo.
702        assert_eq!(p.url, "rediss://default:p%40ss%2Fw%23rd%2520x@h:1");
703        // The encoded userinfo round-trips back to the original password (what a client does).
704        assert_eq!(
705            urlencoding::decode("p%40ss%2Fw%23rd%2520x").unwrap(),
706            "p@ss/w#rd%20x"
707        );
708        // The broken-out field stays raw for apps that read it directly.
709        assert_eq!(p.password, "p@ss/w#rd%20x");
710    }
711
712    #[test]
713    fn plain_url_when_tls_off() {
714        let db: FixedDatabase = serde_json::from_value(serde_json::json!({
715            "publicEndpoint": "h:1",
716            "security": { "enableTls": false, "password": "p" }
717        }))
718        .unwrap();
719        assert_eq!(connection_parts(&db).unwrap().url, "redis://default:p@h:1");
720    }
721
722    #[test]
723    fn errors_when_password_missing() {
724        let db: FixedDatabase = serde_json::from_value(serde_json::json!({
725            "publicEndpoint": "h:1",
726            "security": { "enableTls": true }
727        }))
728        .unwrap();
729        assert!(connection_parts(&db).is_err());
730    }
731
732    #[test]
733    fn free_gate_error_classifies_as_free_db_exists() {
734        let err = CloudError::BadRequest {
735            message: "FREE_PLAN_IS_ALLOWED_ONLY_FOR_ACCOUNTS_WITH_VALID_PAYMENT_INFO".to_string(),
736        };
737        assert!(matches!(
738            classify_create_error(err),
739            QuickDatabaseError::FreeDbExists(_)
740        ));
741    }
742
743    #[test]
744    fn task_free_plan_error_classifies_as_free_db_exists() {
745        let e = classify_task_error("The account already has a free plan Essentials subscription.");
746        assert!(matches!(e, QuickDatabaseError::FreeDbExists(_)));
747    }
748
749    #[test]
750    fn report_serialization_carries_no_secrets() {
751        // The report is the entire tool/CLI response; it must never structurally carry the
752        // password or a connection URL (those go only to the credentials file).
753        let report = QuickDatabaseReport {
754            status: "ok".to_string(),
755            database: DatabaseSummary {
756                id: "9001".to_string(),
757                name: "my-app".to_string(),
758                region: Some("us-east-1".to_string()),
759                plan: "free".to_string(),
760                tls: true,
761            },
762            credentials_written_to: "./.env".to_string(),
763            credentials_variable: "REDIS_URL".to_string(),
764        };
765        let s = serde_json::to_string(&report).unwrap();
766        assert!(!s.contains("password"));
767        assert!(!s.contains("rediss://"));
768        assert!(!s.contains('@'));
769    }
770
771    #[test]
772    fn transient_5xx_classifies_as_transient() {
773        let err = CloudError::ServiceUnavailable {
774            message: "try later".to_string(),
775        };
776        assert!(matches!(
777            classify_cloud_error("x", err),
778            QuickDatabaseError::Transient(_)
779        ));
780    }
781}