Skip to main content

nomoreide_core/
vercel_manager.rs

1//! Read-safe Vercel REST client — the Rust counterpart of
2//! `src/core/vercel-manager.ts`.
3//!
4//! Like `git_manager`, this module deliberately contains no operation that
5//! changes what is deployed. Redeploy / cancel / promote / rollback live in
6//! `vercel_actions.rs`, so the read/write split the Node side enforces survives
7//! into the desktop app rather than being flattened by the port.
8//!
9//! Responses are passed through as `serde_json::Value` and normalized on the
10//! way out, so the frontend sees the same shapes both backends produce.
11
12use serde_json::Value;
13
14use crate::providers::api_base::{provider_api_base, provider_api_host};
15use crate::providers::egress::ProviderEgress;
16
17/// Where account identity comes from. `User` is Vercel's `/v2/user`; `Oidc` is
18/// the issuer's userinfo endpoint, which is what an OAuth browser sign-in
19/// answers on — a browser sign-in has no legacy user record and 404s on
20/// `/v2/user`. The caller states which it holds rather than having this infer
21/// it from a failed request, so a genuine authorization error still surfaces.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum Identity {
24    User,
25    Oidc,
26}
27
28const API_BASE: &str = "https://api.vercel.com";
29
30/// Vercel's API, or the loopback stand-in an environment override names.
31///
32/// One host covers everything this client asks for: the REST API and the OIDC
33/// userinfo endpoint a browser sign-in uses are both on it.
34pub fn api_base() -> String {
35    provider_api_base("NOMOREIDE_VERCEL_API_BASE", API_BASE)
36}
37
38/// The scoped sender every Vercel request goes through, with its allowlist
39/// derived from the base URL so the two cannot drift apart.
40fn egress() -> ProviderEgress {
41    ProviderEgress::new("vercel", vec![provider_api_host(&api_base())])
42}
43
44#[derive(Debug, Clone)]
45pub struct VercelApiError {
46    pub message: String,
47    pub status: u16,
48}
49
50impl std::fmt::Display for VercelApiError {
51    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        write!(formatter, "{}", self.message)
53    }
54}
55
56/// Auth + scope for a single Vercel request. Shared with `vercel_actions` so
57/// auth, team scoping, and error shaping stay identical across the read and
58/// write halves — the boundary is which module exposes an operation, not which
59/// one can reach the network.
60#[derive(Debug, Clone)]
61pub struct RequestAuth {
62    pub token: String,
63    pub team_id: Option<String>,
64}
65
66/// The one place a Vercel API call is made.
67pub async fn request(
68    auth: &RequestAuth,
69    method: &str,
70    path: &str,
71    accept: Option<&str>,
72) -> Result<Value, VercelApiError> {
73    let text = request_text(auth, method, path, accept).await?;
74    if accept == Some("text/plain") {
75        return Ok(Value::String(text));
76    }
77    serde_json::from_str(&text).or(Ok(Value::Null))
78}
79
80pub async fn request_text(
81    auth: &RequestAuth,
82    method: &str,
83    path: &str,
84    accept: Option<&str>,
85) -> Result<String, VercelApiError> {
86    request_text_with(auth, method, path, accept, None).await
87}
88
89/// The same request, carrying a JSON body.
90///
91/// A separate entry point rather than a fifth argument on every read: only the
92/// write half sends a body, and every caller that does not should not have to
93/// say so. Both funnel into one sender, which is what keeps the write half
94/// inside the egress allowlist and pointed at the same base URL as the reads —
95/// the writes used to build their own client against a hard-coded
96/// `api.vercel.com`, which put them outside both.
97pub async fn request_json(
98    auth: &RequestAuth,
99    method: &str,
100    path: &str,
101    body: Option<&Value>,
102) -> Result<Value, VercelApiError> {
103    let text = request_text_with(auth, method, path, None, body).await?;
104    serde_json::from_str(&text).or(Ok(Value::Null))
105}
106
107async fn request_text_with(
108    auth: &RequestAuth,
109    method: &str,
110    path: &str,
111    accept: Option<&str>,
112    body: Option<&Value>,
113) -> Result<String, VercelApiError> {
114    let mut url = if path.starts_with("http") {
115        path.to_string()
116    } else {
117        format!("{}{path}", api_base())
118    };
119    if let Some(team_id) = auth.team_id.as_ref() {
120        url = with_team_scope(&url, team_id);
121    }
122
123    // `redirect(Policy::none())` so a 3xx comes back to be inspected rather
124    // than being followed past the allowlist; `egress` then checks every hop.
125    let client = reqwest::Client::builder()
126        .redirect(reqwest::redirect::Policy::none())
127        .build()
128        .map_err(|error| VercelApiError {
129            message: format!("Vercel request failed: {error}"),
130            status: 0,
131        })?;
132    let verb = match method {
133        "POST" => reqwest::Method::POST,
134        "PATCH" => reqwest::Method::PATCH,
135        "DELETE" => reqwest::Method::DELETE,
136        _ => reqwest::Method::GET,
137    };
138    let token = auth.token.clone();
139    let accept_header = accept.unwrap_or("application/json").to_string();
140    let payload = body.cloned();
141    let response = egress()
142        .send(
143            &client,
144            &url,
145            |client, verb, target| {
146                let request = client
147                    .request(verb, target)
148                    .header("Authorization", format!("Bearer {token}"))
149                    .header("Accept", accept_header.clone());
150                match payload.as_ref() {
151                    Some(payload) => request.json(payload),
152                    None => request,
153                }
154            },
155            verb,
156        )
157        .await
158        .map_err(|error| VercelApiError {
159            message: format!("Vercel request failed: {error}"),
160            status: 0,
161        })?;
162
163    let status = response.status();
164    let text = response.text().await.unwrap_or_default();
165    if status.is_success() {
166        return Ok(text);
167    }
168    Err(VercelApiError {
169        message: api_error_message(
170            &text,
171            status.as_u16(),
172            status.canonical_reason().unwrap_or_default(),
173            path,
174        ),
175        status: status.as_u16(),
176    })
177}
178
179/// One query-string value, encoded the way `URLSearchParams` encodes it.
180///
181/// Not `urlencoding::encode`, which is `encodeURIComponent` and writes a space
182/// as `%20`. The reference builds these queries with `URLSearchParams`, whose
183/// `application/x-www-form-urlencoded` serialization writes a space as `+` —
184/// and that string is quoted verbatim in the error a failed request reports,
185/// so it is visible to a caller and not only to the vendor.
186fn query_value(value: &str) -> String {
187    url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
188}
189
190/// Adds the team scope to a request, the way the reference adds it.
191///
192/// Not a string append: the reference reaches for `URL.searchParams.set`, which
193/// **re-serializes the whole query** as `application/x-www-form-urlencoded` —
194/// so a space that was written `%20` in the path comes back out as `+`. That is
195/// invisible until a caller searches for something with a space in it, and it
196/// is the difference between two runtimes asking a vendor the same question and
197/// two runtimes asking different ones. `query_pairs_mut` does the same encoding.
198///
199/// An existing `teamId` is dropped rather than kept beside the new one, which
200/// is what `set` means.
201fn with_team_scope(url: &str, team_id: &str) -> String {
202    let Ok(mut parsed) = url::Url::parse(url) else {
203        return url.to_string();
204    };
205    let existing: Vec<(String, String)> = parsed
206        .query_pairs()
207        .filter(|(key, _)| key != "teamId")
208        .map(|(key, value)| (key.into_owned(), value.into_owned()))
209        .collect();
210    parsed
211        .query_pairs_mut()
212        .clear()
213        .extend_pairs(existing)
214        .append_pair("teamId", team_id);
215    parsed.to_string()
216}
217
218/// Vercel wraps its failures in `{ error: { code, message } }`. When it sends
219/// no message the bare status says nothing about what was being asked, so the
220/// request is named instead — that string is what the dashboard shows, and it
221/// is the only clue the user gets.
222fn api_error_message(body: &str, status: u16, reason: &str, path: &str) -> String {
223    serde_json::from_str::<Value>(body)
224        .ok()
225        .and_then(|value| {
226            value
227                .get("error")
228                .and_then(|error| error.get("message"))
229                .and_then(Value::as_str)
230                .map(str::to_string)
231        })
232        .unwrap_or_else(|| format!("Vercel returned {status} {reason} for {path}"))
233}
234
235pub struct VercelManager {
236    auth: RequestAuth,
237    identity: Identity,
238}
239
240impl VercelManager {
241    pub fn new(token: String, team_id: Option<String>, identity: Identity) -> Self {
242        VercelManager {
243            auth: RequestAuth { token, team_id },
244            identity,
245        }
246    }
247
248    /// The signed-in account.
249    pub async fn viewer(&self) -> Result<Value, VercelApiError> {
250        if self.identity == Identity::Oidc {
251            let claims = request(&self.auth, "GET", "/login/oauth/userinfo", None).await?;
252            return Ok(serde_json::json!({
253                "id": claims.get("sub").and_then(Value::as_str).unwrap_or(""),
254                "username": claims
255                    .get("preferred_username")
256                    .and_then(Value::as_str)
257                    .or_else(|| claims.get("email").and_then(Value::as_str))
258                    .unwrap_or("vercel"),
259                "email": claims.get("email").cloned().unwrap_or(Value::Null),
260                "avatar": claims.get("picture").cloned().unwrap_or(Value::Null),
261            }));
262        }
263        let data = request(&self.auth, "GET", "/v2/user", None).await?;
264        Ok(data.get("user").cloned().unwrap_or(Value::Null))
265    }
266
267    pub async fn list_teams(&self) -> Result<Vec<Value>, VercelApiError> {
268        let data = request(&self.auth, "GET", "/v2/teams?limit=100", None).await?;
269        Ok(data
270            .get("teams")
271            .and_then(Value::as_array)
272            .map(|teams| {
273                teams
274                    .iter()
275                    .map(|team| {
276                        // `id` and `slug` are absent when Vercel sent none;
277                        // `name` is reported as null instead, because the
278                        // reference writes `team.name ?? null` and only that
279                        // one. These rows go on the wire, so the difference
280                        // between a missing key and a null one is observable.
281                        let mut row = serde_json::Map::new();
282                        for key in ["id", "slug"] {
283                            if let Some(value) = team.get(key) {
284                                row.insert(key.into(), value.clone());
285                            }
286                        }
287                        row.insert(
288                            "name".into(),
289                            team.get("name")
290                                .filter(|value| !value.is_null())
291                                .cloned()
292                                .unwrap_or(Value::Null),
293                        );
294                        Value::Object(row)
295                    })
296                    .collect()
297            })
298            .unwrap_or_default())
299    }
300
301    /// Vercel's own project records, exactly as it sent them.
302    ///
303    /// The desktop app reads {@link Self::list_projects}, whose shape it has
304    /// always had; the provider layer reads these, because the vendor-neutral
305    /// shape needs to tell a setting the user *cleared* from one this vendor
306    /// does not have — a distinction the desktop shape flattens to `null`.
307    pub async fn list_projects_raw(
308        &self,
309        search: Option<&str>,
310        repo_url: Option<&str>,
311        limit: Option<u32>,
312    ) -> Result<Vec<Value>, VercelApiError> {
313        let mut path = format!("/v10/projects?limit={}", limit.unwrap_or(50));
314        if let Some(search) = search.filter(|value| !value.is_empty()) {
315            path.push_str(&format!("&search={}", query_value(search)));
316        }
317        if let Some(repo_url) = repo_url {
318            path.push_str(&format!("&repoUrl={}", query_value(repo_url)));
319        }
320        let data = request(&self.auth, "GET", &path, None).await?;
321        Ok(match &data {
322            Value::Array(items) => items.clone(),
323            _ => data
324                .get("projects")
325                .and_then(Value::as_array)
326                .cloned()
327                .unwrap_or_default(),
328        })
329    }
330
331    pub async fn list_projects(
332        &self,
333        search: Option<&str>,
334        repo_url: Option<&str>,
335        limit: Option<u32>,
336    ) -> Result<Vec<Value>, VercelApiError> {
337        let projects = self.list_projects_raw(search, repo_url, limit).await?;
338        Ok(projects.iter().map(normalize_project).collect())
339    }
340
341    pub async fn get_project_raw(&self, id_or_name: &str) -> Result<Value, VercelApiError> {
342        let path = format!("/v9/projects/{}", urlencoding::encode(id_or_name));
343        request(&self.auth, "GET", &path, None).await
344    }
345
346    pub async fn get_project(&self, id_or_name: &str) -> Result<Value, VercelApiError> {
347        Ok(normalize_project(&self.get_project_raw(id_or_name).await?))
348    }
349
350    /// Vercel's own deployment records, filtered but not reshaped.
351    pub async fn list_deployments_raw(
352        &self,
353        project_id: &str,
354        target: Option<&str>,
355        limit: Option<u32>,
356    ) -> Result<Vec<Value>, VercelApiError> {
357        let mut path = format!(
358            "/v7/deployments?projectId={}&limit={}",
359            query_value(project_id),
360            limit.unwrap_or(20)
361        );
362        // Vercel has no `preview` target filter — preview deployments are the
363        // ones with a null target, so those are filtered below instead.
364        if target == Some("production") {
365            path.push_str("&target=production");
366        }
367        let data = request(&self.auth, "GET", &path, None).await?;
368        let deployments = data
369            .get("deployments")
370            .and_then(Value::as_array)
371            .cloned()
372            .unwrap_or_default();
373
374        if target == Some("preview") {
375            return Ok(deployments
376                .into_iter()
377                .filter(|deployment| {
378                    deployment.get("target") != Some(&Value::String("production".into()))
379                })
380                .collect());
381        }
382        Ok(deployments)
383    }
384
385    pub async fn list_deployments(
386        &self,
387        project_id: &str,
388        target: Option<&str>,
389        limit: Option<u32>,
390    ) -> Result<Vec<Value>, VercelApiError> {
391        let deployments = self.list_deployments_raw(project_id, target, limit).await?;
392        Ok(deployments.iter().map(normalize_deployment).collect())
393    }
394
395    pub async fn get_deployment_raw(&self, id_or_url: &str) -> Result<Value, VercelApiError> {
396        let path = format!(
397            "/v13/deployments/{}?withGitRepoInfo=true",
398            urlencoding::encode(id_or_url)
399        );
400        request(&self.auth, "GET", &path, None).await
401    }
402
403    pub async fn get_deployment(&self, id_or_url: &str) -> Result<Value, VercelApiError> {
404        let raw = self.get_deployment_raw(id_or_url).await?;
405        let mut deployment = normalize_deployment(&raw);
406        if let Some(object) = deployment.as_object_mut() {
407            object.insert(
408                "aliases".into(),
409                raw.get("alias").cloned().unwrap_or(Value::Array(vec![])),
410            );
411            if let Some(building_at) = raw.get("buildingAt") {
412                object.insert("buildingAt".into(), building_at.clone());
413            }
414            if let Some(error_message) = raw.get("errorMessage").filter(|v| !v.is_null()) {
415                object.insert("errorMessage".into(), error_message.clone());
416            }
417        }
418        Ok(deployment)
419    }
420
421    /// Build logs for a deployment. Requested without `follow`, so the endpoint
422    /// answers as a one-shot document instead of an open event stream — the
423    /// dashboard polls while a build runs rather than holding a socket.
424    pub async fn deployment_build_logs(
425        &self,
426        id_or_url: &str,
427        limit: Option<u32>,
428    ) -> Result<Vec<Value>, VercelApiError> {
429        let path = format!(
430            "/v3/deployments/{}/events?builds=1&direction=backward&limit={}",
431            urlencoding::encode(id_or_url),
432            limit.unwrap_or(500)
433        );
434        let raw = request_text(&self.auth, "GET", &path, Some("text/plain")).await?;
435        Ok(parse_build_log_events(&raw))
436    }
437
438    /// The project's environment variables, without their values.
439    ///
440    /// `decrypt` is left off, so Vercel returns encrypted variables as
441    /// ciphertext — but plain and system ones would still come back readable,
442    /// so the value is dropped here for every type rather than for some of
443    /// them. One door for reading a value keeps that door easy to audit.
444    pub async fn list_env(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
445        let path = format!(
446            "/v9/projects/{}/env?limit=200",
447            urlencoding::encode(project_id)
448        );
449        let data = request(&self.auth, "GET", &path, None).await?;
450        let envs = match &data {
451            Value::Array(items) => items.clone(),
452            _ => data
453                .get("envs")
454                .and_then(Value::as_array)
455                .cloned()
456                .unwrap_or_default(),
457        };
458        let mut normalized: Vec<Value> = envs.iter().map(normalize_env_var).collect();
459        normalized.sort_by(|a, b| {
460            let key = |value: &Value| {
461                value
462                    .get("key")
463                    .and_then(Value::as_str)
464                    .unwrap_or("")
465                    .to_string()
466            };
467            key(a).cmp(&key(b))
468        });
469        Ok(normalized)
470    }
471
472    /// The project's variables exactly as Vercel sent them.
473    ///
474    /// The provider layer reads these rather than {@link Self::list_env}
475    /// because the desktop shape reports every absent field as `null`, and the
476    /// dashboard contract distinguishes a field the vendor omitted from one it
477    /// sent as null. Same reasoning as `list_projects_raw`.
478    pub async fn list_env_raw(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
479        let path = format!(
480            "/v9/projects/{}/env?limit=200",
481            urlencoding::encode(project_id)
482        );
483        let data = request(&self.auth, "GET", &path, None).await?;
484        Ok(match &data {
485            Value::Array(items) => items.clone(),
486            _ => data
487                .get("envs")
488                .and_then(Value::as_array)
489                .cloned()
490                .unwrap_or_default(),
491        })
492    }
493
494    /// The project's domains exactly as Vercel sent them — see
495    /// {@link Self::list_env_raw} for why the provider layer wants the raw ones.
496    pub async fn list_domains_raw(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
497        let path = format!(
498            "/v9/projects/{}/domains?limit=100",
499            urlencoding::encode(project_id)
500        );
501        let data = request(&self.auth, "GET", &path, None).await?;
502        Ok(data
503            .get("domains")
504            .and_then(Value::as_array)
505            .cloned()
506            .unwrap_or_default())
507    }
508
509    /// One variable's decrypted value. Deliberately a single-key read: the
510    /// dashboard reveals one row at a time on an explicit click, and nothing
511    /// else has a reason to hold every secret at once.
512    pub async fn env_value(
513        &self,
514        project_id: &str,
515        env_id: &str,
516    ) -> Result<String, VercelApiError> {
517        let path = format!(
518            "/v9/projects/{}/env/{}",
519            urlencoding::encode(project_id),
520            urlencoding::encode(env_id)
521        );
522        let data = request(&self.auth, "GET", &path, None).await?;
523        Ok(data
524            .get("value")
525            .and_then(Value::as_str)
526            .unwrap_or("")
527            .to_string())
528    }
529
530    pub async fn list_domains(&self, project_id: &str) -> Result<Vec<Value>, VercelApiError> {
531        let path = format!(
532            "/v9/projects/{}/domains?limit=100",
533            urlencoding::encode(project_id)
534        );
535        let data = request(&self.auth, "GET", &path, None).await?;
536        Ok(data
537            .get("domains")
538            .and_then(Value::as_array)
539            .map(|domains| domains.iter().map(normalize_domain).collect())
540            .unwrap_or_default())
541    }
542
543    /// Runtime (function) logs, as opposed to build logs: why a *deployed*
544    /// request failed rather than why the build did.
545    ///
546    /// Not available on every plan, so a plan-gated refusal yields an empty
547    /// list — an absent capability is not a broken dashboard. Genuine auth
548    /// failures still surface.
549    pub async fn deployment_runtime_logs(
550        &self,
551        id_or_url: &str,
552        limit: Option<u32>,
553    ) -> Result<Vec<Value>, VercelApiError> {
554        let path = format!(
555            "/v1/deployments/{}/runtime-logs?limit={}",
556            urlencoding::encode(id_or_url),
557            limit.unwrap_or(200)
558        );
559        match request_text(&self.auth, "GET", &path, Some("text/plain")).await {
560            Ok(raw) => Ok(parse_runtime_log_events(&raw)),
561            Err(error) if RUNTIME_LOGS_UNAVAILABLE.contains(&error.status) => Ok(vec![]),
562            Err(error) => Err(error),
563        }
564    }
565}
566
567/// Statuses that mean "this account cannot use runtime logs" rather than "the
568/// request was wrong": plan-gated (402), not entitled (403), or the endpoint
569/// not being served for this deployment (404).
570const RUNTIME_LOGS_UNAVAILABLE: [u16; 3] = [402, 403, 404];
571
572/// Vercel exposes a repo's project via the exact remote URL it was imported with.
573pub fn repo_url(remote_url: &str) -> Option<String> {
574    let trimmed = remote_url
575        .trim()
576        .trim_end_matches(".git")
577        .trim_end_matches('/');
578    if let Some(rest) = trimmed.strip_prefix("git@") {
579        let (host, path) = rest.split_once(':')?;
580        return Some(format!("https://{host}/{path}"));
581    }
582    if trimmed.starts_with("https://") {
583        return Some(trimmed.to_string());
584    }
585    if let Some(rest) = trimmed.strip_prefix("http://") {
586        return Some(format!("https://{rest}"));
587    }
588    None
589}
590
591/// The events endpoint answers either a JSON array or newline-delimited JSON
592/// depending on how it decides to stream, so both shapes are accepted and
593/// anything unparseable is dropped rather than failing the whole log read.
594pub fn parse_build_log_events(raw: &str) -> Vec<Value> {
595    let trimmed = raw.trim();
596    if trimmed.is_empty() {
597        return vec![];
598    }
599
600    let mut events: Vec<Value> = Vec::new();
601    match serde_json::from_str::<Value>(trimmed) {
602        Ok(Value::Array(items)) => events.extend(items),
603        Ok(other) => events.push(other),
604        Err(_) => {
605            for line in trimmed.lines().filter(|line| !line.trim().is_empty()) {
606                if let Ok(event) = serde_json::from_str::<Value>(line) {
607                    events.push(event);
608                }
609            }
610        }
611    }
612
613    let mut lines: Vec<Value> = events
614        .iter()
615        .enumerate()
616        .map(|(index, event)| {
617            let payload = event.get("payload");
618            let created = event.get("created").and_then(Value::as_i64);
619            let created_at = payload
620                .and_then(|p| p.get("date"))
621                .and_then(Value::as_i64)
622                .or(created)
623                .unwrap_or(0);
624            let id = payload
625                .and_then(|p| p.get("id"))
626                .and_then(Value::as_str)
627                .map(str::to_string)
628                .unwrap_or_else(|| format!("{}-{index}", created.unwrap_or(index as i64)));
629            let text = payload
630                .and_then(|p| p.get("text"))
631                .and_then(Value::as_str)
632                .unwrap_or("");
633            serde_json::json!({
634                "id": id,
635                "createdAt": created_at,
636                "type": event.get("type").and_then(Value::as_str).unwrap_or("stdout"),
637                "text": strip_ansi(text).trim_end(),
638            })
639        })
640        .filter(|line| {
641            line.get("text")
642                .and_then(Value::as_str)
643                .is_some_and(|text| !text.is_empty())
644        })
645        .collect();
646
647    lines.sort_by_key(|line| line.get("createdAt").and_then(Value::as_i64).unwrap_or(0));
648    lines
649}
650
651/// Build output is ANSI-colored; strip the SGR codes so the log renders as
652/// plain text rather than as escape soup.
653fn strip_ansi(text: &str) -> String {
654    let mut out = String::with_capacity(text.len());
655    let mut chars = text.chars().peekable();
656    while let Some(character) = chars.next() {
657        if character != '\u{1b}' {
658            out.push(character);
659            continue;
660        }
661        if chars.peek() != Some(&'[') {
662            continue;
663        }
664        chars.next();
665        // Consume the parameter bytes and the final letter of the sequence.
666        for inner in chars.by_ref() {
667            if !inner.is_ascii_digit() && inner != ';' {
668                break;
669            }
670        }
671    }
672    out
673}
674
675/// Runtime logs arrive as newline-delimited JSON. Unparseable lines are dropped
676/// rather than failing the read — a truncated final line is normal for a stream
677/// that was cut off at the limit.
678pub fn parse_runtime_log_events(raw: &str) -> Vec<Value> {
679    let trimmed = raw.trim();
680    if trimmed.is_empty() {
681        return vec![];
682    }
683
684    let mut rows: Vec<Value> = Vec::new();
685    for line in trimmed.lines().filter(|line| !line.trim().is_empty()) {
686        match serde_json::from_str::<Value>(line) {
687            Ok(Value::Array(items)) => rows.extend(items),
688            Ok(other) => rows.push(other),
689            Err(_) => {}
690        }
691    }
692
693    let mut lines: Vec<Value> = rows
694        .iter()
695        .enumerate()
696        .map(|(index, row)| {
697            let created_at = row
698                .get("timestampInMs")
699                .or_else(|| row.get("timestamp"))
700                .and_then(Value::as_i64)
701                .unwrap_or(0);
702            // The fallback id reads `timestampInMs` only — not the `timestamp`
703            // that `createdAt` falls back to. A row with the second but not the
704            // first is identified by its index twice over, which looks like an
705            // oversight and is the contract.
706            let id = row
707                .get("rowId")
708                .or_else(|| row.get("requestId"))
709                .and_then(Value::as_str)
710                .map(str::to_string)
711                .unwrap_or_else(|| {
712                    let stamp = row
713                        .get("timestampInMs")
714                        .and_then(Value::as_i64)
715                        .map(|value| value.to_string())
716                        .unwrap_or_else(|| index.to_string());
717                    format!("{stamp}-{index}")
718                });
719            let mut line = serde_json::Map::new();
720            line.insert("id".into(), Value::String(id));
721            line.insert("createdAt".into(), Value::from(created_at));
722            line.insert(
723                "level".into(),
724                Value::from(row.get("level").and_then(Value::as_str).unwrap_or("info")),
725            );
726            line.insert(
727                "message".into(),
728                Value::from(
729                    row.get("message")
730                        .and_then(Value::as_str)
731                        .unwrap_or("")
732                        .trim_end(),
733                ),
734            );
735            // Carried by *presence*, not by value: a vendor that sent an
736            // explicit null said something, and the difference reaches the
737            // client — `JSON.stringify` drops an absent field and keeps a null
738            // one, and the request badge is built from whether the key is there.
739            for key in ["source", "statusCode", "requestMethod", "requestPath"] {
740                if let Some(value) = row.get(key) {
741                    line.insert(key.into(), value.clone());
742                }
743            }
744            Value::Object(line)
745        })
746        .filter(|line| {
747            line.get("message")
748                .and_then(Value::as_str)
749                .is_some_and(|message| !message.is_empty())
750        })
751        .collect();
752
753    lines.sort_by_key(|line| line.get("createdAt").and_then(Value::as_i64).unwrap_or(0));
754    lines
755}
756
757fn normalize_env_var(env: &Value) -> Value {
758    let target = match env.get("target") {
759        Some(Value::Array(items)) => Value::Array(items.clone()),
760        Some(Value::String(single)) => Value::Array(vec![Value::String(single.clone())]),
761        _ => Value::Array(vec![]),
762    };
763    serde_json::json!({
764        "id": env
765            .get("id")
766            .or_else(|| env.get("key"))
767            .cloned()
768            .unwrap_or(Value::Null),
769        "key": env.get("key").cloned().unwrap_or(Value::Null),
770        "target": target,
771        "type": env.get("type").and_then(Value::as_str).unwrap_or("encrypted"),
772        "gitBranch": env.get("gitBranch").cloned().unwrap_or(Value::Null),
773        "comment": env.get("comment").cloned().unwrap_or(Value::Null),
774        "createdAt": env.get("createdAt").cloned().unwrap_or(Value::Null),
775        "updatedAt": env.get("updatedAt").cloned().unwrap_or(Value::Null),
776    })
777}
778
779fn normalize_domain(domain: &Value) -> Value {
780    let verification: Vec<Value> = domain
781        .get("verification")
782        .and_then(Value::as_array)
783        .map(|entries| {
784            entries
785                .iter()
786                .filter(|entry| {
787                    entry.get("domain").and_then(Value::as_str).is_some()
788                        && entry.get("value").and_then(Value::as_str).is_some()
789                })
790                .map(|entry| {
791                    serde_json::json!({
792                        "type": entry.get("type").and_then(Value::as_str).unwrap_or("TXT"),
793                        "domain": entry.get("domain").cloned().unwrap_or(Value::Null),
794                        "value": entry.get("value").cloned().unwrap_or(Value::Null),
795                        "reason": entry.get("reason").cloned().unwrap_or(Value::Null),
796                    })
797                })
798                .collect()
799        })
800        .unwrap_or_default();
801
802    serde_json::json!({
803        "name": domain.get("name").cloned().unwrap_or(Value::Null),
804        "apexName": domain.get("apexName").cloned().unwrap_or(Value::Null),
805        "verified": domain.get("verified").and_then(Value::as_bool).unwrap_or(false),
806        "redirect": domain.get("redirect").cloned().unwrap_or(Value::Null),
807        "gitBranch": domain.get("gitBranch").cloned().unwrap_or(Value::Null),
808        "createdAt": domain.get("createdAt").cloned().unwrap_or(Value::Null),
809        "updatedAt": domain.get("updatedAt").cloned().unwrap_or(Value::Null),
810        "verification": verification,
811    })
812}
813
814fn normalize_project(project: &Value) -> Value {
815    let link = project.get("link").filter(|link| {
816        link.get("type")
817            .and_then(Value::as_str)
818            .is_some_and(|kind| !kind.is_empty())
819    });
820    serde_json::json!({
821        "id": project.get("id").cloned().unwrap_or(Value::Null),
822        "name": project.get("name").cloned().unwrap_or(Value::Null),
823        "framework": project.get("framework").cloned().unwrap_or(Value::Null),
824        "updatedAt": project.get("updatedAt").cloned().unwrap_or(Value::Null),
825        "link": link.map(|link| serde_json::json!({
826            "type": link.get("type").cloned().unwrap_or(Value::Null),
827            "org": link.get("org").cloned().unwrap_or(Value::Null),
828            "repo": link.get("repo").cloned().unwrap_or(Value::Null),
829            "productionBranch": link.get("productionBranch").cloned().unwrap_or(Value::Null),
830        })).unwrap_or(Value::Null),
831        // Only the single-project read fills these in; null therefore reads as
832        // "Vercel is using the framework default", which is what it means.
833        "buildCommand": project.get("buildCommand").cloned().unwrap_or(Value::Null),
834        "devCommand": project.get("devCommand").cloned().unwrap_or(Value::Null),
835        "installCommand": project.get("installCommand").cloned().unwrap_or(Value::Null),
836        "outputDirectory": project.get("outputDirectory").cloned().unwrap_or(Value::Null),
837        "rootDirectory": project.get("rootDirectory").cloned().unwrap_or(Value::Null),
838        "nodeVersion": project.get("nodeVersion").cloned().unwrap_or(Value::Null),
839        "serverlessFunctionRegion": project
840            .get("serverlessFunctionRegion")
841            .cloned()
842            .unwrap_or(Value::Null),
843    })
844}
845
846fn normalize_deployment(deployment: &Value) -> Value {
847    let meta = deployment.get("meta");
848    let pick = |keys: [&str; 3]| -> Value {
849        keys.iter()
850            .find_map(|key| meta.and_then(|meta| meta.get(*key)).cloned())
851            .unwrap_or(Value::Null)
852    };
853    let target = deployment.get("target").cloned().unwrap_or(Value::Null);
854    let ready_substate = deployment.get("readySubstate").and_then(Value::as_str);
855
856    serde_json::json!({
857        "uid": deployment
858            .get("uid")
859            .or_else(|| deployment.get("id"))
860            .cloned()
861            .unwrap_or(Value::String(String::new())),
862        "name": deployment.get("name").cloned().unwrap_or(Value::Null),
863        "url": deployment.get("url").cloned().unwrap_or(Value::Null),
864        "state": deployment
865            .get("readyState")
866            .or_else(|| deployment.get("state"))
867            .cloned()
868            .unwrap_or(Value::String("QUEUED".into())),
869        "target": target,
870        "createdAt": deployment
871            .get("createdAt")
872            .or_else(|| deployment.get("created"))
873            .cloned()
874            .unwrap_or(Value::from(0)),
875        "readyAt": deployment
876            .get("readyAt")
877            .or_else(|| deployment.get("ready"))
878            .cloned()
879            .unwrap_or(Value::Null),
880        // `PROMOTED`/`ROLLING` mark the deployment currently aliased to
881        // production; `STAGED` means built for production but not serving it.
882        "isCurrentProduction": deployment.get("target").and_then(Value::as_str) == Some("production")
883            && ready_substate != Some("STAGED"),
884        "creator": deployment.get("creator").cloned().unwrap_or(Value::Null),
885        "meta": {
886            "branch": pick(["githubCommitRef", "gitlabCommitRef", "bitbucketCommitRef"]),
887            "sha": pick(["githubCommitSha", "gitlabCommitSha", "bitbucketCommitSha"]),
888            "commitMessage": pick(["githubCommitMessage", "gitlabCommitMessage", "bitbucketCommitMessage"]),
889            "commitAuthor": pick(["githubCommitAuthorName", "gitlabCommitAuthorName", "bitbucketCommitAuthorName"]),
890        },
891        "inspectorUrl": deployment.get("inspectorUrl").cloned().unwrap_or(Value::Null),
892    })
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    #[test]
900    fn ssh_and_https_remotes_map_to_the_url_vercel_indexes() {
901        assert_eq!(
902            repo_url("git@github.com:acme/web.git").unwrap(),
903            "https://github.com/acme/web"
904        );
905        assert_eq!(
906            repo_url("https://github.com/acme/web/").unwrap(),
907            "https://github.com/acme/web"
908        );
909        // Vercel stores the https form, so an http remote must be upgraded.
910        assert_eq!(
911            repo_url("http://github.com/acme/web").unwrap(),
912            "https://github.com/acme/web"
913        );
914        assert!(repo_url("/local/path").is_none());
915    }
916
917    #[test]
918    fn a_production_deployment_staged_behind_the_alias_is_not_current() {
919        let staged = normalize_deployment(&serde_json::json!({
920            "uid": "dpl_1", "name": "web", "url": null,
921            "target": "production", "readySubstate": "STAGED",
922        }));
923        assert_eq!(staged["isCurrentProduction"], false);
924
925        let promoted = normalize_deployment(&serde_json::json!({
926            "uid": "dpl_2", "name": "web", "url": null,
927            "target": "production", "readySubstate": "PROMOTED",
928        }));
929        assert_eq!(promoted["isCurrentProduction"], true);
930    }
931
932    #[test]
933    fn deployment_ids_fall_back_across_the_api_versions_field_names() {
934        let by_id = normalize_deployment(&serde_json::json!({ "id": "dpl_x", "name": "web" }));
935        assert_eq!(by_id["uid"], "dpl_x");
936        assert_eq!(by_id["state"], "QUEUED");
937    }
938
939    #[test]
940    fn build_logs_accept_both_an_array_and_newline_delimited_json() {
941        let array = parse_build_log_events(
942            r#"[{"type":"stdout","created":2,"payload":{"id":"b","text":"second"}},
943                {"type":"stdout","created":1,"payload":{"id":"a","text":"first"}}]"#,
944        );
945        assert_eq!(array.len(), 2);
946        // Sorted oldest-first regardless of the order the API returned.
947        assert_eq!(array[0]["text"], "first");
948
949        let ndjson = parse_build_log_events(
950            "{\"type\":\"stdout\",\"created\":1,\"payload\":{\"text\":\"one\"}}\n{ broken\n{\"type\":\"stdout\",\"created\":2,\"payload\":{\"text\":\"two\"}}",
951        );
952        // The unparseable line is dropped rather than failing the whole read.
953        assert_eq!(ndjson.len(), 2);
954    }
955
956    #[test]
957    fn ansi_colour_codes_are_stripped_from_build_output() {
958        // The escape must reach the parser as JSON's ``, not as a raw
959        // control byte — serde_json rejects the latter, as does Vercel's own
960        // encoder, so building the document is the only faithful fixture.
961        let raw = serde_json::json!({
962            "created": 1,
963            "payload": { "text": "\u{1b}[32mdone\u{1b}[0m" },
964        })
965        .to_string();
966
967        let logs = parse_build_log_events(&raw);
968        assert_eq!(logs[0]["text"], "done");
969    }
970
971    #[test]
972    fn blank_lines_are_dropped_so_the_log_has_no_holes() {
973        let logs = parse_build_log_events("{\"created\":1,\"payload\":{\"text\":\"   \"}}");
974        assert!(logs.is_empty());
975    }
976
977    /// The status *and* its reason phrase: a bare number says nothing about
978    /// what went wrong, and this string is the only clue the caller gets.
979    #[test]
980    fn an_error_body_without_a_message_names_the_request() {
981        assert_eq!(
982            api_error_message("not json", 404, "Not Found", "/v2/user"),
983            "Vercel returned 404 Not Found for /v2/user"
984        );
985        assert_eq!(
986            api_error_message(
987                r#"{"error":{"message":"Forbidden"}}"#,
988                403,
989                "Forbidden",
990                "/v2/user"
991            ),
992            "Forbidden"
993        );
994    }
995}