Skip to main content

codecov_rust_mcp/
client.rs

1//! HTTP client for the Codecov API v2.
2
3use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
4use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
5
6use crate::report::{miss_files_result, CoverageReport, MissFilesResult, ReportFile};
7
8/// Encode path/query components; keep unreserved `-_.` but encode `/`.
9const COMPONENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.');
10
11/// Default Codecov API v2 base (no trailing slash).
12pub const DEFAULT_API_URL: &str = "https://api.codecov.io/api/v2";
13
14/// Errors from Codecov client construction or HTTP calls.
15#[derive(Debug)]
16pub enum ClientError {
17    /// `CODECOV_TOKEN` missing or empty.
18    MissingToken,
19    /// Underlying HTTP transport failure.
20    Http(reqwest::Error),
21    /// Non-success HTTP status from Codecov.
22    Api { status: u16, body: String },
23    /// Response JSON could not be decoded.
24    Json(serde_json::Error),
25}
26
27impl std::fmt::Display for ClientError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::MissingToken => {
31                write!(f, "CODECOV_TOKEN is missing or empty")
32            }
33            Self::Http(err) => write!(f, "HTTP error: {err}"),
34            Self::Api { status, body } => {
35                let snippet = truncate(body, 400);
36                write!(f, "Codecov API {status}: {snippet}")
37            }
38            Self::Json(err) => write!(f, "JSON error: {err}"),
39        }
40    }
41}
42
43impl std::error::Error for ClientError {
44    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
45        match self {
46            Self::Http(err) => Some(err),
47            Self::Json(err) => Some(err),
48            Self::MissingToken | Self::Api { .. } => None,
49        }
50    }
51}
52
53impl From<reqwest::Error> for ClientError {
54    fn from(value: reqwest::Error) -> Self {
55        Self::Http(value)
56    }
57}
58
59impl From<serde_json::Error> for ClientError {
60    fn from(value: serde_json::Error) -> Self {
61        Self::Json(value)
62    }
63}
64
65/// Reject empty / whitespace-only tokens.
66///
67/// # Errors
68///
69/// Returns [`ClientError::MissingToken`] when `s` is empty after trim.
70pub fn require_token(s: &str) -> Result<(), ClientError> {
71    if s.trim().is_empty() {
72        Err(ClientError::MissingToken)
73    } else {
74        Ok(())
75    }
76}
77
78/// Codecov API client (Bearer token + base URL).
79#[derive(Clone)]
80pub struct CodecovClient {
81    token: String,
82    api_base: String,
83    http: reqwest::Client,
84}
85
86impl std::fmt::Debug for CodecovClient {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("CodecovClient")
89            .field("has_token", &!self.token.is_empty())
90            .field("api_base", &self.api_base)
91            .finish_non_exhaustive()
92    }
93}
94
95impl CodecovClient {
96    /// Builds a client with an explicit token and API base URL.
97    ///
98    /// # Panics
99    ///
100    /// Panics only if the default `reqwest::Client` cannot be constructed
101    /// (should not happen in normal environments).
102    #[must_use]
103    pub fn new(token: impl Into<String>, api_base: impl Into<String>) -> Self {
104        let mut headers = HeaderMap::new();
105        let token = token.into();
106        let value = format!("bearer {token}");
107        headers.insert(
108            AUTHORIZATION,
109            HeaderValue::from_str(&value).unwrap_or_else(|_| HeaderValue::from_static("bearer")),
110        );
111        let http = reqwest::Client::builder()
112            .default_headers(headers)
113            .build()
114            .expect("reqwest client");
115        Self {
116            token,
117            api_base: trim_trailing_slash(api_base.into()),
118            http,
119        }
120    }
121
122    /// Builds a client from `CODECOV_TOKEN` and optional `CODECOV_API_URL`.
123    ///
124    /// Loads an optional `.env` file first (does not override existing process env).
125    ///
126    /// # Errors
127    ///
128    /// Returns [`ClientError::MissingToken`] when the token env var is unset or empty.
129    pub fn from_env() -> Result<Self, ClientError> {
130        crate::env_file::load_dotenv();
131        let token = std::env::var("CODECOV_TOKEN").unwrap_or_default();
132        require_token(&token)?;
133        let api_base =
134            std::env::var("CODECOV_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string());
135        Ok(Self::new(token, api_base))
136    }
137
138    /// API base URL without trailing slash.
139    #[must_use]
140    pub fn api_base(&self) -> &str {
141        &self.api_base
142    }
143
144    /// Builds the totals URL for a GitHub repo.
145    #[must_use]
146    pub fn totals_url(
147        api_base: &str,
148        owner: &str,
149        repo: &str,
150        branch: Option<&str>,
151        sha: Option<&str>,
152    ) -> String {
153        let base = trim_trailing_slash(api_base.to_string());
154        let mut url = format!("{base}/github/{}/repos/{}/totals/", enc(owner), enc(repo));
155        append_branch_sha(&mut url, branch, sha);
156        url
157    }
158
159    /// Builds the file report URL for a path inside a GitHub repo.
160    #[must_use]
161    pub fn file_report_url(
162        api_base: &str,
163        owner: &str,
164        repo: &str,
165        path: &str,
166        branch: Option<&str>,
167        sha: Option<&str>,
168    ) -> String {
169        let base = trim_trailing_slash(api_base.to_string());
170        // No trailing slash after the file path: Codecov treats `parser.rs/` as a
171        // different key and returns 404 for line coverage.
172        let mut url = format!(
173            "{base}/github/{}/repos/{}/file_report/{}",
174            enc(owner),
175            enc(repo),
176            enc(path)
177        );
178        append_branch_sha(&mut url, branch, sha);
179        url
180    }
181
182    /// Fetches commit coverage totals (including per-file breakdown).
183    ///
184    /// # Errors
185    ///
186    /// Returns [`ClientError::Http`], [`ClientError::Api`], or [`ClientError::Json`].
187    pub async fn totals(
188        &self,
189        owner: &str,
190        repo: &str,
191        branch: Option<&str>,
192        sha: Option<&str>,
193    ) -> Result<CoverageReport, ClientError> {
194        let url = Self::totals_url(&self.api_base, owner, repo, branch, sha);
195        self.get_json(&url).await
196    }
197
198    /// Files with misses, sorted descending, capped by `limit` (`0` = uncapped).
199    ///
200    /// # Errors
201    ///
202    /// Same as [`Self::totals`].
203    pub async fn miss_files(
204        &self,
205        owner: &str,
206        repo: &str,
207        branch: Option<&str>,
208        sha: Option<&str>,
209        limit: usize,
210    ) -> Result<MissFilesResult, ClientError> {
211        let report = self.totals(owner, repo, branch, sha).await?;
212        Ok(miss_files_result(&report, limit))
213    }
214
215    /// Fetches line coverage for a single file path.
216    ///
217    /// # Errors
218    ///
219    /// Same as [`Self::totals`].
220    pub async fn file_report(
221        &self,
222        owner: &str,
223        repo: &str,
224        path: &str,
225        branch: Option<&str>,
226        sha: Option<&str>,
227    ) -> Result<ReportFile, ClientError> {
228        let url = Self::file_report_url(&self.api_base, owner, repo, path, branch, sha);
229        self.get_json(&url).await
230    }
231
232    pub(crate) async fn get_json<T: serde::de::DeserializeOwned>(
233        &self,
234        url: &str,
235    ) -> Result<T, ClientError> {
236        let response = self.http.get(url).send().await?;
237        let status = response.status();
238        let body = response.text().await?;
239        if !status.is_success() {
240            return Err(ClientError::Api {
241                status: status.as_u16(),
242                body,
243            });
244        }
245        Ok(serde_json::from_str(&body)?)
246    }
247}
248
249fn enc(s: &str) -> String {
250    utf8_percent_encode(s, COMPONENT).to_string()
251}
252
253fn trim_trailing_slash(mut s: String) -> String {
254    while s.ends_with('/') {
255        s.pop();
256    }
257    s
258}
259
260fn append_branch_sha(url: &mut String, branch: Option<&str>, sha: Option<&str>) {
261    let mut sep = '?';
262    if let Some(branch) = branch.filter(|b| !b.is_empty()) {
263        url.push(sep);
264        url.push_str("branch=");
265        url.push_str(&enc(branch));
266        sep = '&';
267    }
268    if let Some(sha) = sha.filter(|s| !s.is_empty()) {
269        url.push(sep);
270        url.push_str("sha=");
271        url.push_str(&enc(sha));
272    }
273}
274
275fn truncate(s: &str, max: usize) -> String {
276    let count = s.chars().count();
277    if count <= max {
278        s.to_string()
279    } else {
280        s.chars().take(max).collect()
281    }
282}
283
284#[cfg(test)]
285#[allow(clippy::await_holding_lock)] // env mutex must span HTTP mocks
286mod tests {
287    use super::*;
288    use crate::test_env::{env_lock, restore_env};
289    use serde_json::json;
290    use std::error::Error;
291    use wiremock::matchers::{method, path_regex};
292    use wiremock::{Mock, MockServer, ResponseTemplate};
293
294    fn sample_totals_json() -> serde_json::Value {
295        json!({
296            "totals": {
297                "files": 1,
298                "lines": 10,
299                "hits": 8,
300                "misses": 2,
301                "partials": 0,
302                "coverage": 80.0,
303                "branches": 0,
304                "methods": 0
305            },
306            "files": [{
307                "name": "a.rs",
308                "totals": {
309                    "files": 0,
310                    "lines": 10,
311                    "hits": 8,
312                    "misses": 2,
313                    "partials": 0,
314                    "coverage": 80.0,
315                    "branches": 0,
316                    "methods": 0
317                },
318                "line_coverage": []
319            }]
320        })
321    }
322
323    fn sample_file_report_json() -> serde_json::Value {
324        json!({
325            "name": "a.rs",
326            "totals": {
327                "files": 0,
328                "lines": 10,
329                "hits": 8,
330                "misses": 2,
331                "partials": 0,
332                "coverage": 80.0,
333                "branches": 0,
334                "methods": 0
335            },
336            "line_coverage": [[1, 1], [2, 0]]
337        })
338    }
339
340    #[test]
341    fn require_token_rejects_empty() {
342        assert!(matches!(require_token(""), Err(ClientError::MissingToken)));
343        assert!(matches!(
344            require_token("   "),
345            Err(ClientError::MissingToken)
346        ));
347        assert!(require_token("tok").is_ok());
348    }
349
350    #[test]
351    fn client_error_display_and_source() {
352        let missing = ClientError::MissingToken;
353        assert!(missing.to_string().contains("CODECOV_TOKEN"));
354        assert!(missing.source().is_none());
355
356        let api = ClientError::Api {
357            status: 500,
358            body: "short".into(),
359        };
360        assert_eq!(api.to_string(), "Codecov API 500: short");
361        assert!(api.source().is_none());
362
363        let long_body: String = "x".repeat(450);
364        let truncated = ClientError::Api {
365            status: 502,
366            body: long_body,
367        };
368        let display = truncated.to_string();
369        assert!(display.starts_with("Codecov API 502: "));
370        assert_eq!(
371            display.chars().count(),
372            "Codecov API 502: ".chars().count() + 400
373        );
374
375        let json_err = ClientError::from(serde_json::from_str::<()>("x").unwrap_err());
376        assert!(json_err.to_string().contains("JSON error"));
377        assert!(json_err.source().is_some());
378    }
379
380    #[tokio::test]
381    async fn client_error_http_display_and_from() {
382        let err = reqwest::Client::new()
383            .get("http://127.0.0.1:9/")
384            .send()
385            .await
386            .expect_err("connection should fail");
387        let http = ClientError::from(err);
388        assert!(http.to_string().contains("HTTP error"));
389        assert!(http.source().is_some());
390    }
391
392    #[test]
393    fn debug_hides_token() {
394        let client = CodecovClient::new("secret-token", DEFAULT_API_URL);
395        let debug = format!("{client:?}");
396        assert!(debug.contains("has_token: true"));
397        assert!(!debug.contains("secret-token"));
398        assert!(debug.contains(DEFAULT_API_URL));
399    }
400
401    #[test]
402    fn totals_url_encodes_and_queries() {
403        let url = CodecovClient::totals_url(
404            DEFAULT_API_URL,
405            "Interchouette-ITC",
406            "rustashop",
407            Some("dev"),
408            None,
409        );
410        assert_eq!(
411            url,
412            "https://api.codecov.io/api/v2/github/Interchouette-ITC/repos/rustashop/totals/?branch=dev"
413        );
414    }
415
416    #[test]
417    fn totals_url_with_sha() {
418        let url = CodecovClient::totals_url(
419            "https://api.codecov.io/api/v2/",
420            "o",
421            "r",
422            Some("main"),
423            Some("abc123"),
424        );
425        assert_eq!(
426            url,
427            "https://api.codecov.io/api/v2/github/o/repos/r/totals/?branch=main&sha=abc123"
428        );
429    }
430
431    #[test]
432    fn file_report_url_encodes_path_without_trailing_slash() {
433        let url = CodecovClient::file_report_url(
434            DEFAULT_API_URL,
435            "o",
436            "r",
437            "crates/foo/src/lib.rs",
438            Some("dev"),
439            None,
440        );
441        assert_eq!(
442            url,
443            "https://api.codecov.io/api/v2/github/o/repos/r/file_report/crates%2Ffoo%2Fsrc%2Flib.rs?branch=dev"
444        );
445        assert!(
446            !url.contains("lib.rs/"),
447            "trailing slash after path breaks Codecov file_report lookups: {url}"
448        );
449    }
450
451    #[test]
452    fn new_stores_trimmed_base() {
453        let client = CodecovClient::new("tok", "https://api.codecov.io/api/v2/");
454        assert_eq!(client.api_base(), DEFAULT_API_URL);
455        assert!(!client.token.is_empty());
456    }
457
458    #[tokio::test]
459    async fn totals_miss_files_and_file_report_ok() {
460        let server = MockServer::start().await;
461        Mock::given(method("GET"))
462            .and(path_regex(r".*/totals/.*"))
463            .respond_with(ResponseTemplate::new(200).set_body_json(sample_totals_json()))
464            .mount(&server)
465            .await;
466        Mock::given(method("GET"))
467            .and(path_regex(r".*/file_report/.*"))
468            .respond_with(ResponseTemplate::new(200).set_body_json(sample_file_report_json()))
469            .mount(&server)
470            .await;
471
472        let client = CodecovClient::new("tok", server.uri());
473        let report = client
474            .totals("o", "r", Some("dev"), None)
475            .await
476            .expect("totals");
477        assert_eq!(report.totals.misses, 2);
478        assert_eq!(report.files.len(), 1);
479
480        let misses = client
481            .miss_files("o", "r", None, Some("abc"), 10)
482            .await
483            .expect("miss_files");
484        assert_eq!(misses.returned, 1);
485        assert_eq!(misses.files[0].name, "a.rs");
486
487        let file = client
488            .file_report("o", "r", "a.rs", Some("dev"), None)
489            .await
490            .expect("file_report");
491        assert_eq!(file.name, "a.rs");
492        assert_eq!(file.line_coverage.len(), 2);
493    }
494
495    #[tokio::test]
496    async fn get_json_api_error() {
497        let server = MockServer::start().await;
498        Mock::given(method("GET"))
499            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
500            .mount(&server)
501            .await;
502
503        let client = CodecovClient::new("tok", server.uri());
504        let url = format!("{}/github/o/repos/r/totals/", client.api_base());
505        match client.get_json::<serde_json::Value>(&url).await {
506            Err(ClientError::Api { status, body }) => {
507                assert_eq!(status, 500);
508                assert_eq!(body, "boom");
509            }
510            other => panic!("expected Api error, got {other:?}"),
511        }
512    }
513
514    #[tokio::test]
515    async fn get_json_invalid_json() {
516        let server = MockServer::start().await;
517        Mock::given(method("GET"))
518            .respond_with(ResponseTemplate::new(200).set_body_string("not-json"))
519            .mount(&server)
520            .await;
521
522        let client = CodecovClient::new("tok", server.uri());
523        let url = format!("{}/github/o/repos/r/totals/", client.api_base());
524        let json_err = client.get_json::<serde_json::Value>(&url).await;
525        assert!(matches!(json_err, Err(ClientError::Json(_))));
526    }
527
528    #[tokio::test]
529    async fn get_json_http_transport_error() {
530        let client = CodecovClient::new("tok", "http://127.0.0.1:9");
531        let err = client
532            .get_json::<serde_json::Value>("http://127.0.0.1:9/github/o/repos/r/totals/")
533            .await;
534        assert!(matches!(err, Err(ClientError::Http(_))));
535    }
536
537    #[tokio::test]
538    async fn from_env_uses_token_and_api_url() {
539        let _guard = env_lock();
540        let server = MockServer::start().await;
541        Mock::given(method("GET"))
542            .and(path_regex(r".*/totals/.*"))
543            .respond_with(ResponseTemplate::new(200).set_body_json(sample_totals_json()))
544            .mount(&server)
545            .await;
546
547        let prev_token = std::env::var("CODECOV_TOKEN").ok();
548        let prev_url = std::env::var("CODECOV_API_URL").ok();
549        std::env::set_var("CODECOV_TOKEN", "env-tok");
550        std::env::set_var("CODECOV_API_URL", server.uri());
551
552        let client = CodecovClient::from_env().expect("from_env");
553        assert_eq!(client.api_base(), server.uri().trim_end_matches('/'));
554        let report = client.totals("o", "r", None, None).await.expect("totals");
555        assert_eq!(report.totals.hits, 8);
556
557        restore_env("CODECOV_TOKEN", prev_token);
558        restore_env("CODECOV_API_URL", prev_url);
559    }
560
561    #[test]
562    fn new_falls_back_on_invalid_auth_header() {
563        // Newlines are invalid in HTTP header values.
564        let client = CodecovClient::new("bad\ntoken", "https://api.codecov.io/api/v2/");
565        assert_eq!(client.api_base(), DEFAULT_API_URL);
566        assert!(!client.token.is_empty());
567    }
568
569    #[test]
570    fn from_env_defaults_api_url() {
571        let _guard = env_lock();
572        let prev_token = std::env::var("CODECOV_TOKEN").ok();
573        let prev_url = std::env::var("CODECOV_API_URL").ok();
574        std::env::set_var("CODECOV_TOKEN", "default-url-tok");
575        std::env::remove_var("CODECOV_API_URL");
576        let client = CodecovClient::from_env().expect("from_env");
577        assert_eq!(client.api_base(), DEFAULT_API_URL);
578        restore_env("CODECOV_TOKEN", prev_token);
579        restore_env("CODECOV_API_URL", prev_url);
580    }
581
582    #[tokio::test]
583    async fn miss_files_propagates_totals_error() {
584        let server = MockServer::start().await;
585        Mock::given(method("GET"))
586            .respond_with(ResponseTemplate::new(500).set_body_string("nope"))
587            .mount(&server)
588            .await;
589        let client = CodecovClient::new("tok", server.uri());
590        let err = client.miss_files("o", "r", None, None, 10).await;
591        assert!(matches!(err, Err(ClientError::Api { .. })));
592    }
593
594    #[test]
595    fn from_env_missing_token() {
596        let _guard = env_lock();
597        let prev_token = std::env::var("CODECOV_TOKEN").ok();
598        let prev_url = std::env::var("CODECOV_API_URL").ok();
599        // Empty (not removed): load_dotenv must not refill from a checkout `.env`.
600        std::env::set_var("CODECOV_TOKEN", "");
601        std::env::remove_var("CODECOV_API_URL");
602        assert!(matches!(
603            CodecovClient::from_env(),
604            Err(ClientError::MissingToken)
605        ));
606        restore_env("CODECOV_TOKEN", prev_token);
607        restore_env("CODECOV_API_URL", prev_url);
608    }
609}