1use 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
8const COMPONENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.');
10
11pub const DEFAULT_API_URL: &str = "https://api.codecov.io/api/v2";
13
14#[derive(Debug)]
16pub enum ClientError {
17 MissingToken,
19 Http(reqwest::Error),
21 Api { status: u16, body: String },
23 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
65pub 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#[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 #[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 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 #[must_use]
140 pub fn api_base(&self) -> &str {
141 &self.api_base
142 }
143
144 #[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 #[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 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 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 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 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)] mod 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 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 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}