coveralls_api/
lib.rs

1use deflate::deflate_bytes_gzip;
2use reqwest::{
3    blocking::{
4        multipart::{Form, Part},
5        Client,
6    },
7    StatusCode,
8};
9use serde::{
10    ser::{SerializeStruct, Serializer},
11    Deserialize, Serialize,
12};
13use std::collections::HashMap;
14use std::env::var;
15use std::fmt;
16use std::fs::File;
17use std::io;
18use std::io::prelude::*;
19use std::path::Path;
20use std::str::FromStr;
21
22/// Representation of branch data
23#[derive(
24    Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Deserialize, Serialize,
25)]
26pub struct BranchData {
27    pub line_number: usize,
28    pub block_name: usize,
29    pub branch_number: usize,
30    pub hits: usize,
31}
32
33/// Expands the line map into the form expected by coveralls (includes uncoverable lines)
34fn expand_lines(lines: &HashMap<usize, usize>, line_count: usize) -> Vec<Option<usize>> {
35    (0..line_count)
36        .map(|x| match lines.get(&(x + 1)) {
37            Some(x) => Some(*x),
38            None => None,
39        })
40        .collect::<Vec<Option<usize>>>()
41}
42
43/// Expands branch coverage into the less user friendly format used by coveralls -
44/// an array with the contents of the structs repeated one after another in an array.
45fn expand_branches(branches: &Vec<BranchData>) -> Vec<usize> {
46    branches
47        .iter()
48        .flat_map(|x| vec![x.line_number, x.block_name, x.branch_number, x.hits])
49        .collect::<Vec<usize>>()
50}
51
52/// Struct representing source files and the coverage for coveralls
53#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Deserialize, Serialize)]
54pub struct Source {
55    /// Name of the source file. Represented as path relative to root of repo
56    name: String,
57    /// MD5 hash of the source file
58    source_digest: String,
59    /// Coverage for the source. Each element is a line with the following rules:
60    /// None - not relevant to coverage
61    /// 0 - not covered
62    /// 1+ - covered and how often
63    coverage: Vec<Option<usize>>,
64    /// Branch data for branch coverage.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    branches: Option<Vec<usize>>,
67    /// Contents of the source file (Manual Repos on Enterprise only)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    source: Option<String>,
70}
71
72impl Source {
73    /// Creates a source description for a given file.
74    /// display_name: Name given to the source file
75    /// repo_path - Path to file relative to repository root
76    /// path - absolute path on file system
77    /// lines - map of line numbers to hits
78    /// branches - optional, vector of branches in code
79    pub fn new(
80        repo_path: &Path,
81        path: &Path,
82        lines: &HashMap<usize, usize>,
83        branches: &Option<Vec<BranchData>>,
84        include_source: bool,
85    ) -> Result<Source, io::Error> {
86        let mut code = File::open(path)?;
87        let mut content = String::new();
88        code.read_to_string(&mut content)?;
89        let src = if include_source {
90            Some(content.clone())
91        } else {
92            None
93        };
94
95        let brch = match branches {
96            &Some(ref b) => Some(expand_branches(&b)),
97            &None => None,
98        };
99        let line_count = content.lines().count();
100        Ok(Source {
101            name: repo_path.to_str().unwrap_or("").to_string(),
102            source_digest: format!("{:x}", md5::compute(content)),
103            coverage: expand_lines(lines, line_count),
104            branches: brch,
105            source: src,
106        })
107    }
108}
109
110#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Deserialize, Serialize)]
111pub struct Head {
112    pub id: String,
113    pub author_name: String,
114    pub author_email: String,
115    pub committer_name: String,
116    pub committer_email: String,
117    pub message: String,
118}
119
120#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Deserialize, Serialize)]
121pub struct Remote {
122    pub name: String,
123    pub url: String,
124}
125
126#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default, Deserialize, Serialize)]
127pub struct GitInfo {
128    pub head: Head,
129    pub branch: String,
130    pub remotes: Vec<Remote>,
131}
132
133/// Continuous Integration services and the string identifiers coveralls.io
134/// uses to present them.
135#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
136pub enum CiService {
137    Travis,
138    TravisPro,
139    Circle,
140    Semaphore,
141    Jenkins,
142    Codeship,
143    /// Other Ci Service, coveralls-ruby is a valid input which gives same features
144    /// as travis for coveralls users.
145    Other(String),
146}
147
148impl FromStr for CiService {
149    type Err = ();
150
151    fn from_str(s: &str) -> Result<Self, Self::Err> {
152        let res = match s {
153            "travis-ci" => CiService::Travis,
154            "travis-pro" => CiService::TravisPro,
155            "circle-ci" => CiService::Circle,
156            "semaphore" => CiService::Semaphore,
157            "jenkins" => CiService::Jenkins,
158            "codeship" => CiService::Codeship,
159            e => CiService::Other(e.to_string()),
160        };
161        Ok(res)
162    }
163}
164
165impl CiService {
166    fn value<'a>(&'a self) -> &'a str {
167        use CiService::*;
168        // Only travis and ruby have special features but the others might gain
169        // those features in future so best to put them all for now.
170        match *self {
171            Travis => "travis-ci",
172            TravisPro => "travis-pro",
173            Other(ref x) => x.as_str(),
174            Circle => "circle-ci",
175            Semaphore => "semaphore",
176            Jenkins => "jenkins",
177            Codeship => "codeship",
178        }
179    }
180}
181
182/// Service's are used for CI integration. Coveralls current supports
183/// * travis ci
184/// * travis pro
185/// * circleCI
186/// * Semaphore
187/// * JenkinsCI
188/// * Codeship
189#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
190pub struct Service {
191    /// Name of the CiService
192    pub name: CiService,
193    /// Job ID
194    pub job_id: Option<String>,
195    /// Optional service_number
196    pub number: Option<String>,
197    /// Optional service_build_url
198    pub build_url: Option<String>,
199    /// Optional service_branch
200    pub branch: Option<String>,
201    /// Optional service_pull_request
202    pub pull_request: Option<String>,
203}
204
205impl Service {
206    pub fn from_env() -> Option<Self> {
207        if var("TRAVIS").is_ok() {
208            Some(Self::get_travis_env())
209        } else if var("CIRCLECI").is_ok() {
210            Some(Self::get_circle_env())
211        } else if var("JENKINS_URL").is_ok() {
212            Some(Self::get_jenkins_env())
213        } else if var("SEMAPHORE").is_ok() {
214            Some(Self::get_semaphore_env())
215        } else {
216            Self::get_generic_env()
217        }
218    }
219
220    pub fn from_ci(ci: CiService) -> Option<Self> {
221        use CiService::*;
222        match ci {
223            Travis | TravisPro => {
224                let mut temp = Self::get_travis_env();
225                temp.name = ci;
226                Some(temp)
227            }
228            Circle => Some(Self::get_circle_env()),
229            Semaphore => Some(Self::get_semaphore_env()),
230            Jenkins => Some(Self::get_jenkins_env()),
231            _ => Self::get_generic_env(),
232        }
233    }
234
235    /// Gets service variables from travis environment
236    /// Warning is unable to figure out if travis pro or free so assumes free
237    pub fn get_travis_env() -> Self {
238        let id = var("TRAVIS_JOB_ID").ok();
239        let pr = match var("TRAVIS_PULL_REQUEST") {
240            Ok(ref s) if s != "false" => Some(s.to_string()),
241            _ => None,
242        };
243        let branch = var("TRAVIS_BRANCH").ok();
244        Service {
245            name: CiService::Travis,
246            job_id: id,
247            number: None,
248            build_url: None,
249            pull_request: pr,
250            branch: branch,
251        }
252    }
253
254    pub fn get_circle_env() -> Self {
255        let num = var("CIRCLE_BUILD_NUM").ok();
256        let branch = var("CIRCLE_BRANCH").ok();
257        Service {
258            name: CiService::Circle,
259            job_id: None, // Not happy with this but apparently it works
260            number: num,
261            build_url: None,
262            pull_request: None,
263            branch: branch,
264        }
265    }
266
267    pub fn get_jenkins_env() -> Self {
268        let num = var("BUILD_NUM").ok();
269        let url = var("BUILD_URL").ok();
270        let branch = var("GIT_BRANCH").ok();
271        Service {
272            name: CiService::Jenkins,
273            job_id: None, // Not happy with this but apparently it works
274            number: num,
275            build_url: url,
276            pull_request: None,
277            branch: branch,
278        }
279    }
280
281    pub fn get_semaphore_env() -> Self {
282        let num = var("SEMAPHORE_BUILD_NUMBER").ok();
283        let pr = var("PULL_REQUEST_NUMBER").ok();
284        Service {
285            name: CiService::Semaphore,
286            job_id: None,
287            number: num,
288            pull_request: pr,
289            branch: None,
290            build_url: None,
291        }
292    }
293
294    pub fn get_generic_env() -> Option<Self> {
295        let name = var("CI_NAME").ok();
296        let num = var("CI_BUILD_NUMBER").ok();
297        let id = var("CI_JOB_ID").ok();
298        let url = var("CI_BUILD_URL").ok();
299        let branch = var("CI_BRANCH").ok();
300        let pr = var("CI_PULL_REQUEST").ok();
301        if name.is_some()
302            || num.is_some()
303            || id.is_some()
304            || url.is_some()
305            || branch.is_some()
306            || pr.is_some()
307        {
308            let name = name.unwrap_or_else(|| "unknown".to_string());
309
310            Some(Service {
311                name: CiService::from_str(&name).unwrap(),
312                job_id: id,
313                number: num,
314                pull_request: pr,
315                branch: branch,
316                build_url: url,
317            })
318        } else {
319            None
320        }
321    }
322}
323
324/// Repo tokens are alternatives to Services and involve a secret token on coveralls
325#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
326pub enum Identity {
327    RepoToken(String),
328    ServiceToken(String, Service),
329}
330
331impl Identity {
332    /// Creates a report identity from a coveralls repo token if one is available
333    /// Only checks via environment variables - this doesn't take into account
334    /// the presence of a .coveralls.yml file
335    pub fn from_token() -> Option<Self> {
336        match var("COVERALLS_REPO_TOKEN") {
337            Ok(token) => Some(Identity::RepoToken(token)),
338            _ => None,
339        }
340    }
341
342    /// Creates a report identity based on the CI service auto-detect functionality
343    pub fn from_env() -> Option<Self> {
344        let token = match var("COVERALLS_REPO_TOKEN") {
345            Ok(token) => token,
346            _ => String::new(),
347        };
348        match Service::from_env() {
349            Some(s) => Some(Identity::ServiceToken(token, s)),
350            _ => None,
351        }
352    }
353
354    /// Prefers a coveralls repo token otherwise falls back on CI environment
355    /// variables
356    pub fn best_match() -> Option<Self> {
357        if let Some(s) = Self::from_env() {
358            Some(s)
359        } else if let Some(s) = Self::from_token() {
360            Some(s)
361        } else {
362            None
363        }
364    }
365
366    pub fn best_match_with_token(token: String) -> Self {
367        if let Some(Identity::ServiceToken(_, s)) = Self::from_env() {
368            Identity::ServiceToken(token, s)
369        } else {
370            Identity::RepoToken(token)
371        }
372    }
373}
374
375/// Coveralls report struct
376/// for more details: https://coveralls.zendesk.com/hc/en-us/articles/201350799-API-Reference
377pub struct CoverallsReport {
378    id: Identity,
379    /// List of source files which includes coverage information.
380    source_files: Vec<Source>,
381    /// Git commit SHA
382    commit: Option<String>,
383    /// Git information
384    git: Option<GitInfo>,
385    /// Client for HTTP requests
386    client: Client,
387}
388
389#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize)]
390pub struct Response {
391    pub message: String,
392    pub url: String,
393}
394
395#[derive(Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize)]
396pub struct ErrorResponse {
397    pub error: bool,
398    pub message: String,
399}
400
401impl fmt::Display for ErrorResponse {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        write!(f, "{}", self.message)
404    }
405}
406
407#[derive(Debug, thiserror::Error)]
408pub enum Error {
409    #[error("http error: {0}")]
410    Http(reqwest::Error),
411    #[error("{0}")]
412    Api(ErrorResponse),
413    #[error("unrecognized API error: {0}")]
414    UnrecognizedMessage(String),
415}
416
417impl From<reqwest::Error> for Error {
418    fn from(other: reqwest::Error) -> Self {
419        Self::Http(other)
420    }
421}
422
423impl CoverallsReport {
424    /// Create new coveralls report given a unique identifier which allows
425    /// coveralls to identify the user and project
426    pub fn new(id: Identity) -> CoverallsReport {
427        CoverallsReport {
428            id: id,
429            source_files: Vec::new(),
430            commit: None,
431            git: None,
432            client: Client::new(),
433        }
434    }
435
436    /// Add generated source data to coveralls report.
437    pub fn add_source(&mut self, source: Source) {
438        self.source_files.push(source);
439    }
440
441    /// Sets the commit ID. Overrides more detailed git info
442    pub fn set_commit(&mut self, commit: &str) {
443        self.commit = Some(commit.to_string());
444        self.git = None;
445    }
446
447    /// Set detailed git information, overrides commit ID if set.
448    pub fn set_detailed_git_info(&mut self, git: GitInfo) {
449        self.git = Some(git);
450        self.commit = None;
451    }
452
453    /// Send report to the coveralls.io directly. For coveralls hosted on other
454    /// platforms see send_to_endpoint
455    pub fn send_to_coveralls(&self) -> Result<Response, Error> {
456        self.send_to_endpoint("https://coveralls.io/api/v1/jobs")
457    }
458
459    /// Sends coveralls report to the specified url
460    pub fn send_to_endpoint(&self, url: &str) -> Result<Response, Error> {
461        let body = match serde_json::to_vec(&self) {
462            Ok(body) => body,
463            Err(e) => panic!("Error {}", e),
464        };
465
466        let body = deflate_bytes_gzip(&body);
467
468        let form = Form::new().part(
469            "json_file",
470            Part::bytes(body).mime_str("gzip/json")?.file_name("report"),
471        );
472
473        let response = self.client.post(url).multipart(form).send()?;
474
475        let code = response.status();
476        let text = response.text()?;
477        match code {
478            StatusCode::OK => match serde_json::from_str(&text) {
479                Ok(resp) => Ok(resp),
480                Err(_e) => Err(Error::UnrecognizedMessage(text)),
481            },
482            _ => match serde_json::from_str(&text) {
483                Ok(resp) => Err(Error::Api(resp)),
484                Err(_e) => Err(Error::UnrecognizedMessage(text)),
485            },
486        }
487    }
488}
489
490impl Serialize for CoverallsReport {
491    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
492    where
493        S: Serializer,
494    {
495        let size = 1 + match self.id {
496            Identity::RepoToken(_) => 1 + self.commit.is_some() as usize,
497            Identity::ServiceToken(_, _) => 2 + self.commit.is_some() as usize,
498        };
499        let mut s = serializer.serialize_struct("CoverallsReport", size)?;
500        match self.id {
501            Identity::RepoToken(ref r) => {
502                s.serialize_field("repo_token", &r)?;
503            }
504            Identity::ServiceToken(ref r, ref serv) => {
505                if !r.is_empty() {
506                    s.serialize_field("repo_token", &r)?;
507                }
508                s.serialize_field("service_name", serv.name.value())?;
509                if let Some(ref id) = serv.job_id {
510                    s.serialize_field("service_job_id", id)?;
511                }
512                if let Some(ref num) = serv.number {
513                    s.serialize_field("service_number", &num)?;
514                }
515                if let Some(ref url) = serv.build_url {
516                    s.serialize_field("service_build_url", &url)?;
517                }
518                if let Some(ref branch) = serv.branch {
519                    s.serialize_field("service_branch", &branch)?;
520                }
521                if let Some(ref pr) = serv.pull_request {
522                    s.serialize_field("service_pull_request", &pr)?;
523                }
524            }
525        }
526        if let Some(ref sha) = self.commit {
527            s.serialize_field("commit_sha", &sha)?;
528        }
529        if let Some(ref git) = self.git {
530            s.serialize_field("git", &git)?;
531        }
532        s.serialize_field("source_files", &self.source_files)?;
533        s.end()
534    }
535}
536
537#[cfg(test)]
538mod tests {
539
540    use crate::*;
541    use std::collections::HashMap;
542
543    #[test]
544    fn test_expand_lines() {
545        let line_count = 10;
546        let mut example: HashMap<usize, usize> = HashMap::new();
547        example.insert(5, 1);
548        example.insert(6, 1);
549        example.insert(8, 2);
550
551        let expected = vec![
552            None,
553            None,
554            None,
555            None,
556            Some(1),
557            Some(1),
558            None,
559            Some(2),
560            None,
561            None,
562        ];
563
564        assert_eq!(expand_lines(&example, line_count), expected);
565    }
566
567    #[test]
568    fn test_branch_expand() {
569        let b1 = BranchData {
570            line_number: 3,
571            block_name: 1,
572            branch_number: 1,
573            hits: 1,
574        };
575        let b2 = BranchData {
576            line_number: 4,
577            block_name: 1,
578            branch_number: 2,
579            hits: 0,
580        };
581
582        let v = vec![b1, b2];
583        let actual = expand_branches(&v);
584        let expected = vec![3, 1, 1, 1, 4, 1, 2, 0];
585        assert_eq!(actual, expected);
586    }
587}