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

loot / loot-condition-interpreter / 5983913000

26 Aug 2023 08:59AM UTC coverage: 89.882% (+0.07%) from 89.809%
5983913000

push

github

Ortham
Avoid constructing paths using string literals

To avoid the chance of an issue involving a case-sensitive filesystem
and hardcoded string literals in loot-condition-interpreter.

However, I'm pretty sure this was a waste of time, because the library
input strings come from condition strings that do not necessarily
reflect the casing of filenames and paths in the filesystem that the
conditions are evaluated against, and that's much more likely to cause
issues.

129 of 129 new or added lines in 3 files covered. (100.0%)

4104 of 4566 relevant lines covered (89.88%)

14.74 hits per line

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

97.91
/src/function/path.rs
1
use std::{
2
    collections::HashMap,
3
    ffi::{OsStr, OsString},
4
    path::{Path, PathBuf},
5
};
6

7
use crate::{GameType, State};
8

9
const GHOST_EXTENSION: &str = "ghost";
10
const GHOST_EXTENSION_WITH_PERIOD: &str = ".ghost";
11

12
fn is_unghosted_plugin_file_extension(game_type: GameType, extension: &OsStr) -> bool {
71✔
13
    extension.eq_ignore_ascii_case("esp")
71✔
14
        || extension.eq_ignore_ascii_case("esm")
55✔
15
        || (game_type.supports_light_plugins() && extension.eq_ignore_ascii_case("esl"))
37✔
16
}
71✔
17

18
fn has_unghosted_plugin_file_extension(game_type: GameType, path: &Path) -> bool {
32✔
19
    match path.extension() {
32✔
20
        Some(ext) => is_unghosted_plugin_file_extension(game_type, ext),
17✔
21
        _ => false,
15✔
22
    }
23
}
32✔
24

25
pub fn has_ghosted_plugin_file_extension(game_type: GameType, path: &Path) -> bool {
26
    match path.extension() {
21✔
27
        Some(ext) if ext.eq_ignore_ascii_case(GHOST_EXTENSION) => path
×
28
            .file_stem()
×
29
            .map(|s| has_unghosted_plugin_file_extension(game_type, Path::new(s)))
×
30
            .unwrap_or(false),
×
31
        _ => false,
21✔
32
    }
33
}
21✔
34

35
pub fn has_plugin_file_extension(game_type: GameType, path: &Path) -> bool {
36
    match path.extension() {
13✔
37
        Some(ext) if ext.eq_ignore_ascii_case(GHOST_EXTENSION) => path
3✔
38
            .file_stem()
3✔
39
            .map(|s| has_unghosted_plugin_file_extension(game_type, Path::new(s)))
3✔
40
            .unwrap_or(false),
3✔
41
        Some(ext) => is_unghosted_plugin_file_extension(game_type, ext),
9✔
42
        _ => false,
1✔
43
    }
44
}
13✔
45

46
pub fn normalise_file_name(game_type: GameType, name: &OsStr) -> &OsStr {
59✔
47
    let path = Path::new(name);
59✔
48
    if path
59✔
49
        .extension()
59✔
50
        .map(|s| s.eq_ignore_ascii_case(GHOST_EXTENSION))
59✔
51
        .unwrap_or(false)
59✔
52
    {
53
        // name ends in .ghost, trim it and then check the file extension.
54
        if let Some(stem) = path.file_stem() {
3✔
55
            if has_unghosted_plugin_file_extension(game_type, Path::new(stem)) {
3✔
56
                return stem;
3✔
57
            }
×
58
        }
×
59
    }
56✔
60

61
    name
56✔
62
}
59✔
63

64
fn get_ghosted_filename(path: &Path) -> Option<OsString> {
8✔
65
    let mut filename = path.file_name()?.to_os_string();
8✔
66
    filename.push(GHOST_EXTENSION_WITH_PERIOD);
8✔
67
    Some(filename)
8✔
68
}
8✔
69

70
fn add_ghost_extension(
8✔
71
    path: &Path,
8✔
72
    ghosted_plugins: &HashMap<PathBuf, Vec<OsString>>,
8✔
73
) -> Option<PathBuf> {
8✔
74
    // Can't just append a .ghost extension as the filesystem may be case-sensitive and the ghosted
75
    // file may have a .GHOST extension (for example). Instead loop through the other files in the
76
    // same parent directory and look for one that's unicode-case-insensitively-equal.
77
    let expected_filename = get_ghosted_filename(&path)?;
8✔
78
    let expected_filename = expected_filename.to_str()?;
8✔
79
    let parent_path = path.parent()?;
8✔
80

81
    let ghosted_plugins = ghosted_plugins.get(&parent_path.to_path_buf())?;
8✔
82

83
    for ghosted_plugin in ghosted_plugins {
7✔
84
        let ghosted_plugin_str = ghosted_plugin.to_str()?;
5✔
85

86
        if unicase::eq(expected_filename, ghosted_plugin_str) {
5✔
87
            return Some(parent_path.join(ghosted_plugin));
4✔
88
        }
1✔
89
    }
90

91
    None
2✔
92
}
8✔
93

94
pub fn resolve_path(state: &State, path: &Path) -> PathBuf {
74✔
95
    // First check external data paths, as files there may override files in the main data path.
96
    for data_path in &state.additional_data_paths {
75✔
97
        let mut path = data_path.join(path);
2✔
98

2✔
99
        if path.exists() {
2✔
100
            return path;
1✔
101
        }
1✔
102

1✔
103
        if has_unghosted_plugin_file_extension(state.game_type, &path) {
1✔
104
            if let Some(ghosted_path) = add_ghost_extension(&path, &state.ghosted_plugins) {
×
105
                path = ghosted_path
×
106
            }
×
107
        }
1✔
108

109
        if path.exists() {
1✔
110
            return path;
×
111
        }
1✔
112
    }
113

114
    // Now check the main data path.
115
    let path = state.data_path.join(path);
73✔
116

73✔
117
    if !path.exists() && has_unghosted_plugin_file_extension(state.game_type, &path) {
73✔
118
        add_ghost_extension(&path, &state.ghosted_plugins).unwrap_or(path)
4✔
119
    } else {
120
        path
69✔
121
    }
122
}
74✔
123

124
#[cfg(test)]
125
mod tests {
126
    use super::*;
127

128
    #[test]
1✔
129
    fn is_unghosted_plugin_file_extension_should_be_true_for_esp_for_all_game_types() {
1✔
130
        let extension = OsStr::new("Esp");
1✔
131

1✔
132
        assert!(is_unghosted_plugin_file_extension(
1✔
133
            GameType::Morrowind,
1✔
134
            extension
1✔
135
        ));
1✔
136
        assert!(is_unghosted_plugin_file_extension(
1✔
137
            GameType::Oblivion,
1✔
138
            extension
1✔
139
        ));
1✔
140
        assert!(is_unghosted_plugin_file_extension(
1✔
141
            GameType::Skyrim,
1✔
142
            extension
1✔
143
        ));
1✔
144
        assert!(is_unghosted_plugin_file_extension(
1✔
145
            GameType::SkyrimSE,
1✔
146
            extension
1✔
147
        ));
1✔
148
        assert!(is_unghosted_plugin_file_extension(
1✔
149
            GameType::SkyrimVR,
1✔
150
            extension
1✔
151
        ));
1✔
152
        assert!(is_unghosted_plugin_file_extension(
1✔
153
            GameType::Fallout3,
1✔
154
            extension
1✔
155
        ));
1✔
156
        assert!(is_unghosted_plugin_file_extension(
1✔
157
            GameType::FalloutNV,
1✔
158
            extension
1✔
159
        ));
1✔
160
        assert!(is_unghosted_plugin_file_extension(
1✔
161
            GameType::Fallout4,
1✔
162
            extension
1✔
163
        ));
1✔
164
        assert!(is_unghosted_plugin_file_extension(
1✔
165
            GameType::Fallout4VR,
1✔
166
            extension
1✔
167
        ));
1✔
168
    }
1✔
169

170
    #[test]
1✔
171
    fn is_unghosted_plugin_file_extension_should_be_true_for_esm_for_all_game_types() {
1✔
172
        let extension = OsStr::new("Esm");
1✔
173

1✔
174
        assert!(is_unghosted_plugin_file_extension(
1✔
175
            GameType::Morrowind,
1✔
176
            extension
1✔
177
        ));
1✔
178
        assert!(is_unghosted_plugin_file_extension(
1✔
179
            GameType::Oblivion,
1✔
180
            extension
1✔
181
        ));
1✔
182
        assert!(is_unghosted_plugin_file_extension(
1✔
183
            GameType::Skyrim,
1✔
184
            extension
1✔
185
        ));
1✔
186
        assert!(is_unghosted_plugin_file_extension(
1✔
187
            GameType::SkyrimSE,
1✔
188
            extension
1✔
189
        ));
1✔
190
        assert!(is_unghosted_plugin_file_extension(
1✔
191
            GameType::SkyrimVR,
1✔
192
            extension
1✔
193
        ));
1✔
194
        assert!(is_unghosted_plugin_file_extension(
1✔
195
            GameType::Fallout3,
1✔
196
            extension
1✔
197
        ));
1✔
198
        assert!(is_unghosted_plugin_file_extension(
1✔
199
            GameType::FalloutNV,
1✔
200
            extension
1✔
201
        ));
1✔
202
        assert!(is_unghosted_plugin_file_extension(
1✔
203
            GameType::Fallout4,
1✔
204
            extension
1✔
205
        ));
1✔
206
        assert!(is_unghosted_plugin_file_extension(
1✔
207
            GameType::Fallout4VR,
1✔
208
            extension
1✔
209
        ));
1✔
210
    }
1✔
211

212
    #[test]
1✔
213
    fn is_unghosted_plugin_file_extension_should_be_true_for_esl_for_tes5se_tes5vr_fo4_and_fo4vr() {
1✔
214
        let extension = OsStr::new("Esl");
1✔
215

1✔
216
        assert!(is_unghosted_plugin_file_extension(
1✔
217
            GameType::SkyrimSE,
1✔
218
            extension
1✔
219
        ));
1✔
220
        assert!(is_unghosted_plugin_file_extension(
1✔
221
            GameType::SkyrimVR,
1✔
222
            extension
1✔
223
        ));
1✔
224
        assert!(is_unghosted_plugin_file_extension(
1✔
225
            GameType::Fallout4,
1✔
226
            extension
1✔
227
        ));
1✔
228
        assert!(is_unghosted_plugin_file_extension(
1✔
229
            GameType::Fallout4VR,
1✔
230
            extension
1✔
231
        ));
1✔
232
    }
1✔
233

234
    #[test]
1✔
235
    fn is_unghosted_plugin_file_extension_should_be_false_for_esl_for_tes3_to_5_fo3_and_fonv() {
1✔
236
        let extension = OsStr::new("Esl");
1✔
237

1✔
238
        assert!(!is_unghosted_plugin_file_extension(
1✔
239
            GameType::Morrowind,
1✔
240
            extension
1✔
241
        ));
1✔
242
        assert!(!is_unghosted_plugin_file_extension(
1✔
243
            GameType::Oblivion,
1✔
244
            extension
1✔
245
        ));
1✔
246
        assert!(!is_unghosted_plugin_file_extension(
1✔
247
            GameType::Skyrim,
1✔
248
            extension
1✔
249
        ));
1✔
250
        assert!(!is_unghosted_plugin_file_extension(
1✔
251
            GameType::Fallout3,
1✔
252
            extension
1✔
253
        ));
1✔
254
        assert!(!is_unghosted_plugin_file_extension(
1✔
255
            GameType::FalloutNV,
1✔
256
            extension
1✔
257
        ));
1✔
258
    }
1✔
259

260
    #[test]
1✔
261
    fn is_unghosted_plugin_file_extension_should_be_false_for_ghost_for_all_game_types() {
1✔
262
        let extension = OsStr::new("Ghost");
1✔
263

1✔
264
        assert!(!is_unghosted_plugin_file_extension(
1✔
265
            GameType::Morrowind,
1✔
266
            extension
1✔
267
        ));
1✔
268
        assert!(!is_unghosted_plugin_file_extension(
1✔
269
            GameType::Oblivion,
1✔
270
            extension
1✔
271
        ));
1✔
272
        assert!(!is_unghosted_plugin_file_extension(
1✔
273
            GameType::Skyrim,
1✔
274
            extension
1✔
275
        ));
1✔
276
        assert!(!is_unghosted_plugin_file_extension(
1✔
277
            GameType::SkyrimSE,
1✔
278
            extension
1✔
279
        ));
1✔
280
        assert!(!is_unghosted_plugin_file_extension(
1✔
281
            GameType::SkyrimVR,
1✔
282
            extension
1✔
283
        ));
1✔
284
        assert!(!is_unghosted_plugin_file_extension(
1✔
285
            GameType::Fallout3,
1✔
286
            extension
1✔
287
        ));
1✔
288
        assert!(!is_unghosted_plugin_file_extension(
1✔
289
            GameType::FalloutNV,
1✔
290
            extension
1✔
291
        ));
1✔
292
        assert!(!is_unghosted_plugin_file_extension(
1✔
293
            GameType::Fallout4,
1✔
294
            extension
1✔
295
        ));
1✔
296
        assert!(!is_unghosted_plugin_file_extension(
1✔
297
            GameType::Fallout4VR,
1✔
298
            extension
1✔
299
        ));
1✔
300
    }
1✔
301

302
    #[test]
1✔
303
    fn is_unghosted_plugin_file_extension_should_be_false_for_non_esp_esm_esl_for_all_game_types() {
1✔
304
        let extension = OsStr::new("txt");
1✔
305

1✔
306
        assert!(!is_unghosted_plugin_file_extension(
1✔
307
            GameType::Morrowind,
1✔
308
            extension
1✔
309
        ));
1✔
310
        assert!(!is_unghosted_plugin_file_extension(
1✔
311
            GameType::Oblivion,
1✔
312
            extension
1✔
313
        ));
1✔
314
        assert!(!is_unghosted_plugin_file_extension(
1✔
315
            GameType::Skyrim,
1✔
316
            extension
1✔
317
        ));
1✔
318
        assert!(!is_unghosted_plugin_file_extension(
1✔
319
            GameType::SkyrimSE,
1✔
320
            extension
1✔
321
        ));
1✔
322
        assert!(!is_unghosted_plugin_file_extension(
1✔
323
            GameType::SkyrimVR,
1✔
324
            extension
1✔
325
        ));
1✔
326
        assert!(!is_unghosted_plugin_file_extension(
1✔
327
            GameType::Fallout3,
1✔
328
            extension
1✔
329
        ));
1✔
330
        assert!(!is_unghosted_plugin_file_extension(
1✔
331
            GameType::FalloutNV,
1✔
332
            extension
1✔
333
        ));
1✔
334
        assert!(!is_unghosted_plugin_file_extension(
1✔
335
            GameType::Fallout4,
1✔
336
            extension
1✔
337
        ));
1✔
338
        assert!(!is_unghosted_plugin_file_extension(
1✔
339
            GameType::Fallout4VR,
1✔
340
            extension
1✔
341
        ));
1✔
342
    }
1✔
343

344
    #[test]
1✔
345
    fn has_unghosted_plugin_file_extension_should_return_false_if_the_path_has_no_extension() {
1✔
346
        assert!(!has_unghosted_plugin_file_extension(
1✔
347
            GameType::Skyrim,
1✔
348
            Path::new("file")
1✔
349
        ));
1✔
350
    }
1✔
351

352
    #[test]
1✔
353
    fn has_unghosted_plugin_file_extension_should_return_false_if_the_path_has_a_non_plugin_extension(
1✔
354
    ) {
1✔
355
        assert!(!has_unghosted_plugin_file_extension(
1✔
356
            GameType::Skyrim,
1✔
357
            Path::new("plugin.bsa")
1✔
358
        ));
1✔
359
    }
1✔
360

361
    #[test]
1✔
362
    fn has_unghosted_plugin_file_extension_should_return_false_if_the_path_has_a_ghosted_plugin_extension(
1✔
363
    ) {
1✔
364
        assert!(!has_unghosted_plugin_file_extension(
1✔
365
            GameType::Skyrim,
1✔
366
            Path::new("plugin.esp.ghost")
1✔
367
        ));
1✔
368
    }
1✔
369

370
    #[test]
1✔
371
    fn has_unghosted_plugin_file_extension_should_return_true_if_the_path_has_an_unghosted_plugin_extension(
1✔
372
    ) {
1✔
373
        assert!(has_unghosted_plugin_file_extension(
1✔
374
            GameType::Skyrim,
1✔
375
            Path::new("plugin.esp")
1✔
376
        ));
1✔
377
    }
1✔
378

379
    #[test]
1✔
380
    fn has_plugin_file_extension_should_return_true_if_the_path_has_an_unghosted_plugin_extension()
1✔
381
    {
1✔
382
        assert!(has_plugin_file_extension(
1✔
383
            GameType::Skyrim,
1✔
384
            Path::new("plugin.esp")
1✔
385
        ));
1✔
386
    }
1✔
387

388
    #[test]
1✔
389
    fn has_plugin_file_extension_should_return_true_if_the_path_has_a_ghosted_plugin_extension() {
1✔
390
        assert!(has_plugin_file_extension(
1✔
391
            GameType::Skyrim,
1✔
392
            Path::new("plugin.esp.Ghost")
1✔
393
        ));
1✔
394
    }
1✔
395

396
    #[test]
1✔
397
    fn has_plugin_file_extension_should_return_false_if_the_path_has_a_non_plugin_extension() {
1✔
398
        assert!(!has_plugin_file_extension(
1✔
399
            GameType::Skyrim,
1✔
400
            Path::new("plugin.bsa")
1✔
401
        ));
1✔
402
    }
1✔
403

404
    #[test]
1✔
405
    fn has_plugin_file_extension_should_return_false_if_the_path_has_a_ghosted_non_plugin_extension(
1✔
406
    ) {
1✔
407
        assert!(!has_plugin_file_extension(
1✔
408
            GameType::Skyrim,
1✔
409
            Path::new("plugin.bsa.Ghost")
1✔
410
        ));
1✔
411
    }
1✔
412

413
    #[test]
1✔
414
    fn has_plugin_file_extension_should_return_false_if_the_path_has_only_ghost_extension() {
1✔
415
        assert!(!has_plugin_file_extension(
1✔
416
            GameType::Skyrim,
1✔
417
            Path::new("plugin.Ghost")
1✔
418
        ));
1✔
419
    }
1✔
420

421
    #[test]
1✔
422
    fn has_plugin_file_extension_should_return_false_if_the_path_has_no_extension() {
1✔
423
        assert!(!has_plugin_file_extension(
1✔
424
            GameType::Skyrim,
1✔
425
            Path::new("plugin")
1✔
426
        ));
1✔
427
    }
1✔
428

429
    #[test]
1✔
430
    fn add_ghost_extension_should_return_none_if_the_given_parent_path_is_not_in_hashmap() {
1✔
431
        let path = Path::new("subdir/plugin.esp");
1✔
432
        let result = add_ghost_extension(path, &HashMap::new());
1✔
433

1✔
434
        assert!(result.is_none());
1✔
435
    }
1✔
436

437
    #[test]
1✔
438
    fn add_ghost_extension_should_return_none_if_the_given_parent_path_has_no_ghosted_plugins() {
1✔
439
        let path = Path::new("subdir/plugin.esp");
1✔
440
        let mut map = HashMap::new();
1✔
441
        map.insert(PathBuf::from("subdir"), Vec::new());
1✔
442

1✔
443
        let result = add_ghost_extension(path, &map);
1✔
444

1✔
445
        assert!(result.is_none());
1✔
446
    }
1✔
447

448
    #[test]
1✔
449
    fn add_ghost_extension_should_return_none_if_the_given_parent_path_has_no_matching_ghosted_plugins(
1✔
450
    ) {
1✔
451
        let path = Path::new("subdir/plugin.esp");
1✔
452
        let mut map = HashMap::new();
1✔
453
        map.insert(
1✔
454
            PathBuf::from("subdir"),
1✔
455
            vec![OsString::from("plugin.esm.ghost")],
1✔
456
        );
1✔
457
        let result = add_ghost_extension(path, &map);
1✔
458

1✔
459
        assert!(result.is_none());
1✔
460
    }
1✔
461

462
    #[test]
1✔
463
    fn add_ghost_extension_should_return_some_if_the_given_parent_path_has_a_case_insensitively_equal_ghosted_plugin(
1✔
464
    ) {
1✔
465
        let path = Path::new("subdir/plugin.esp");
1✔
466
        let ghosted_plugin = "Plugin.ESp.GHoST";
1✔
467
        let mut map = HashMap::new();
1✔
468
        map.insert(
1✔
469
            PathBuf::from("subdir"),
1✔
470
            vec![OsString::from(ghosted_plugin)],
1✔
471
        );
1✔
472
        let result = add_ghost_extension(path, &map);
1✔
473

1✔
474
        assert!(result.is_some());
1✔
475
        assert_eq!(Path::new("subdir").join(ghosted_plugin), result.unwrap());
1✔
476
    }
1✔
477

478
    #[test]
1✔
479
    fn resolve_path_should_return_the_data_path_prefixed_path_if_it_exists() {
1✔
480
        let data_path = PathBuf::from(".");
1✔
481
        let state = State::new(GameType::Skyrim, data_path.clone());
1✔
482
        let input_path = Path::new("README.md");
1✔
483
        let resolved_path = resolve_path(&state, input_path);
1✔
484

1✔
485
        assert_eq!(data_path.join(input_path), resolved_path);
1✔
486
    }
1✔
487

488
    #[test]
1✔
489
    fn resolve_path_should_return_the_data_path_prefixed_path_if_it_does_not_exist_and_is_not_an_unghosted_plugin_filename(
1✔
490
    ) {
1✔
491
        let data_path = PathBuf::from(".");
1✔
492
        let state = State::new(GameType::Skyrim, data_path.clone());
1✔
493
        let input_path = Path::new("plugin.esp.ghost");
1✔
494
        let resolved_path = resolve_path(&state, input_path);
1✔
495

1✔
496
        assert_eq!(data_path.join(input_path), resolved_path);
1✔
497

498
        let input_path = Path::new("file.txt");
1✔
499
        let resolved_path = resolve_path(&state, input_path);
1✔
500

1✔
501
        assert_eq!(data_path.join(input_path), resolved_path);
1✔
502
    }
1✔
503

504
    #[test]
1✔
505
    fn resolve_path_should_return_the_given_data_relative_path_plus_a_ghost_extension_if_the_plugin_path_does_not_exist(
1✔
506
    ) {
1✔
507
        let data_path = PathBuf::from(".");
1✔
508
        let mut state = State::new(GameType::Skyrim, data_path.clone());
1✔
509
        state
1✔
510
            .ghosted_plugins
1✔
511
            .insert(data_path.clone(), vec![OsString::from("plugin.esp.ghost")]);
1✔
512

1✔
513
        let input_path = Path::new("plugin.esp");
1✔
514
        let resolved_path = resolve_path(&state, input_path);
1✔
515

1✔
516
        assert_eq!(
1✔
517
            data_path.join(input_path.with_extension("esp.ghost")),
1✔
518
            resolved_path
1✔
519
        );
1✔
520
    }
1✔
521

522
    #[test]
1✔
523
    fn resolve_path_should_check_external_data_paths_in_order_before_data_path() {
1✔
524
        use std::fs::copy;
1✔
525
        use std::fs::create_dir;
1✔
526

1✔
527
        let tmp_dir = tempfile::tempdir().unwrap();
1✔
528
        let external_data_path_1 = tmp_dir.path().join("Data1");
1✔
529
        let external_data_path_2 = tmp_dir.path().join("Data2");
1✔
530
        let data_path = tmp_dir.path().join("Data3");
1✔
531

1✔
532
        create_dir(&external_data_path_1).unwrap();
1✔
533
        create_dir(&external_data_path_2).unwrap();
1✔
534
        create_dir(&data_path).unwrap();
1✔
535
        copy(
1✔
536
            Path::new("Cargo.toml"),
1✔
537
            external_data_path_2.join("Cargo.toml"),
1✔
538
        )
1✔
539
        .unwrap();
1✔
540
        copy(Path::new("Cargo.toml"), data_path.join("Cargo.toml")).unwrap();
1✔
541

1✔
542
        let mut state = State::new(GameType::Skyrim, data_path);
1✔
543
        state.set_additional_data_paths(vec![external_data_path_1, external_data_path_2.clone()]);
1✔
544

1✔
545
        let input_path = Path::new("Cargo.toml");
1✔
546
        let resolved_path = resolve_path(&state, input_path);
1✔
547

1✔
548
        assert_eq!(external_data_path_2.join(input_path), resolved_path);
1✔
549
    }
1✔
550
}
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