• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

xd009642 / tarpaulin / #723

17 Feb 2026 02:19AM UTC coverage: 85.48% (+0.02%) from 85.462%
#723

push

web-flow
Bump the cargo group across 1 directory with 8 updates (#1822)

* Bump the cargo group across 1 directory with 8 updates

Bumps the cargo group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [gimli](https://github.com/gimli-rs/gimli) | `0.32.3` | `0.33.0` |
| [proc-macro2](https://github.com/dtolnay/proc-macro2) | `1.0.105` | `1.0.106` |
| [quick-xml](https://github.com/tafia/quick-xml) | `0.39.0` | `0.39.1` |
| [quote](https://github.com/dtolnay/quote) | `1.0.43` | `1.0.44` |
| [regex](https://github.com/rust-lang/regex) | `1.12.2` | `1.12.3` |
| [toml](https://github.com/toml-rs/toml) | `0.9.11+spec-1.1.0` | `1.0.1+spec-1.1.0` |
| [cargo-config2](https://github.com/taiki-e/cargo-config2) | `0.1.39` | `0.1.42` |
| [libc](https://github.com/rust-lang/libc) | `0.2.180` | `0.2.182` |



Updates `gimli` from 0.32.3 to 0.33.0
- [Changelog](https://github.com/gimli-rs/gimli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gimli-rs/gimli/compare/0.32.3...0.33.0)

Updates `proc-macro2` from 1.0.105 to 1.0.106
- [Release notes](https://github.com/dtolnay/proc-macro2/releases)
- [Commits](https://github.com/dtolnay/proc-macro2/compare/1.0.105...1.0.106)

Updates `quick-xml` from 0.39.0 to 0.39.1
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.39.0...v0.39.1)

Updates `quote` from 1.0.43 to 1.0.44
- [Release notes](https://github.com/dtolnay/quote/releases)
- [Commits](https://github.com/dtolnay/quote/compare/1.0.43...1.0.44)

Updates `regex` from 1.12.2 to 1.12.3
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.12.2...1.12.3)

Updates `toml` from 0.9.11+spec-1.1.0 to 1.0.1+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/co... (continued)

7 of 7 new or added lines in 1 file covered. (100.0%)

3 existing lines in 1 file now uncovered.

4792 of 5606 relevant lines covered (85.48%)

245317.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.27
/src/source_analysis/mod.rs
1
use crate::config::{Config, RunType};
2
use crate::path_utils::{get_source_walker, is_source_file};
3
use proc_macro2::{Span, TokenStream};
4
use quote::ToTokens;
5
use regex::Regex;
6
use serde::{Deserialize, Serialize};
7
use std::cell::RefCell;
8
use std::collections::{HashMap, HashSet};
9
use std::ffi::OsStr;
10
use std::fs::File;
11
use std::io::{self, BufRead, BufReader, Read};
12
use std::path::{Path, PathBuf};
13
use std::sync::LazyLock;
14
use syn::spanned::Spanned;
15
use syn::*;
16
use tracing::{debug, trace, warn};
17
use walkdir::WalkDir;
18

19
mod attributes;
20
mod expressions;
21
mod items;
22
mod macros;
23
mod statements;
24
#[cfg(test)]
25
mod tests;
26

27
pub(crate) mod prelude {
28
    pub(crate) use super::*;
29
    pub(crate) use attributes::*;
30
    pub(crate) use macros::*;
31
}
32

33
/// Enumeration representing which lines to ignore
34
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
35
pub enum Lines {
36
    /// Ignore all lines in the file
37
    All,
38
    /// A single line to ignore in the file
39
    Line(usize),
40
}
41

42
/// Represents the results of analysis of a single file. Does not store the file
43
/// in question as this is expected to be maintained by the user.
44
#[derive(Clone, Debug, Default)]
45
pub struct LineAnalysis {
46
    /// This represents lines that should be ignored in coverage
47
    /// but may be identified as coverable in the DWARF tables
48
    pub ignore: HashSet<Lines>,
49
    /// This represents lines that should be included in coverage
50
    /// But may be ignored. Doesn't make sense to cover ALL the lines so this
51
    /// is just an index.
52
    pub cover: HashSet<usize>,
53
    /// Some logical lines may be split between physical lines this shows the
54
    /// mapping from physical line to logical line to prevent false positives
55
    /// from expressions split across physical lines
56
    pub logical_lines: HashMap<usize, usize>,
57
    /// Shows the line length of the provided file
58
    max_line: usize,
59
    pub functions: HashMap<String, (usize, usize)>,
60
}
61

62
/// Provides context to the source analysis stage including the tarpaulin
63
/// config and the source code being analysed.
64
pub(crate) struct Context<'a> {
65
    /// Program config
66
    config: &'a Config,
67
    /// Contents of the source file
68
    file_contents: &'a str,
69
    /// path to the file being analysed
70
    file: &'a Path,
71
    /// Other parts of context are immutable like tarpaulin config and users
72
    /// source code. This is discovered during hence use of interior mutability
73
    ignore_mods: RefCell<HashSet<PathBuf>>,
74
    /// As we traverse the structures we want to grab module names etc so we can get proper names
75
    /// for our functions etc
76
    pub(crate) symbol_stack: RefCell<Vec<String>>,
77
}
78

79
pub(crate) struct StackGuard<'a>(&'a RefCell<Vec<String>>);
80

81
impl<'a> Drop for StackGuard<'a> {
82
    fn drop(&mut self) {
744✔
83
        self.0.borrow_mut().pop();
744✔
84
    }
85
}
86

87
impl<'a> Context<'a> {
88
    pub(crate) fn push_to_symbol_stack(&self, mut ident: String) -> StackGuard<'_> {
744✔
89
        if !(ident.starts_with("<") && ident.ends_with(">")) {
1,488✔
90
            ident = ident.replace(' ', "");
2,214✔
91
        }
92
        self.symbol_stack.borrow_mut().push(ident);
1,488✔
93
        StackGuard(&self.symbol_stack)
744✔
94
    }
95

96
    pub(crate) fn get_qualified_name(&self) -> String {
543✔
97
        let stack = self.symbol_stack.borrow();
1,629✔
98
        let name = stack.join("::");
1,629✔
99
        debug!("Found function: {}", name);
543✔
100
        name
543✔
101
    }
102
}
103

104
/// When the `LineAnalysis` results are mapped to their files there needs to be
105
/// an easy way to get the information back. For the container used implement
106
/// this trait
107
pub trait SourceAnalysisQuery {
108
    /// Returns true if the line in the given file should be ignored
109
    fn should_ignore(&self, path: &Path, l: &usize) -> bool;
110
    /// Takes a path and line number and normalises it to the logical line
111
    /// that should be represented in the statistics
112
    fn normalise(&self, path: &Path, l: usize) -> (PathBuf, usize);
113
}
114

115
#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
116
pub(crate) enum SubResult {
117
    /// Expression should be a reachable one (or we don't care to check further)
118
    Ok,
119
    /// Expression definitely reachable - reserved for early returns from functions to stop
120
    /// unreachable expressions wiping them out
121
    Definite,
122
    /// Unreachable expression i.e. unreachable!()
123
    Unreachable,
124
}
125

126
// Addition works for this by forcing anything + definite to definite, otherwise prioritising
127
// unreachable.
128
impl std::ops::AddAssign for SubResult {
129
    fn add_assign(&mut self, other: Self) {
235✔
130
        if *self == Self::Definite || other == Self::Definite {
491✔
131
            *self = Self::Definite;
34✔
132
        } else if *self == Self::Unreachable || other == Self::Unreachable {
439✔
133
            *self = Self::Unreachable;
6✔
134
        } else {
135
            *self = Self::Ok;
195✔
136
        }
137
    }
138
}
139

140
impl std::ops::Add for SubResult {
141
    type Output = Self;
142

143
    fn add(mut self, rhs: Self) -> Self::Output {
29✔
144
        self += rhs;
29✔
145
        self
29✔
146
    }
147
}
148

149
impl SubResult {
150
    pub fn is_reachable(&self) -> bool {
3,246✔
151
        *self != Self::Unreachable
3,246✔
152
    }
153

154
    pub fn is_unreachable(&self) -> bool {
3,191✔
155
        !self.is_reachable()
3,191✔
156
    }
157
}
158

159
impl SourceAnalysisQuery for HashMap<PathBuf, LineAnalysis> {
160
    fn should_ignore(&self, path: &Path, l: &usize) -> bool {
21,638✔
161
        if self.contains_key(path) {
64,914✔
162
            self.get(path).unwrap().should_ignore(*l)
108,190✔
163
        } else {
164
            false
×
165
        }
166
    }
167

168
    fn normalise(&self, path: &Path, l: usize) -> (PathBuf, usize) {
1,340✔
169
        let pb = path.to_path_buf();
4,020✔
170
        match self.get(path) {
2,680✔
171
            Some(s) => match s.logical_lines.get(&l) {
4,020✔
172
                Some(o) => (pb, *o),
16✔
173
                _ => (pb, l),
1,332✔
174
            },
175
            _ => (pb, l),
×
176
        }
177
    }
178
}
179

180
impl LineAnalysis {
181
    /// Creates a new LineAnalysis object
182
    fn new() -> Self {
19✔
183
        Default::default()
19✔
184
    }
185

186
    fn new_from_file(path: &Path) -> io::Result<Self> {
365✔
187
        let file = BufReader::new(File::open(path)?);
1,382✔
188
        Ok(Self {
287✔
189
            max_line: file.lines().count(),
861✔
190
            ..Default::default()
287✔
191
        })
192
    }
193

194
    /// Ignore all lines in the file
195
    pub fn ignore_all(&mut self) {
24✔
196
        self.ignore.clear();
48✔
197
        self.cover.clear();
48✔
198
        self.ignore.insert(Lines::All);
72✔
199
    }
200

201
    /// Ignore all tokens in the given token stream
202
    pub fn ignore_tokens<T>(&mut self, tokens: T)
636✔
203
    where
204
        T: ToTokens,
205
    {
206
        for token in tokens.into_token_stream() {
5,436✔
207
            self.ignore_span(token.span());
6,246✔
208
        }
209
    }
210

211
    /// Adds the lines of the provided span to the ignore set
212
    pub fn ignore_span(&mut self, span: Span) {
2,111✔
213
        // If we're already ignoring everything no need to ignore this span
214
        if !self.ignore.contains(&Lines::All) {
4,222✔
215
            for i in span.start().line..=span.end().line {
6,838✔
216
                self.ignore.insert(Lines::Line(i));
7,884✔
217
                if self.cover.contains(&i) {
7,891✔
218
                    self.cover.remove(&i);
14✔
219
                }
220
            }
221
        }
222
    }
223

224
    /// Cover all tokens in the given tokenstream
225
    pub fn cover_token_stream(&mut self, tokens: TokenStream, contents: Option<&str>) {
×
226
        for token in tokens {
×
227
            self.cover_span(token.span(), contents);
×
228
        }
229
    }
230

231
    /// Adds the lines of the provided span to the cover set
232
    pub fn cover_span(&mut self, span: Span, contents: Option<&str>) {
20✔
233
        // Not checking for Lines::All because I trust we've called cover_span
234
        // for a reason.
235
        let mut useful_lines: HashSet<usize> = HashSet::new();
60✔
236
        if let Some(c) = contents {
40✔
237
            static SINGLE_LINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s*//").unwrap());
15✔
238
            const MULTI_START: &str = "/*";
239
            const MULTI_END: &str = "*/";
240
            let len = span.end().line - span.start().line;
60✔
241
            let mut is_comment = false;
40✔
242
            for (i, line) in c.lines().enumerate().skip(span.start().line - 1).take(len) {
266✔
243
                let is_code = if line.contains(MULTI_START) {
189✔
244
                    if !line.contains(MULTI_END) {
2✔
245
                        is_comment = true;
1✔
246
                    }
247
                    false
1✔
248
                } else if is_comment {
62✔
249
                    if line.contains(MULTI_END) {
5✔
250
                        is_comment = false;
1✔
251
                    }
252
                    false
2✔
253
                } else {
254
                    true
60✔
255
                };
256
                if is_code && !SINGLE_LINE.is_match(line) {
239✔
257
                    useful_lines.insert(i + 1);
112✔
258
                }
259
            }
260
        }
261
        for i in span.start().line..=span.end().line {
123✔
262
            if !self.ignore.contains(&Lines::Line(i)) && useful_lines.contains(&i) {
435✔
263
                self.cover.insert(i);
100✔
264
            }
265
        }
266
    }
267

268
    /// Shows whether the line should be ignored by tarpaulin
269
    pub fn should_ignore(&self, line: usize) -> bool {
21,886✔
270
        self.ignore.contains(&Lines::Line(line))
65,658✔
271
            || self.ignore.contains(&Lines::All)
63,015✔
272
            || (self.max_line > 0 && self.max_line < line)
40,439✔
273
    }
274

275
    /// Adds a line to the list of lines to ignore
276
    fn add_to_ignore(&mut self, lines: impl IntoIterator<Item = usize>) {
1,698✔
277
        if !self.ignore.contains(&Lines::All) {
3,396✔
278
            for l in lines {
4,311✔
279
                self.ignore.insert(Lines::Line(l));
7,839✔
280
                if self.cover.contains(&l) {
7,843✔
281
                    self.cover.remove(&l);
8✔
282
                }
283
            }
284
        }
285
    }
286
}
287

288
impl Function {
289
    fn new(name: &str, span: (usize, usize)) -> Self {
458✔
290
        Self {
291
            name: name.to_string(),
1,374✔
292
            start: span.0 as u64,
458✔
293
            end: span.1 as u64,
458✔
294
        }
295
    }
296
}
297

298
#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
299
pub struct Function {
300
    pub name: String,
301
    pub start: u64,
302
    pub end: u64,
303
}
304

305
#[derive(Default)]
306
pub struct SourceAnalysis {
307
    pub lines: HashMap<PathBuf, LineAnalysis>,
308
    ignored_modules: Vec<PathBuf>,
309
}
310

311
impl SourceAnalysis {
312
    pub fn new() -> Self {
241✔
313
        Default::default()
241✔
314
    }
315

316
    pub fn create_function_map(&self) -> HashMap<PathBuf, Vec<Function>> {
163✔
317
        self.lines
163✔
318
            .iter()
319
            .map(|(file, analysis)| {
468✔
320
                let mut functions: Vec<Function> = analysis
915✔
321
                    .functions
305✔
322
                    .iter()
305✔
323
                    .map(|(function, span)| Function::new(function, *span))
1,679✔
324
                    .collect();
305✔
325
                functions.sort_unstable_by(|a, b| a.start.cmp(&b.start));
1,588✔
326
                (file.to_path_buf(), functions)
305✔
327
            })
328
            .collect()
329
    }
330

331
    pub fn get_line_analysis(&mut self, path: PathBuf) -> &mut LineAnalysis {
3,881✔
332
        self.lines
3,881✔
333
            .entry(path.clone())
11,643✔
334
            .or_insert_with(|| LineAnalysis::new_from_file(&path).unwrap_or_default())
4,976✔
335
    }
336

337
    fn is_ignored_module(&self, path: &Path) -> bool {
292✔
338
        self.ignored_modules.iter().any(|x| path.starts_with(x))
644✔
339
    }
340

341
    pub fn get_analysis(config: &Config) -> Self {
162✔
342
        let mut result = Self::new();
324✔
343
        let mut ignored_files: HashSet<PathBuf> = HashSet::new();
486✔
344
        let root = config.root();
486✔
345

346
        for e in get_source_walker(config) {
628✔
347
            if !ignored_files.contains(e.path()) {
1,214✔
348
                result.analyse_package(e.path(), &root, config, &mut ignored_files);
1,510✔
349
            } else {
350
                let mut analysis = LineAnalysis::new();
6✔
351
                analysis.ignore_all();
6✔
352
                result.lines.insert(e.path().to_path_buf(), analysis);
12✔
353
                ignored_files.remove(e.path());
6✔
354
            }
355
        }
356
        for e in &ignored_files {
162✔
UNCOV
357
            let mut analysis = LineAnalysis::new();
×
UNCOV
358
            analysis.ignore_all();
×
UNCOV
359
            result.lines.insert(e.clone(), analysis);
×
360
        }
361
        result.debug_printout(config);
486✔
362

363
        result
162✔
364
    }
365

366
    /// Analyses a package of the target crate.
367
    fn analyse_package(
302✔
368
        &mut self,
369
        path: &Path,
370
        root: &Path,
371
        config: &Config,
372
        filtered_files: &mut HashSet<PathBuf>,
373
    ) {
374
        if let Some(file) = path.to_str() {
604✔
375
            let skip_cause_example = path.starts_with(root.join("examples"))
1,510✔
376
                && !config.run_types.contains(&RunType::Examples);
36✔
377
            if skip_cause_example || self.is_ignored_module(path) {
1,194✔
378
                let mut analysis = LineAnalysis::new();
48✔
379
                analysis.ignore_all();
48✔
380
                self.lines.insert(path.to_path_buf(), analysis);
64✔
381
            } else {
382
                let file = File::open(file);
858✔
383
                if let Ok(mut file) = file {
572✔
384
                    let mut content = String::new();
572✔
385
                    let res = file.read_to_string(&mut content);
1,144✔
386
                    if let Err(e) = res {
286✔
387
                        warn!(
×
388
                            "Unable to read file into string, skipping source analysis: {}",
389
                            e
390
                        );
391
                        return;
×
392
                    }
393
                    let file = parse_file(&content);
858✔
394
                    if let Ok(file) = file {
572✔
395
                        let ctx = Context {
396
                            config,
397
                            file_contents: &content,
572✔
398
                            file: path,
399
                            ignore_mods: RefCell::new(HashSet::new()),
858✔
400
                            symbol_stack: RefCell::new(vec![]),
286✔
401
                        };
402
                        if self.check_attr_list(&file.attrs, &ctx) {
1,144✔
403
                            self.find_ignorable_lines(&ctx);
828✔
404
                            self.process_items(&file.items, &ctx);
1,104✔
405

406
                            let mut ignored_files = ctx.ignore_mods.into_inner();
828✔
407
                            for f in ignored_files.drain() {
554✔
408
                                if f.is_file() {
4✔
409
                                    filtered_files.insert(f);
4✔
410
                                } else {
411
                                    let walker = WalkDir::new(f).into_iter();
×
412
                                    for e in walker
×
413
                                        .filter_map(std::result::Result::ok)
×
414
                                        .filter(is_source_file)
×
415
                                    {
416
                                        filtered_files.insert(e.path().to_path_buf());
×
417
                                    }
418
                                }
419
                            }
420
                            maybe_ignore_first_line(path, &mut self.lines);
828✔
421
                        } else {
422
                            // Now we need to ignore not only this file but if it is a lib.rs or
423
                            // mod.rs we need to get the others
424
                            let bad_module =
10✔
425
                                match (path.parent(), path.file_name().map(OsStr::to_string_lossy))
50✔
426
                                {
427
                                    (Some(p), Some(n)) => {
20✔
428
                                        if n == "lib.rs" || n == "mod.rs" {
16✔
429
                                            Some(p.to_path_buf())
6✔
430
                                        } else {
431
                                            let ignore = p.join(n.trim_end_matches(".rs"));
16✔
432
                                            if ignore.exists() && ignore.is_dir() {
6✔
433
                                                Some(ignore)
2✔
434
                                            } else {
435
                                                None
2✔
436
                                            }
437
                                        }
438
                                    }
439
                                    _ => None,
×
440
                                };
441
                            // Kill it with fire!`
442
                            if let Some(module) = bad_module {
18✔
443
                                self.lines
8✔
444
                                    .iter_mut()
445
                                    .filter(|(k, _)| k.starts_with(module.as_path()))
112✔
446
                                    .for_each(|(_, v)| v.ignore_all());
20✔
447
                                self.ignored_modules.push(module);
24✔
448
                            }
449
                            let analysis = self.get_line_analysis(path.to_path_buf());
50✔
450
                            analysis.ignore_span(file.span());
40✔
451
                        }
452
                    }
453
                }
454
            }
455
        }
456
    }
457

458
    /// Finds lines from the raw string which are ignorable.
459
    /// These are often things like close braces, semicolons that may register as
460
    /// false positives.
461
    pub(crate) fn find_ignorable_lines(&mut self, ctx: &Context) {
277✔
462
        static IGNORABLE: LazyLock<Regex> =
463
            LazyLock::new(|| Regex::new(r"^((\s*//)|([\[\]\{\}\(\)\s;\?,/]*$))").unwrap());
363✔
464
        let analysis = self.get_line_analysis(ctx.file.to_path_buf());
1,385✔
465
        let lines = ctx
554✔
466
            .file_contents
277✔
467
            .lines()
468
            .enumerate()
469
            .filter(|&(_, x)| IGNORABLE.is_match(x))
12,907✔
470
            .map(|(i, _)| i + 1);
2,051✔
471
        analysis.add_to_ignore(lines);
831✔
472

473
        let lines = ctx
554✔
474
            .file_contents
277✔
475
            .lines()
476
            .enumerate()
477
            .filter(|&(_, x)| {
4,487✔
478
                let mut x = x.to_string();
12,630✔
479
                x.retain(|c| !c.is_whitespace());
127,912✔
480
                x == "}else{"
4,210✔
481
            })
482
            .map(|(i, _)| i + 1);
309✔
483
        analysis.add_to_ignore(lines);
831✔
484
    }
485

486
    pub(crate) fn visit_generics(&mut self, generics: &Generics, ctx: &Context) {
538✔
487
        if let Some(ref wh) = generics.where_clause {
550✔
488
            let analysis = self.get_line_analysis(ctx.file.to_path_buf());
36✔
489
            analysis.ignore_tokens(wh);
12✔
490
        }
491
    }
492

493
    /// Printout a debug summary of the results of source analysis if debug logging
494
    /// is enabled
495
    #[cfg(not(tarpaulin_include))]
496
    pub fn debug_printout(&self, config: &Config) {
497
        if config.debug {
498
            for (path, analysis) in &self.lines {
499
                trace!(
500
                    "Source analysis for {}",
501
                    config.strip_base_dir(path).display()
502
                );
503
                let mut lines = Vec::new();
504
                for l in &analysis.ignore {
505
                    match l {
506
                        Lines::All => {
507
                            lines.clear();
508
                            trace!("All lines are ignorable");
509
                            break;
510
                        }
511
                        Lines::Line(i) => {
512
                            lines.push(i);
513
                        }
514
                    }
515
                }
516
                if !lines.is_empty() {
517
                    lines.sort();
518
                    trace!("Ignorable lines: {:?}", lines);
519
                    lines.clear();
520
                }
521
                for c in &analysis.cover {
522
                    lines.push(c);
523
                }
524

525
                if !lines.is_empty() {
526
                    lines.sort();
527
                    trace!("Coverable lines: {:?}", lines);
528
                }
529
            }
530
        }
531
    }
532
}
533

534
/// lib.rs:1 can often show up as a coverable line when it's not. This ignores
535
/// that line as long as it's not a real source line. This can also affect
536
/// the main files for binaries in a project as well.
537
fn maybe_ignore_first_line(file: &Path, result: &mut HashMap<PathBuf, LineAnalysis>) {
276✔
538
    if let Ok(f) = File::open(file) {
552✔
539
        let read_file = BufReader::new(f);
828✔
540
        if let Some(Ok(first)) = read_file.lines().next() {
828✔
541
            if !(first.starts_with("pub") || first.starts_with("fn")) {
676✔
542
                let file = file.to_path_buf();
776✔
543
                let line_analysis = result.entry(file).or_default();
1,164✔
544
                line_analysis.add_to_ignore([1]);
388✔
545
            }
546
        }
547
    }
548
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc