Skip to main content

nomoreide_core/
cloudflare_provider.rs

1//! Cloudflare Pages seen through the vendor-neutral deploy contract.
2//!
3//! The Rust half of `src/core/cloudflare-provider.ts`. Where Vercel needed a
4//! second normalization because the desktop app already had one, Cloudflare
5//! needs one because Pages describes a deployment in nothing like Vercel's
6//! terms: a *stage* and its *status* rather than a ready state, an
7//! *environment* rather than a target, and an owner/repo pair rather than a
8//! repository URL.
9
10use serde_json::Value;
11
12use crate::cloudflare_manager::{
13    repo_url, CloudflareApiError, CloudflareEnvVar, CloudflareManager, PLAIN_TEXT,
14};
15use crate::providers::api_base::provider_api_host;
16use crate::providers::deploy::{
17    present, Deployment, DeploymentDetail, DeploymentMeta, DomainVerification, ProjectLink,
18    ProjectSetting, ProviderDomain, ProviderEnvVar, ProviderLogLine, ProviderProject,
19};
20use crate::providers::project_resolution::LinkFile;
21
22pub const CLOUDFLARE_PROVIDER_ID: &str = "cloudflare";
23
24/// Wrangler records the project it is bound to in `wrangler.toml`, not in a
25/// JSON link file, so Pages has no equivalent of `.vercel/project.json`.
26pub const CLOUDFLARE_LINK_FILE: Option<&LinkFile> = None;
27
28/// Pages' build settings, in the order the dashboard shows them.
29///
30/// Four rather than Vercel's six, and unlike Vercel's these are always
31/// reported: Pages nests them under `build_config` and omits the whole object
32/// for a project it has never built, so "absent" here would mean "never built"
33/// rather than "this setting does not exist".
34const SETTINGS: [(&str, &str); 4] = [
35    ("buildCommand", "Build command"),
36    ("outputDirectory", "Output directory"),
37    ("rootDirectory", "Root directory"),
38    ("productionBranch", "Production branch"),
39];
40
41/// The dashboard a deployment links to. Built as a string and never fetched,
42/// which is why `dash.cloudflare.com` is not on the egress allowlist — an
43/// allowlist covers what is requested, not what is displayed.
44///
45/// The project and deployment are escaped even though this is only ever
46/// displayed: a link is still a URL, and a name with a space in it would make
47/// one that does not open.
48fn inspector_url(account: &str, project: &str, deployment: &str) -> String {
49    format!(
50        "https://dash.cloudflare.com/{account}/pages/view/{}/{}",
51        urlencoding::encode(project),
52        urlencoding::encode(deployment)
53    )
54}
55
56/// Pages reports a stage and its status; only three of the statuses mean
57/// anything a person acts on, and everything else is work in progress.
58///
59/// A deployment with **no stage at all** is queued rather than building — it is
60/// a record Pages has created and not started. That is why this takes an
61/// `Option` rather than the defaulted status: `idle` on a real stage means the
62/// stage is running, and reading the two the same way would say a queued
63/// deployment is already building.
64fn state_of(status: Option<&str>) -> &'static str {
65    match status {
66        Some("success") => "ready",
67        Some("failure") => "error",
68        Some("canceled") => "canceled",
69        Some(_) => "building",
70        None => "queued",
71    }
72}
73
74/// A project's id **is its name**: Pages addresses projects by name, and the
75/// opaque `id` it also carries addresses nothing.
76pub fn project_from_raw(raw: &Value) -> ProviderProject {
77    let build = raw.get("build_config");
78    let field = |owner: Option<&Value>, key: &str| -> Value {
79        owner
80            .and_then(|value| value.get(key))
81            .cloned()
82            .unwrap_or(Value::Null)
83    };
84    let values = [
85        field(build, "build_command"),
86        field(build, "destination_dir"),
87        field(build, "root_dir"),
88        field(Some(raw), "production_branch"),
89    ];
90
91    ProviderProject {
92        id: raw.get("name").cloned(),
93        name: raw.get("name").cloned(),
94        // Pages does not detect a framework, and saying `null` is the honest
95        // answer rather than an omission.
96        framework: Value::Null,
97        updated_at: raw
98            .get("created_on")
99            .and_then(Value::as_str)
100            .and_then(epoch_ms)
101            .map(Value::from),
102        link: link_from_raw(raw.get("source")),
103        settings: SETTINGS
104            .iter()
105            .zip(values)
106            .map(|((key, label), value)| ProjectSetting { key, label, value })
107            .collect(),
108    }
109}
110
111fn link_from_raw(raw: Option<&Value>) -> Option<ProjectLink> {
112    let source = raw?;
113    let kind = source
114        .get("type")
115        .and_then(Value::as_str)
116        .filter(|kind| !kind.is_empty())?;
117    let config = source.get("config");
118    Some(ProjectLink {
119        kind: kind.to_string(),
120        org: config.and_then(|config| config.get("owner")).cloned(),
121        repo: config.and_then(|config| config.get("repo_name")).cloned(),
122        production_branch: config
123            .and_then(|config| config.get("production_branch"))
124            .cloned(),
125    })
126}
127
128/// The `owner/repo` pair Pages stores, for comparing against a git remote.
129fn project_repo_url(raw: &Value) -> Option<String> {
130    let config = raw.get("source")?.get("config")?;
131    let owner = config.get("owner")?.as_str()?;
132    let repo = config.get("repo_name")?.as_str()?;
133    Some(format!("{owner}/{repo}").to_lowercase())
134}
135
136/// The two states a Pages deployment reports: the vendor-neutral one, and the
137/// `stage:status` pair the vendor's own UI shows.
138fn states_of(raw: &Value) -> (&'static str, String) {
139    // A skipped deployment never ran, so its stage says nothing useful — it is
140    // reported as its own state rather than as whatever stage it stopped at.
141    if raw.get("is_skipped").and_then(Value::as_bool) == Some(true) {
142        return ("canceled", "skipped".to_string());
143    }
144    let stage = raw.get("latest_stage");
145    let stage_name = stage
146        .and_then(|stage| stage.get("name"))
147        .and_then(Value::as_str)
148        .unwrap_or("queued");
149    let status = stage
150        .and_then(|stage| stage.get("status"))
151        .and_then(Value::as_str);
152    (
153        state_of(status),
154        format!("{stage_name}:{}", status.unwrap_or("idle")),
155    )
156}
157
158/// `project` is the project the caller *resolved*, not the one the record
159/// names. They agree whenever Pages labelled the deployment — but the dashboard
160/// link has to open even when it did not, and the record's own label is the one
161/// field here that can be missing.
162pub fn deployment_from_raw(
163    raw: &Value,
164    account: &str,
165    project: &str,
166    canonical: Option<&str>,
167) -> Deployment {
168    let (state, raw_state) = states_of(raw);
169
170    let id = raw
171        .get("id")
172        .and_then(Value::as_str)
173        .unwrap_or_default()
174        .to_string();
175
176    Deployment {
177        // Current production is whichever deployment the project points at, not
178        // whichever one is newest and production-targeted: Pages can serve an
179        // older build after a rollback, and can serve a preview URL as the
180        // canonical one.
181        is_current_production: canonical.is_some_and(|canonical| canonical == id),
182        // The record's own label, always a string and empty when Pages did not
183        // set one — a record old enough to predate the field still has to
184        // render in a list whose name column the client reads unconditionally.
185        name: Some(Value::from(
186            raw.get("project_name")
187                .and_then(Value::as_str)
188                .unwrap_or_default(),
189        )),
190        // Always reported, `null` included: Pages assigns a URL the moment a
191        // build starts, so its absence is news rather than an omission.
192        url: Some(
193            raw.get("url")
194                .and_then(Value::as_str)
195                .map(hostname)
196                .map_or(Value::Null, Value::from),
197        ),
198        state: state.to_string(),
199        raw_state,
200        // Pages' default environment, and it labels every deployment with one —
201        // so an absent label is a record that predates the field rather than a
202        // deployment with no environment, and `null` would read as "unknown".
203        target: Value::from(
204            raw.get("environment")
205                .and_then(Value::as_str)
206                .unwrap_or("preview"),
207        ),
208        created_at: raw
209            .get("created_on")
210            .and_then(Value::as_str)
211            .and_then(epoch_ms)
212            .map(Value::from),
213        // Only a finished build has a moment it became ready; for anything else
214        // `modified_on` is just the last time the record changed.
215        ready_at: (state == "ready")
216            .then(|| {
217                raw.get("modified_on")
218                    .and_then(Value::as_str)
219                    .and_then(epoch_ms)
220            })
221            .flatten()
222            .map(Value::from),
223        creator: None,
224        meta: meta_from_raw(raw.get("deployment_trigger")),
225        inspector_url: Some(Value::from(inspector_url(account, project, &id))),
226        id: Value::from(id),
227    }
228}
229
230/// The commit, out of the trigger that caused the build. Pages records no
231/// author, so `commitAuthor` is simply absent rather than empty.
232fn meta_from_raw(raw: Option<&Value>) -> DeploymentMeta {
233    let metadata = raw.and_then(|trigger| trigger.get("metadata"));
234    let pick = |key: &str| metadata.and_then(|metadata| metadata.get(key)).cloned();
235    DeploymentMeta {
236        branch: pick("branch"),
237        sha: pick("commit_hash"),
238        commit_message: pick("commit_message"),
239        commit_author: None,
240    }
241}
242
243pub fn detail_from_raw(
244    raw: &Value,
245    account: &str,
246    project: &str,
247    canonical: Option<&str>,
248) -> DeploymentDetail {
249    let deployment = deployment_from_raw(raw, account, project, canonical);
250    // Pages records no failure message on the deployment — the reason is in the
251    // build log. Naming the stage that failed is the most a caller gets without
252    // a second request, and it is what tells "the build broke" apart from "the
253    // deploy broke".
254    let error_message = (deployment.state == "error").then(|| {
255        let stage = raw
256            .get("latest_stage")
257            .and_then(|stage| stage.get("name"))
258            .and_then(Value::as_str)
259            .unwrap_or("build");
260        Value::from(format!("The {stage} stage failed."))
261    });
262    DeploymentDetail {
263        deployment,
264        aliases: Value::Array(
265            raw.get("aliases")
266                .and_then(Value::as_array)
267                .map(|aliases| {
268                    aliases
269                        .iter()
270                        .filter_map(Value::as_str)
271                        .map(|alias| Value::from(hostname(alias)))
272                        .collect()
273                })
274                .unwrap_or_default(),
275        ),
276        // Pages has no separate build-start moment: a stage's `started_on` is
277        // when *that* stage began, not when the build did.
278        building_at: None,
279        error_message,
280    }
281}
282
283/// The host part of a URL Pages reports with a scheme, since every other
284/// provider reports a bare hostname.
285fn hostname(url: &str) -> String {
286    url.trim()
287        .trim_start_matches("https://")
288        .trim_start_matches("http://")
289        .trim_end_matches('/')
290        .to_string()
291}
292
293/// An ISO instant as epoch milliseconds, which is how every other provider
294/// reports a time.
295fn epoch_ms(value: &str) -> Option<i64> {
296    chrono::DateTime::parse_from_rfc3339(value)
297        .ok()
298        .map(|parsed| parsed.timestamp_millis())
299}
300
301/// One variable, in the neutral shape.
302///
303/// Cloudflare has no variable ids, so the key is both — which is what makes
304/// the reveal and update routes addressable at all.
305pub fn env_from_merged(variable: &CloudflareEnvVar) -> ProviderEnvVar {
306    ProviderEnvVar {
307        id: Some(Value::String(variable.key.clone())),
308        key: Some(Value::String(variable.key.clone())),
309        environments: variable
310            .environments
311            .iter()
312            .map(|environment| Value::String(environment.clone()))
313            .collect(),
314        kind: Value::String(
315            if variable.kind == PLAIN_TEXT {
316                "plain"
317            } else {
318                "encrypted"
319            }
320            .into(),
321        ),
322        // Cloudflare has neither concept, and reporting them as null would
323        // claim it had asked and found nothing.
324        git_branch: None,
325        comment: None,
326        created_at: None,
327        updated_at: None,
328    }
329}
330
331/// One custom domain, in the neutral shape.
332///
333/// A domain that is not yet active *and* has a TXT record to add reports that
334/// record as the one thing the user can do about it. A domain that is merely
335/// pending with nothing to copy reports no record, because an empty row to
336/// paste is worse than none.
337fn domain_from_raw(raw: &Value) -> ProviderDomain {
338    let status = raw
339        .get("status")
340        .and_then(Value::as_str)
341        .unwrap_or("pending");
342    let validation = raw.get("validation_data").filter(|value| !value.is_null());
343    let txt_name = validation
344        .and_then(|data| data.get("txt_name"))
345        .and_then(Value::as_str)
346        .filter(|name| !name.is_empty());
347
348    let verification = match txt_name.filter(|_| status != "active") {
349        Some(txt_name) => vec![DomainVerification {
350            kind: Value::String("TXT".into()),
351            domain: Value::String(txt_name.to_string()),
352            value: Value::String(
353                validation
354                    .and_then(|data| data.get("txt_value"))
355                    .and_then(Value::as_str)
356                    .unwrap_or_default()
357                    .to_string(),
358            ),
359            // Cloudflare puts the reason in either of two places; when it gives
360            // none, the status is the only thing there is to say.
361            reason: Some(
362                validation
363                    .and_then(|data| present(data.get("error_message")))
364                    .or_else(|| {
365                        present(
366                            raw.get("verification_data")
367                                .and_then(|data| data.get("error_message")),
368                        )
369                    })
370                    .unwrap_or_else(|| Value::String(status.to_string())),
371            ),
372        }],
373        None => Vec::new(),
374    };
375
376    ProviderDomain {
377        name: raw.get("name").cloned(),
378        // Pages has no apex grouping, no redirects, no per-branch domains and
379        // no modification time, so each is absent rather than null.
380        apex_name: None,
381        verified: status == "active",
382        redirect: None,
383        git_branch: None,
384        created_at: raw
385            .get("created_on")
386            .and_then(Value::as_str)
387            .and_then(epoch_ms)
388            .map(Value::from),
389        updated_at: None,
390        verification,
391    }
392}
393
394/// A connected Cloudflare client answering in the vendor-neutral shapes.
395pub struct CloudflareDeployProvider {
396    manager: CloudflareManager,
397}
398
399impl CloudflareDeployProvider {
400    pub fn new(manager: CloudflareManager) -> Self {
401        Self { manager }
402    }
403
404    fn account(&self) -> String {
405        self.manager.account_id().unwrap_or_default().to_string()
406    }
407
408    /// Who the credential belongs to — `/user`, or the token's own identity
409    /// when `/user` is out of its scope. See [`CloudflareManager::viewer`].
410    pub async fn viewer(&self) -> Result<Value, CloudflareApiError> {
411        self.manager.viewer().await
412    }
413
414    /// The accounts this credential can act as.
415    ///
416    /// Cloudflare accounts have no slug, so the id addresses one and is
417    /// reported as both; a nameless account is offered under its id rather
418    /// than as a blank row.
419    pub async fn list_scopes(&self) -> Result<Vec<Value>, CloudflareApiError> {
420        Ok(self
421            .manager
422            .list_accounts()
423            .await?
424            .iter()
425            .map(|raw| {
426                let id = raw.get("id").filter(|value| !value.is_null()).cloned();
427                let name = raw
428                    .get("name")
429                    .filter(|value| !value.is_null())
430                    .cloned()
431                    .or_else(|| id.clone());
432                let mut scope = serde_json::Map::new();
433                for (key, value) in [("id", id.clone()), ("slug", id), ("name", name)] {
434                    if let Some(value) = value {
435                        scope.insert(key.into(), value);
436                    }
437                }
438                Value::Object(scope)
439            })
440            .collect())
441    }
442
443    pub async fn list_env(&self, project: &str) -> Result<Vec<ProviderEnvVar>, CloudflareApiError> {
444        Ok(self
445            .manager
446            .list_env(project)
447            .await?
448            .iter()
449            .map(env_from_merged)
450            .collect())
451    }
452
453    pub async fn get_env_value(
454        &self,
455        project: &str,
456        key: &str,
457    ) -> Result<String, CloudflareApiError> {
458        self.manager.env_value(project, key).await
459    }
460
461    /// The project's domains, including the `*.pages.dev` host Cloudflare
462    /// assigns.
463    ///
464    /// `/domains` lists **custom** domains only, so a project serving perfectly
465    /// well reads as having none — while Vercel's equivalent endpoint includes
466    /// the vendor-assigned `*.vercel.app`. Both render through the same generic
467    /// view, so the asymmetry showed up on a live account as "no domains"
468    /// beside a site anyone could load. The assigned host goes last, the way
469    /// Vercel orders its own, so a custom domain still leads.
470    ///
471    /// A failed *project* read degrades to the custom domains alone: the panel
472    /// is still correct, just missing the assigned host.
473    pub async fn list_domains(
474        &self,
475        project: &str,
476    ) -> Result<Vec<ProviderDomain>, CloudflareApiError> {
477        let (custom, project_raw) = tokio::join!(
478            self.manager.list_domains_raw(project),
479            self.manager.get_project_raw(project)
480        );
481        let mut domains: Vec<ProviderDomain> = custom?.iter().map(domain_from_raw).collect();
482        let Ok(project_raw) = project_raw else {
483            return Ok(domains);
484        };
485        let Some(subdomain) = project_raw
486            .get("subdomain")
487            .and_then(Value::as_str)
488            .filter(|subdomain| !subdomain.is_empty())
489        else {
490            return Ok(domains);
491        };
492        if domains
493            .iter()
494            .any(|domain| domain.name.as_ref().and_then(Value::as_str) == Some(subdomain))
495        {
496            return Ok(domains);
497        }
498        domains.push(ProviderDomain {
499            name: Some(Value::String(subdomain.to_string())),
500            apex_name: None,
501            // The assigned host is always serving; there is nothing to verify.
502            verified: true,
503            redirect: None,
504            git_branch: None,
505            created_at: project_raw
506                .get("created_on")
507                .and_then(Value::as_str)
508                .and_then(epoch_ms)
509                .map(Value::from),
510            updated_at: None,
511            verification: Vec::new(),
512        });
513        Ok(domains)
514    }
515
516    /// Pages has no server-side project search, so the filter is applied here —
517    /// on the name, which is also the id a caller would act on.
518    pub async fn list_projects(
519        &self,
520        search: Option<&str>,
521    ) -> Result<Vec<ProviderProject>, CloudflareApiError> {
522        let projects = self.manager.list_projects_raw().await?;
523        let needle = search.map(str::to_lowercase);
524        Ok(projects
525            .iter()
526            .filter(|raw| match &needle {
527                None => true,
528                Some(needle) => raw
529                    .get("name")
530                    .and_then(Value::as_str)
531                    .is_some_and(|name| name.to_lowercase().contains(needle)),
532            })
533            .map(project_from_raw)
534            .collect())
535    }
536
537    pub async fn get_project(&self, name: &str) -> Result<ProviderProject, CloudflareApiError> {
538        Ok(project_from_raw(&self.manager.get_project_raw(name).await?))
539    }
540
541    /// The project imported from this git remote, found by walking the listing:
542    /// Pages offers no lookup by repository.
543    pub async fn find_by_repo_url(
544        &self,
545        repo: &str,
546    ) -> Result<Option<ProviderProject>, CloudflareApiError> {
547        Ok(self
548            .manager
549            .list_projects_raw()
550            .await?
551            .iter()
552            .find(|raw| project_repo_url(raw).as_deref() == Some(repo))
553            .map(project_from_raw))
554    }
555
556    pub async fn list_deployments(
557        &self,
558        project: &str,
559        target: Option<&str>,
560        limit: u32,
561    ) -> Result<Vec<Deployment>, CloudflareApiError> {
562        // Issued alongside the listing rather than after it. The listing is a
563        // multi-page walk, and waiting for it before asking which deployment is
564        // canonical would add a round trip to every read.
565        let (deployments, canonical) = tokio::join!(
566            self.manager
567                .list_deployments_raw(project, target, limit as usize),
568            self.canonical_deployment(project)
569        );
570        let deployments = deployments?;
571        let account = self.account();
572        Ok(deployments
573            .iter()
574            .map(|raw| deployment_from_raw(raw, &account, project, canonical.as_deref()))
575            .collect())
576    }
577
578    pub async fn get_deployment(
579        &self,
580        project: &str,
581        deployment: &str,
582    ) -> Result<DeploymentDetail, CloudflareApiError> {
583        let (raw, canonical) = tokio::join!(
584            self.manager.get_deployment_raw(project, deployment),
585            self.canonical_deployment(project)
586        );
587        Ok(detail_from_raw(
588            &raw?,
589            &self.account(),
590            project,
591            canonical.as_deref(),
592        ))
593    }
594
595    pub async fn build_logs(
596        &self,
597        project: &str,
598        deployment: &str,
599    ) -> Result<Vec<ProviderLogLine>, CloudflareApiError> {
600        Ok(self
601            .manager
602            .build_logs_raw(project, deployment)
603            .await?
604            .iter()
605            // Numbered before the empty lines are dropped, because the index is
606            // part of the fallback id: filtering first would renumber every
607            // line after a blank one.
608            .enumerate()
609            .filter_map(|(index, entry)| {
610                // Trailing whitespace goes and leading whitespace stays, the
611                // same way Vercel's build log is read — indentation is what
612                // makes a build log legible, and a line that is only whitespace
613                // is a hole in it.
614                let text = entry.get("line").and_then(Value::as_str)?.trim_end();
615                if text.is_empty() {
616                    return None;
617                }
618                let stamp = entry.get("ts").and_then(Value::as_str);
619                Some(ProviderLogLine::build(
620                    // Pages numbers nothing, so the id is the timestamp it did
621                    // send — a string, not a number — paired with the position.
622                    match stamp {
623                        Some(ts) => format!("{ts}-{index}"),
624                        None => format!("{index}-{index}"),
625                    },
626                    stamp.and_then(epoch_ms).unwrap_or(0),
627                    // Pages does not separate its streams, so every line is
628                    // stdout rather than a level the vendor chose.
629                    "stdout".to_string(),
630                    text.to_string(),
631                ))
632            })
633            .collect())
634    }
635
636    /// Which deployment the project currently serves. A failure to read it is
637    /// not a failure to list deployments — it only means none is marked.
638    async fn canonical_deployment(&self, project: &str) -> Option<String> {
639        self.manager
640            .get_project_raw(project)
641            .await
642            .ok()?
643            .get("canonical_deployment")?
644            .get("id")?
645            .as_str()
646            .map(str::to_string)
647    }
648}
649
650/// The `owner/repo` a git remote reduces to, for matching Pages' own pair.
651pub fn cloudflare_repo_url(remote: &str) -> Option<String> {
652    repo_url(remote)
653}
654
655/// The manifest the dashboard renders a tab from.
656///
657/// `requiresScope` is the field Vercel has no use for: a Cloudflare token is
658/// account-scoped, so a connection that has not chosen an account cannot ask
659/// for anything yet.
660pub fn manifest() -> Value {
661    serde_json::json!({
662        "id": "cloudflare",
663        "name": "Cloudflare",
664        "kind": "deploy",
665        "strings": {
666            "en": {
667                "scope.label": "Cloudflare account",
668                "action.redeploy": "Retry build",
669                "action.redeploy.done": "Build retried.",
670                "action.rollback": "Roll back",
671                "action.rollback.done": "Rolled back production.",
672                "action.rollback.confirmTitle": "Roll production back?",
673                "action.rollback.confirm": "Production traffic switches back to this older deployment immediately."
674            },
675            "zh": {
676                "scope.label": "Cloudflare 账户",
677                "action.redeploy": "重试构建",
678                "action.redeploy.done": "已重试构建。",
679                "action.rollback": "回滚",
680                "action.rollback.done": "已回滚生产环境。",
681                "action.rollback.confirmTitle": "回滚生产环境?",
682                "action.rollback.confirm": "生产流量将立即切回这个较旧的部署。"
683            }
684        },
685        "authSources": [
686            "cli",
687            "stored"
688        ],
689        "capabilities": [
690            "projects",
691            "deployments",
692            "buildLogs",
693            "env",
694            "domains"
695        ],
696        "requiresScope": true,
697        "actions": [
698            "redeploy",
699            "rollback"
700        ],
701        "productionAffecting": [
702            "rollback"
703        ],
704        // `dash.cloudflare.com` is deliberately absent: the manager builds
705        // dashboard and deployment URLs as strings for the UI to link to, and
706        // never fetches them. An allowlist covers what is requested, not what
707        // is displayed.
708        //
709        // Derived from the base URL rather than written out, so the allowlist
710        // and the place requests actually go cannot drift apart — including
711        // when `NOMOREIDE_CLOUDFLARE_API_BASE` points them at a loopback stub.
712        "api": {
713            "hosts": [
714                provider_api_host(&crate::cloudflare_manager::api_base())
715            ]
716        }
717    })
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use serde_json::json;
724
725    #[test]
726    fn a_projects_id_is_its_name() {
727        let project = project_from_raw(&json!({"id": "prj_opaque", "name": "app"}));
728        assert_eq!(project.id, Some(json!("app")));
729        assert_eq!(project.name, Some(json!("app")));
730    }
731
732    /// Unlike Vercel's, these are always reported: Pages omits the whole
733    /// `build_config` for a project it has never built.
734    #[test]
735    fn every_setting_is_reported_even_when_the_project_has_none() {
736        let project = project_from_raw(&json!({"name": "app"}));
737        assert_eq!(project.settings.len(), 4);
738        assert!(project
739            .settings
740            .iter()
741            .all(|setting| setting.value.is_null()));
742    }
743
744    #[test]
745    fn a_skipped_deployment_is_canceled_and_says_so_plainly() {
746        let deployment = deployment_from_raw(
747            &json!({"id": "d", "is_skipped": true, "latest_stage": {"name": "deploy", "status": "success"}}),
748            "acc",
749            "app",
750            None,
751        );
752        assert_eq!(deployment.state, "canceled");
753        assert_eq!(deployment.raw_state, "skipped");
754    }
755
756    /// A stage that is running is *building*; no stage at all is *queued*.
757    #[test]
758    fn a_missing_stage_reads_as_queued_and_idle() {
759        let deployment = deployment_from_raw(&json!({"id": "d"}), "acc", "app", None);
760        assert_eq!(deployment.state, "queued");
761        assert_eq!(deployment.raw_state, "queued:idle");
762    }
763
764    /// Pages can serve an older build after a rollback, so "current
765    /// production" is what the project points at and not what is newest.
766    #[test]
767    fn current_production_is_the_canonical_deployment() {
768        let raw = json!({"id": "d1", "environment": "preview"});
769        assert!(deployment_from_raw(&raw, "acc", "app", Some("d1")).is_current_production);
770        assert!(!deployment_from_raw(&raw, "acc", "app", Some("d2")).is_current_production);
771        assert!(!deployment_from_raw(&raw, "acc", "app", None).is_current_production);
772    }
773
774    #[test]
775    fn only_a_finished_build_has_a_ready_moment() {
776        let ready = json!({
777            "id": "d", "latest_stage": {"name": "deploy", "status": "success"},
778            "modified_on": "2026-02-01T10:05:00Z"
779        });
780        assert!(deployment_from_raw(&ready, "acc", "app", None)
781            .ready_at
782            .is_some());
783        let failed = json!({
784            "id": "d", "latest_stage": {"name": "deploy", "status": "failure"},
785            "modified_on": "2026-02-01T10:05:00Z"
786        });
787        assert!(deployment_from_raw(&failed, "acc", "app", None)
788            .ready_at
789            .is_none());
790    }
791
792    #[test]
793    fn urls_and_aliases_are_reported_as_bare_hostnames() {
794        let detail = detail_from_raw(
795            &json!({"id": "d", "url": "https://d.pages.dev", "aliases": ["https://a.pages.dev", "b.pages.dev"]}),
796            "acc",
797            "app",
798            None,
799        );
800        assert_eq!(detail.deployment.url, Some(json!("d.pages.dev")));
801        assert_eq!(detail.aliases, json!(["a.pages.dev", "b.pages.dev"]));
802    }
803}