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

vigna / webgraph-rs / 23365769388

20 Mar 2026 10:52PM UTC coverage: 68.228% (-3.0%) from 71.245%
23365769388

push

github

vigna
No le_bins,be_bins for webgraph

6655 of 9754 relevant lines covered (68.23%)

46582760.24 hits per line

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

63.79
/cli/src/lib.rs
1
/*
2
 * SPDX-FileCopyrightText: 2023 Inria
3
 * SPDX-FileCopyrightText: 2023 Tommaso Fontana
4
 * SPDX-FileCopyrightText: 2025 Sebastiano Vigna
5
 *
6
 * SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
7
 */
8

9
#![doc = include_str!("../README.md")]
10
#![deny(unstable_features)]
11
#![deny(trivial_casts)]
12
#![deny(unconditional_recursion)]
13
#![deny(clippy::empty_loop)]
14
#![deny(unreachable_code)]
15
#![deny(unreachable_pub)]
16
#![deny(unreachable_patterns)]
17
#![deny(unused_macro_rules)]
18
#![deny(unused_doc_comments)]
19
#![allow(clippy::type_complexity)]
20

21
use anyhow::{Context, Result, anyhow, bail, ensure};
22
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
23
use dsi_bitstream::dispatch::Codes;
24
use epserde::deser::{Deserialize, Flags, MemCase};
25
use epserde::ser::Serialize;
26
use num_traits::{FromBytes, ToBytes};
27
use std::fmt::Display;
28
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
29
use std::path::{Path, PathBuf};
30
use std::str::FromStr;
31
use std::time::Duration;
32
use std::time::SystemTime;
33
use sux::bits::BitFieldVec;
34
use sux::utils::PrimitiveUnsignedExt;
35
use value_traits::slices::SliceByValue;
36
use webgraph::prelude::CompFlags;
37
#[cfg(target_pointer_width = "64")]
38
use webgraph::prelude::JavaPermutation;
39
use webgraph::utils::{Granularity, MemoryUsage};
40

41
macro_rules! SEQ_PROC_WARN {
42
    () => {"Processing the graph sequentially: for parallel processing please build the Elias–Fano offsets list using 'webgraph build ef {}'"}
43
}
44

45
#[cfg(not(any(feature = "le_bins", feature = "be_bins")))]
46
compile_error!("At least one of the features `le_bins` or `be_bins` must be enabled.");
47

48
/// Calls
49
/// [`par_comp_lenders`](webgraph::prelude::BvCompConfig::par_comp_lenders)
50
/// dispatching on a runtime endianness string.
51
///
52
/// * `config` is the [`BvCompConfig`](webgraph::prelude::BvCompConfig) to call
53
///   [`par_comp_lenders`](webgraph::prelude::BvCompConfig::par_comp_lenders) on;
54
///
55
/// * `lenders` and `num_nodes` are the arguments to
56
///   [`par_comp_lenders`](webgraph::prelude::BvCompConfig::par_comp_lenders);
57
///
58
/// * `endianness` is a string specifying the endianness type to use for the
59
///   call; it must implement `AsRef<str>`, and must be equal to the name of one
60
///   of the endianness types supported by the binary (e.g., "BE" or "LE").
61
///
62
/// The macro returns a [`Result`] with the output of the call if the endianness
63
/// is recognized, and an error otherwise.
64
#[macro_export]
65
macro_rules! par_comp_lenders {
66
    ($config:expr, $lenders:expr, $num_nodes:expr, $endianness:expr) => {
67
        match $endianness.as_str() {
68
            #[cfg(feature = "be_bins")]
69
            BE::NAME => $config.par_comp_lenders::<BE, _>($lenders, $num_nodes),
70
            #[cfg(feature = "le_bins")]
71
            LE::NAME => $config.par_comp_lenders::<LE, _>($lenders, $num_nodes),
72
            _e => anyhow::bail!("Unknown endianness: {}", _e),
73
        }
74
    };
75
}
76

77
pub mod build_info {
78
    include!(concat!(env!("OUT_DIR"), "/built.rs"));
79

80
    pub fn version_string() -> String {
95✔
81
        format!(
95✔
82
            "{}
83
git info: {} {} {}
84
build info: built on {} for {} with {}",
85
            PKG_VERSION,
95✔
86
            GIT_VERSION.unwrap_or(""),
285✔
87
            GIT_COMMIT_HASH.unwrap_or(""),
285✔
88
            match GIT_DIRTY {
95✔
89
                None => "",
95✔
90
                Some(true) => "(dirty)",
×
91
                Some(false) => "(clean)",
×
92
            },
93
            BUILD_DATE,
94
            TARGET,
95✔
95
            RUSTC_VERSION
95✔
96
        )
97
    }
98
}
99

100
/// Enum for instantaneous codes.
101
///
102
/// It is used to implement [`ValueEnum`] here instead of in [`dsi_bitstream`].
103
///
104
/// For CLI ergonomics and compatibility, these codes must be the same as those
105
/// appearing in [`CompFlags::code_from_str`].​
106
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
107
pub enum PrivCode {
108
    Unary,
109
    Gamma,
110
    Delta,
111
    Omega,
112
    Zeta1,
113
    Zeta2,
114
    Zeta3,
115
    Zeta4,
116
    Zeta5,
117
    Zeta6,
118
    Zeta7,
119
    Pi1,
120
    Pi2,
121
    Pi3,
122
    Pi4,
123
}
124

125
impl From<PrivCode> for Codes {
126
    fn from(value: PrivCode) -> Self {
50✔
127
        match value {
50✔
128
            PrivCode::Unary => Codes::Unary,
10✔
129
            PrivCode::Gamma => Codes::Gamma,
30✔
130
            PrivCode::Delta => Codes::Delta,
×
131
            PrivCode::Omega => Codes::Omega,
×
132
            PrivCode::Zeta1 => Codes::Zeta(1),
×
133
            PrivCode::Zeta2 => Codes::Zeta(2),
×
134
            PrivCode::Zeta3 => Codes::Zeta(3),
10✔
135
            PrivCode::Zeta4 => Codes::Zeta(4),
×
136
            PrivCode::Zeta5 => Codes::Zeta(5),
×
137
            PrivCode::Zeta6 => Codes::Zeta(6),
×
138
            PrivCode::Zeta7 => Codes::Zeta(7),
×
139
            PrivCode::Pi1 => Codes::Pi(1),
×
140
            PrivCode::Pi2 => Codes::Pi(2),
×
141
            PrivCode::Pi3 => Codes::Pi(3),
×
142
            PrivCode::Pi4 => Codes::Pi(4),
×
143
        }
144
    }
145
}
146

147
/// Shared CLI arguments for reading files containing arcs.​
148
#[derive(Args, Debug)]
149
pub struct ArcsArgs {
150
    #[arg(long, default_value_t = '#')]
151
    /// Ignore lines that start with this symbol.​
152
    pub line_comment_symbol: char,
153

154
    #[arg(long, default_value_t = 0)]
155
    /// Number of lines to skip, ignoring comment lines.​
156
    pub lines_to_skip: usize,
157

158
    #[arg(long)]
159
    /// Maximum number of lines to parse, after skipping and ignoring comment
160
    /// lines.​
161
    pub max_arcs: Option<usize>,
162

163
    #[arg(long, default_value_t = '\t')]
164
    /// The column separator.​
165
    pub separator: char,
166

167
    #[arg(long, default_value_t = 0)]
168
    /// The index of the column containing the source node of an arc.​
169
    pub source_column: usize,
170

171
    #[arg(long, default_value_t = 1)]
172
    /// The index of the column containing the target node of an arc.​
173
    pub target_column: usize,
174

175
    #[arg(long, default_value_t = false)]
176
    /// Treat source and target values as string labels rather than numeric
177
    /// node identifiers.​
178
    pub labels: bool,
179
}
180

181
/// Parses the number of threads from a string.
182
///
183
/// This function is meant to be used with `#[arg(...,  value_parser =
184
/// num_threads_parser)]`.
185
pub fn num_threads_parser(arg: &str) -> Result<usize> {
20✔
186
    let num_threads = arg.parse::<usize>()?;
60✔
187
    ensure!(num_threads > 0, "Number of threads must be greater than 0");
40✔
188
    Ok(num_threads)
20✔
189
}
190

191
/// Shared CLI arguments for commands that specify a number of threads.​
192
#[derive(Args, Debug)]
193
pub struct NumThreadsArg {
194
    #[arg(short = 'j', long, default_value_t = rayon::current_num_threads().max(1), value_parser = num_threads_parser)]
195
    /// The number of threads to use.​
196
    pub num_threads: usize,
197
}
198

199
/// Shared CLI arguments for commands that specify a granularity.​
200
#[derive(Args, Debug)]
201
pub struct GranularityArgs {
202
    #[arg(long, conflicts_with("node_granularity"))]
203
    /// The tentative number of arcs used to define the size of a parallel job
204
    /// (advanced option).​
205
    pub arc_granularity: Option<u64>,
206

207
    #[arg(long, conflicts_with("arc_granularity"))]
208
    /// The tentative number of nodes used to define the size of a parallel job
209
    /// (advanced option).​
210
    pub node_granularity: Option<usize>,
211
}
212

213
impl GranularityArgs {
214
    pub fn into_granularity(&self) -> Granularity {
10✔
215
        match (self.arc_granularity, self.node_granularity) {
20✔
216
            (Some(_), Some(_)) => unreachable!(),
217
            (Some(arc_granularity), None) => Granularity::Arcs(arc_granularity),
×
218
            (None, Some(node_granularity)) => Granularity::Nodes(node_granularity),
×
219
            (None, None) => Granularity::default(),
10✔
220
        }
221
    }
222
}
223

224
/// Shared CLI arguments for commands that specify a memory usage.
225
///
226
/// Accepts a plain number, a number with a suffix, or a percentage. If the
227
/// value ends with `b` or `B` it is interpreted as a number of bytes;
228
/// otherwise, it is interpreted as a number of elements. The available SI
229
/// and NIST multipliers are k, M, G, T, P, ki, Mi, Gi, Ti, and Pi. A
230
/// trailing `%` interprets the value as a percentage of the available
231
/// memory. The default is `50%`.​
232
#[derive(Args, Debug)]
233
pub struct MemoryUsageArg {
234
    #[clap(short = 'm', long = "memory-usage", value_parser = memory_usage_parser, default_value = "50%")]
235
    /// The memory usage for batches (a number of elements with an optional
236
    /// SI/NIST suffix; append "b"/"B" for bytes, "%" for a percentage of
237
    /// available memory).​
238
    pub memory_usage: MemoryUsage,
239
}
240

241
/// Formats for storing and loading slices of floats.​
242
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
243
pub enum FloatSliceFormat {
244
    /// Java-compatible format: a sequence of big-endian floats (32 or 64 bits).​
245
    Java,
246
    /// A sequence of floats (32 or 64 bits) serialized using ε-serde.​
247
    Epserde,
248
    /// ASCII format, one float per line.​
249
    #[default]
250
    Ascii,
251
    /// A JSON array.​
252
    Json,
253
}
254

255
impl FloatSliceFormat {
256
    /// Stores a slice of floats in the specified `path` using the format defined by
257
    /// `self`.
258
    ///
259
    /// If the result is a textual format, that is, ASCII or JSON, `precision`
260
    /// will be used to round the float values to the specified number of
261
    /// decimal digits. If `None`, [zmij](https://crates.io/crates/zmij)
262
    /// formatting will be used.
263
    pub fn store<F>(
65✔
264
        &self,
265
        path: impl AsRef<Path>,
266
        values: &[F],
267
        precision: Option<usize>,
268
    ) -> Result<()>
269
    where
270
        F: ToBytes + Display + epserde::ser::Serialize + Copy + zmij::Float,
271
        for<'a> &'a [F]: epserde::ser::Serialize,
272
    {
273
        create_parent_dir(&path)?;
130✔
274
        let path_display = path.as_ref().display();
195✔
275
        let file = std::fs::File::create(&path)
195✔
276
            .with_context(|| format!("Could not create slice at {}", path_display))?;
65✔
277
        let mut file = BufWriter::new(file);
195✔
278

279
        match self {
65✔
280
            FloatSliceFormat::Epserde => {
×
281
                log::info!("Storing in ε-serde format at {}", path_display);
15✔
282
                // SAFETY: the type is ε-serde serializable.
283
                unsafe {
284
                    values
15✔
285
                        .serialize(&mut file)
30✔
286
                        .with_context(|| format!("Could not write slice to {}", path_display))
15✔
287
                }?;
288
            }
289
            FloatSliceFormat::Java => {
×
290
                log::info!("Storing in Java format at {}", path_display);
15✔
291
                for word in values.iter() {
80✔
292
                    file.write_all(word.to_be_bytes().as_ref())
150✔
293
                        .with_context(|| format!("Could not write slice to {}", path_display))?;
50✔
294
                }
295
            }
296
            FloatSliceFormat::Ascii => {
×
297
                log::info!("Storing in ASCII format at {}", path_display);
15✔
298
                let mut buf = zmij::Buffer::new();
30✔
299
                for word in values.iter() {
80✔
300
                    match precision {
50✔
301
                        None => writeln!(file, "{}", buf.format(*word)),
250✔
302
                        Some(precision) => writeln!(file, "{word:.precision$}"),
×
303
                    }
304
                    .with_context(|| format!("Could not write slice to {}", path_display))?;
50✔
305
                }
306
            }
307
            FloatSliceFormat::Json => {
×
308
                log::info!("Storing in JSON format at {}", path_display);
20✔
309
                let mut buf = zmij::Buffer::new();
40✔
310
                write!(file, "[")?;
40✔
311
                for word in values.iter().take(values.len().saturating_sub(1)) {
165✔
312
                    match precision {
45✔
313
                        None => write!(file, "{}, ", buf.format(*word)),
200✔
314
                        Some(precision) => write!(file, "{word:.precision$}, "),
15✔
315
                    }
316
                    .with_context(|| format!("Could not write slice to {}", path_display))?;
45✔
317
                }
318
                if let Some(last) = values.last() {
35✔
319
                    match precision {
15✔
320
                        None => write!(file, "{}", buf.format(*last)),
50✔
321
                        Some(precision) => write!(file, "{last:.precision$}"),
15✔
322
                    }
323
                    .with_context(|| format!("Could not write slice to {}", path_display))?;
15✔
324
                }
325
                write!(file, "]")?;
40✔
326
            }
327
        }
328

329
        Ok(())
65✔
330
    }
331

332
    /// Loads float values from the specified `path` using the format defined
333
    /// by `self`.
334
    pub fn load<F>(&self, path: impl AsRef<Path>) -> Result<Vec<F>>
60✔
335
    where
336
        F: FromBytes + FromStr + Copy + serde::de::DeserializeOwned,
337
        <F as FromBytes>::Bytes: for<'a> TryFrom<&'a [u8]>,
338
        <F as FromStr>::Err: std::error::Error + Send + Sync + 'static,
339
        Vec<F>: epserde::deser::Deserialize,
340
    {
341
        let path = path.as_ref();
180✔
342
        let path_display = path.display();
180✔
343

344
        match self {
60✔
345
            FloatSliceFormat::Epserde => {
×
346
                log::info!("Loading ε-serde format from {}", path_display);
15✔
347
                Ok(unsafe {
×
348
                    <Vec<F>>::load_full(path)
30✔
349
                        .with_context(|| format!("Could not load slice from {}", path_display))?
15✔
350
                })
351
            }
352
            FloatSliceFormat::Java => {
×
353
                log::info!("Loading Java format from {}", path_display);
15✔
354
                let file = std::fs::File::open(path)
45✔
355
                    .with_context(|| format!("Could not open {}", path_display))?;
15✔
356
                let file_len = file.metadata()?.len() as usize;
60✔
357
                let byte_size = size_of::<F>();
30✔
358
                ensure!(
15✔
359
                    file_len % byte_size == 0,
15✔
360
                    "File size ({}) is not a multiple of {} bytes",
×
361
                    file_len,
×
362
                    byte_size
×
363
                );
364
                let n = file_len / byte_size;
30✔
365
                let mut reader = BufReader::new(file);
45✔
366
                let mut result = Vec::with_capacity(n);
45✔
367
                let mut buf = vec![0u8; byte_size];
45✔
368
                for i in 0..n {
65✔
369
                    reader.read_exact(&mut buf).with_context(|| {
200✔
370
                        format!("Could not read value at index {i} from {}", path_display)
×
371
                    })?;
372
                    let bytes = buf.as_slice().try_into().map_err(|_| {
200✔
373
                        anyhow!("Could not convert bytes at index {i} in {}", path_display)
×
374
                    })?;
375
                    result.push(F::from_be_bytes(&bytes));
200✔
376
                }
377
                Ok(result)
15✔
378
            }
379
            FloatSliceFormat::Ascii => {
×
380
                log::info!("Loading ASCII format from {}", path_display);
15✔
381
                let file = std::fs::File::open(path)
45✔
382
                    .with_context(|| format!("Could not open {}", path_display))?;
15✔
383
                let reader = BufReader::new(file);
45✔
384
                reader
15✔
385
                    .lines()
386
                    .enumerate()
387
                    .filter(|(_, line)| line.as_ref().map_or(true, |l| !l.trim().is_empty()))
265✔
388
                    .map(|(i, line)| {
65✔
389
                        let line = line.with_context(|| {
150✔
390
                            format!("Error reading line {} of {}", i + 1, path_display)
×
391
                        })?;
392
                        line.trim().parse::<F>().map_err(|e| {
150✔
393
                            anyhow!("Error parsing line {} of {}: {}", i + 1, path_display, e)
×
394
                        })
395
                    })
396
                    .collect()
397
            }
398
            FloatSliceFormat::Json => {
×
399
                log::info!("Loading JSON format from {}", path_display);
15✔
400
                let file = std::fs::File::open(path)
45✔
401
                    .with_context(|| format!("Could not open {}", path_display))?;
15✔
402
                let reader = BufReader::new(file);
45✔
403
                serde_json::from_reader(reader)
30✔
404
                    .with_context(|| format!("Could not parse JSON from {}", path_display))
15✔
405
            }
406
        }
407
    }
408
}
409

410
/// How to store slices of integers.​
411
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
412
pub enum IntSliceFormat {
413
    #[cfg(target_pointer_width = "64")]
414
    /// Java-compatible format: a sequence of big-endian 64-bit integers; available only on 64-bit platforms.​
415
    Java,
416
    /// A sequence of usize serialized using ε-serde.​
417
    Epserde,
418
    /// A BitFieldVec stored using ε-serde: it stores each element using
419
    /// ⌊log₂(max)⌋ + 1 bits, and it requires to allocate the `BitFieldVec` in RAM
420
    /// before serializing it.​
421
    BitFieldVec,
422
    /// ASCII format, one integer per line.​
423
    #[default]
424
    Ascii,
425
    /// A JSON array.​
426
    Json,
427
}
428

429
/// Loaded integer slice, returned by [`IntSliceFormat::load`].
430
///
431
/// Depending on the format, the data may be backed by a memory-mapped file
432
/// (Java, ε-serde, and [`BitFieldVec`]) or fully loaded into memory (ASCII,
433
/// JSON).
434
///
435
/// This enum implements [`SliceByValue`] with `Value = usize`, so it can
436
/// be used directly wherever a [`SliceByValue`] is expected. However, each
437
/// access goes through enum dispatch, which may be undesirable in
438
/// performance-critical code.
439
///
440
/// For native, dispatch-free access, match on the variants and use each
441
/// inner type directly—they all implement [`SliceByValue<Value = usize>`]:
442
///
443
/// ```ignore
444
/// match args.fmt.load(&path)? {
445
///     IntSlice::Owned(v) => do_transform(&v, ...),
446
///     IntSlice::Java(j) => do_transform(&j, ...),
447
///     IntSlice::Epserde(m) => do_transform(m.uncase(), ...),
448
///     IntSlice::BitFieldVec(m) => do_transform(m.uncase(), ...),
449
/// }
450
/// ```
451
///
452
/// This incurs one monomorphization of `do_transform` per arm; arms that
453
/// share the same inner type can be merged to reduce monomorphization cost.
454
pub enum IntSlice {
455
    /// Fully loaded into memory (ASCII, JSON).
456
    Owned(Box<[usize]>),
457
    #[cfg(target_pointer_width = "64")]
458
    /// Memory-mapped Java big-endian format (64-bit only).
459
    Java(JavaPermutation),
460
    /// Memory-mapped ε-serde serialized slice.
461
    Epserde(MemCase<Box<[usize]>>),
462
    /// Memory-mapped ε-serde serialized [`BitFieldVec`].
463
    BitFieldVec(MemCase<sux::bits::BitFieldVec>),
464
}
465

466
impl SliceByValue for IntSlice {
467
    type Value = usize;
468

469
    unsafe fn get_value_unchecked(&self, index: usize) -> usize {
19,336,450✔
470
        match self {
19,336,450✔
471
            IntSlice::Owned(v) => unsafe { *v.get_unchecked(index) },
58,009,140✔
472
            #[cfg(target_pointer_width = "64")]
473
            IntSlice::Java(j) => unsafe { j.get_value_unchecked(index) },
100✔
474
            IntSlice::Epserde(m) => unsafe { *m.uncase().get_unchecked(index) },
75✔
475
            IntSlice::BitFieldVec(m) => unsafe { m.uncase().get_value_unchecked(index) },
80✔
476
        }
477
    }
478

479
    fn len(&self) -> usize {
19,336,510✔
480
        match self {
19,336,510✔
481
            IntSlice::Owned(v) => v.len(),
58,009,245✔
482
            #[cfg(target_pointer_width = "64")]
483
            IntSlice::Java(j) => j.len(),
105✔
484
            IntSlice::Epserde(m) => m.uncase().len(),
105✔
485
            IntSlice::BitFieldVec(m) => m.uncase().len(),
75✔
486
        }
487
    }
488
}
489

490
/// Dispatches on an [`IntSlice`], binding the concrete inner
491
/// [`SliceByValue`] type to `$var` and evaluating `$body` for each variant.
492
///
493
/// The bound types are `&Box<[usize]>` (Owned and Epserde),
494
/// `&JavaPermutation` (Java, 64-bit only), and `&BitFieldVec`
495
/// (BitFieldVec). All bound types are `Sized`.
496
#[macro_export]
497
macro_rules! dispatch_int_slice {
498
    ($slice:expr, |$var:ident| $body:expr) => {
499
        match $slice {
500
            $crate::IntSlice::Owned(ref __v) => {
501
                let $var = __v;
502
                $body
503
            }
504
            #[cfg(target_pointer_width = "64")]
505
            $crate::IntSlice::Java(ref __j) => {
506
                let $var = __j;
507
                $body
508
            }
509
            $crate::IntSlice::Epserde(ref __m) => {
510
                let $var = __m.uncase();
511
                $body
512
            }
513
            $crate::IntSlice::BitFieldVec(ref __m) => {
514
                let $var = __m.uncase();
515
                $body
516
            }
517
        }
518
    };
519
}
520

521
impl IntSliceFormat {
522
    /// Stores a slice of `usize` in the specified `path` using the format
523
    /// defined by `self`.
524
    ///
525
    /// `max` is the maximum value of the slice. If it is not provided, it will
526
    /// be computed from the data.
527
    pub fn store(&self, path: impl AsRef<Path>, data: &[usize], max: Option<usize>) -> Result<()> {
65✔
528
        // Ensure the parent directory exists
529
        create_parent_dir(&path)?;
130✔
530

531
        let mut file = std::fs::File::create(&path)
195✔
532
            .with_context(|| format!("Could not create slice at {}", path.as_ref().display()))?;
65✔
533
        let mut buf = BufWriter::new(&mut file);
195✔
534

535
        debug_assert_eq!(
65✔
536
            max,
×
537
            max.map(|_| { data.iter().copied().max().unwrap_or(0) }),
165✔
538
            "The wrong maximum value was provided for the slice"
×
539
        );
540

541
        match self {
65✔
542
            IntSliceFormat::Epserde => {
×
543
                log::info!("Storing in ε-serde format at {}", path.as_ref().display());
10✔
544
                // SAFETY: the type is ε-serde serializable.
545
                unsafe {
546
                    data.serialize(&mut buf).with_context(|| {
40✔
547
                        format!("Could not write slice to {}", path.as_ref().display())
×
548
                    })
549
                }?;
550
            }
551
            IntSliceFormat::BitFieldVec => {
×
552
                log::info!(
5✔
553
                    "Storing in BitFieldVec format at {}",
×
554
                    path.as_ref().display()
×
555
                );
556
                let max = max.unwrap_or_else(|| {
15✔
557
                    data.iter()
×
558
                        .copied()
×
559
                        .max()
×
560
                        .unwrap_or_else(|| panic!("Empty slice"))
×
561
                });
562
                let bit_width = max.bit_len() as usize;
10✔
563
                log::info!("Using {} bits per element", bit_width);
5✔
564
                let mut bit_field_vec = BitFieldVec::with_capacity(bit_width, data.len());
25✔
565
                bit_field_vec.extend(data.iter().copied());
25✔
566
                // SAFETY: the type is ε-serde serializable.
567
                unsafe {
568
                    bit_field_vec.store(&path).with_context(|| {
20✔
569
                        format!("Could not write slice to {}", path.as_ref().display())
×
570
                    })
571
                }?;
572
            }
573
            #[cfg(target_pointer_width = "64")]
574
            IntSliceFormat::Java => {
×
575
                log::info!("Storing in Java format at {}", path.as_ref().display());
10✔
576
                for word in data.iter() {
45✔
577
                    buf.write_all(&word.to_be_bytes()).with_context(|| {
100✔
578
                        format!("Could not write slice to {}", path.as_ref().display())
×
579
                    })?;
580
                }
581
            }
582
            IntSliceFormat::Ascii => {
×
583
                log::info!("Storing in ASCII format at {}", path.as_ref().display());
60✔
584
                for word in data.iter() {
4,883,445✔
585
                    writeln!(buf, "{}", word).with_context(|| {
14,650,155✔
586
                        format!("Could not write slice to {}", path.as_ref().display())
×
587
                    })?;
588
                }
589
            }
590
            IntSliceFormat::Json => {
×
591
                log::info!("Storing in JSON format at {}", path.as_ref().display());
10✔
592
                serde_json::to_writer(&mut buf, data).with_context(|| {
40✔
593
                    format!("Could not write slice to {}", path.as_ref().display())
×
594
                })?;
595
            }
596
        };
597

598
        Ok(())
65✔
599
    }
600

601
    /// Loads integer values from the specified `path` using the format defined
602
    /// by `self`, returning an [`IntSlice`].
603
    ///
604
    /// The ε-serde-based formats (Epserde, BitFieldVec) and the Java format
605
    /// use memory mapping; ASCII and JSON are fully loaded into memory.
606
    pub fn load(&self, path: impl AsRef<Path>) -> Result<IntSlice> {
66✔
607
        let path = path.as_ref();
198✔
608
        let path_display = path.display();
198✔
609

610
        match self {
66✔
611
            IntSliceFormat::Epserde => {
×
612
                log::info!("Loading ε-serde format from {}", path_display);
10✔
613
                let mem_case = unsafe {
614
                    <Box<[usize]>>::mmap(path, Flags::RANDOM_ACCESS)
20✔
615
                        .with_context(|| format!("Could not load slice from {}", path_display))?
10✔
616
                };
617
                Ok(IntSlice::Epserde(mem_case))
10✔
618
            }
619
            IntSliceFormat::BitFieldVec => {
×
620
                log::info!("Loading BitFieldVec format from {}", path_display);
5✔
621
                let mem_case = unsafe {
622
                    <BitFieldVec>::mmap(path, Flags::RANDOM_ACCESS)
10✔
623
                        .with_context(|| format!("Could not load slice from {}", path_display))?
5✔
624
                };
625
                Ok(IntSlice::BitFieldVec(mem_case))
5✔
626
            }
627
            #[cfg(target_pointer_width = "64")]
628
            IntSliceFormat::Java => {
×
629
                log::info!("Loading Java format from {}", path_display);
10✔
630
                let perm = JavaPermutation::mmap(path, mmap_rs::MmapFlags::RANDOM_ACCESS)
30✔
631
                    .with_context(|| format!("Could not load slice from {}", path_display))?;
10✔
632
                Ok(IntSlice::Java(perm))
10✔
633
            }
634
            IntSliceFormat::Ascii => {
×
635
                log::info!("Loading ASCII format from {}", path_display);
31✔
636
                let file = std::fs::File::open(path)
93✔
637
                    .with_context(|| format!("Could not open {}", path_display))?;
31✔
638
                let reader = BufReader::new(file);
93✔
639
                let v: Vec<usize> = reader
93✔
640
                    .lines()
641
                    .enumerate()
642
                    .filter(|(_, line)| line.as_ref().map_or(true, |l| !l.trim().is_empty()))
34,183,641✔
643
                    .map(|(i, line)| {
6,836,753✔
644
                        let line = line.with_context(|| {
20,510,166✔
645
                            format!("Error reading line {} of {}", i + 1, path_display)
×
646
                        })?;
647
                        line.trim().parse::<usize>().map_err(|e| {
20,510,166✔
648
                            anyhow!("Error parsing line {} of {}: {}", i + 1, path_display, e)
×
649
                        })
650
                    })
651
                    .collect::<Result<_>>()?;
652
                Ok(IntSlice::Owned(v.into_boxed_slice()))
31✔
653
            }
654
            IntSliceFormat::Json => {
×
655
                log::info!("Loading JSON format from {}", path_display);
10✔
656
                let file = std::fs::File::open(path)
30✔
657
                    .with_context(|| format!("Could not open {}", path_display))?;
10✔
658
                let reader = BufReader::new(file);
30✔
659
                let v: Vec<usize> = serde_json::from_reader(reader)
40✔
660
                    .with_context(|| format!("Could not parse JSON from {}", path_display))?;
10✔
661
                Ok(IntSlice::Owned(v.into_boxed_slice()))
10✔
662
            }
663
        }
664
    }
665
}
666

667
/// Parses a batch size.
668
///
669
/// This function accepts either a number (possibly followed by a
670
/// SI or NIST multiplier k, M, G, T, P, ki, Mi, Gi, Ti, or Pi), or a percentage
671
/// (followed by a `%`) that is interpreted as a percentage of the core
672
/// memory. If the value ends with a `b` or `B` it is interpreted as a number of
673
/// bytes, otherwise as a number of elements.
674
pub fn memory_usage_parser(arg: &str) -> anyhow::Result<MemoryUsage> {
10✔
675
    const PREF_SYMS: [(&str, u64); 10] = [
676
        ("ki", 1 << 10),
677
        ("mi", 1 << 20),
678
        ("gi", 1 << 30),
679
        ("ti", 1 << 40),
680
        ("pi", 1 << 50),
681
        ("k", 1E3 as u64),
682
        ("m", 1E6 as u64),
683
        ("g", 1E9 as u64),
684
        ("t", 1E12 as u64),
685
        ("p", 1E15 as u64),
686
    ];
687
    let arg = arg.trim().to_ascii_lowercase();
30✔
688
    ensure!(!arg.is_empty(), "empty string");
20✔
689

690
    if arg.ends_with('%') {
10✔
691
        let perc = arg[..arg.len() - 1].parse::<f64>()?;
40✔
692
        ensure!((0.0..=100.0).contains(&perc), "percentage out of range");
40✔
693
        return Ok(MemoryUsage::from_perc(perc));
10✔
694
    }
695

696
    let num_digits = arg
×
697
        .chars()
698
        .take_while(|c| c.is_ascii_digit() || *c == '.')
×
699
        .count();
700

701
    let number = arg[..num_digits].parse::<f64>()?;
×
702
    let suffix = &arg[num_digits..].trim();
×
703

704
    if suffix.is_empty() {
×
705
        let value = number as usize;
×
706
        ensure!(value > 0, "batch size must be greater than zero");
×
707
        return Ok(MemoryUsage::BatchSize(value));
×
708
    }
709

710
    let prefix = suffix.strip_suffix('b').unwrap_or(suffix);
×
711
    let multiplier = PREF_SYMS
×
712
        .iter()
713
        .find(|(x, _)| *x == prefix)
×
714
        .map(|(_, m)| m)
×
715
        .ok_or(anyhow!("invalid suffix {}", suffix))?;
×
716

717
    let value = (number * (*multiplier as f64)) as usize;
×
718
    ensure!(value > 0, "batch size must be greater than zero");
×
719

720
    if suffix.ends_with('b') {
×
721
        Ok(MemoryUsage::MemorySize(value))
×
722
    } else {
723
        Ok(MemoryUsage::BatchSize(value))
×
724
    }
725
}
726

727
/// Shared CLI arguments for compression.​
728
#[derive(Args, Debug, Clone)]
729
pub struct CompressArgs {
730
    /// The endianness of the graph to write [default: same as source].​
731
    #[clap(short = 'E', long)]
732
    pub endianness: Option<String>,
733

734
    /// The compression window.​
735
    #[clap(short = 'w', long, default_value_t = 7)]
736
    pub compression_window: usize,
737
    /// The minimum interval length.​
738
    #[clap(short = 'i', long, default_value_t = 4)]
739
    pub min_interval_length: usize,
740
    /// The maximum recursion depth for references (-1 for infinite recursion depth).​
741
    #[clap(short = 'r', long, default_value_t = 3)]
742
    pub max_ref_count: isize,
743

744
    #[arg(value_enum)]
745
    #[clap(long, default_value = "gamma")]
746
    /// The code to use for outdegrees.​
747
    pub outdegrees: PrivCode,
748

749
    #[arg(value_enum)]
750
    #[clap(long, default_value = "unary")]
751
    /// The code to use for reference offsets.​
752
    pub references: PrivCode,
753

754
    #[arg(value_enum)]
755
    #[clap(long, default_value = "gamma")]
756
    /// The code to use for blocks.​
757
    pub blocks: PrivCode,
758

759
    #[arg(value_enum)]
760
    #[clap(long, default_value = "zeta3")]
761
    /// The code to use for residuals.​
762
    pub residuals: PrivCode,
763

764
    /// Use Zuckerli's reference selection algorithm (slower, more memory,
765
    /// but better compression and decoding speed).​
766
    #[clap(long)]
767
    pub bvgraphz: bool,
768

769
    /// Number of nodes per chunk with --bvgraphz.​
770
    #[clap(long, default_value = "10000")]
771
    pub chunk_size: usize,
772
}
773

774
impl From<CompressArgs> for CompFlags {
775
    fn from(value: CompressArgs) -> Self {
10✔
776
        CompFlags {
777
            outdegrees: value.outdegrees.into(),
20✔
778
            references: value.references.into(),
20✔
779
            blocks: value.blocks.into(),
20✔
780
            intervals: PrivCode::Gamma.into(),
20✔
781
            residuals: value.residuals.into(),
20✔
782
            min_interval_length: value.min_interval_length,
10✔
783
            compression_window: value.compression_window,
10✔
784
            max_ref_count: match value.max_ref_count {
10✔
785
                -1 => usize::MAX,
786
                max_ref_count => {
787
                    assert!(
788
                        max_ref_count >= 0,
789
                        "max_ref_count cannot be negative, except for -1, which means infinite recursion depth, but got {}",
790
                        max_ref_count
791
                    );
792
                    value.max_ref_count as usize
793
                }
794
            },
795
        }
796
    }
797
}
798

799
/// Creates a [`ThreadPool`](rayon::ThreadPool) with the given number of threads.
800
pub fn get_thread_pool(num_threads: usize) -> rayon::ThreadPool {
20✔
801
    let thread_pool = rayon::ThreadPoolBuilder::new()
40✔
802
        .num_threads(num_threads)
40✔
803
        .build()
804
        .expect("Failed to create thread pool");
805
    log::info!("Using {} threads", thread_pool.current_num_threads());
60✔
806
    thread_pool
20✔
807
}
808

809
/// Appends a string to the filename of a path.
810
///
811
/// # Panics
812
/// * Will panic if there is no filename.
813
/// * Will panic in test mode if the path has an extension.
814
pub fn append(path: impl AsRef<Path>, s: impl AsRef<str>) -> PathBuf {
×
815
    debug_assert!(path.as_ref().extension().is_none());
×
816
    let mut path_buf = path.as_ref().to_owned();
×
817
    let mut filename = path_buf.file_name().unwrap().to_owned();
×
818
    filename.push(s.as_ref());
×
819
    path_buf.set_file_name(filename);
×
820
    path_buf
×
821
}
822

823
/// Creates all parent directories of the given file path.
824
pub fn create_parent_dir(file_path: impl AsRef<Path>) -> Result<()> {
155✔
825
    // ensure that the dst directory exists
826
    if let Some(parent_dir) = file_path.as_ref().parent() {
310✔
827
        std::fs::create_dir_all(parent_dir).with_context(|| {
465✔
828
            format!(
×
829
                "Failed to create the directory {:?}",
×
830
                parent_dir.to_string_lossy()
×
831
            )
832
        })?;
833
    }
834
    Ok(())
155✔
835
}
836

837
/// Computes cutpoints for splitting a graph into chunks for parallel
838
/// compression.
839
///
840
/// If `use_dcf` is true, loads the DCF file for the given basename and uses
841
/// `FairChunks` to balance by arc count. Otherwise, falls back to uniform
842
/// cutpoints by node count.
843
pub fn cutpoints(
10✔
844
    basename: &Path,
845
    num_nodes: usize,
846
    num_arcs: Option<u64>,
847
    use_dcf: bool,
848
) -> Result<Vec<usize>> {
849
    use epserde::prelude::*;
850
    use sux::traits::IndexedSeq;
851
    use sux::utils::FairChunks;
852
    use webgraph::prelude::{DCF, DEG_CUMUL_EXTENSION};
853

854
    if use_dcf {
10✔
855
        let dcf_path = basename.with_extension(DEG_CUMUL_EXTENSION);
×
856
        ensure!(
×
857
            dcf_path.exists(),
×
858
            "DCF file {} does not exist; build it with `webgraph build dcf`",
859
            dcf_path.display()
×
860
        );
861
        let dcf = unsafe { DCF::mmap(&dcf_path, Flags::RANDOM_ACCESS) }?;
×
862
        let dcf = dcf.uncase();
×
863
        ensure!(
×
864
            dcf.len() == num_nodes + 1,
×
865
            "DCF has {} entries, expected {} (num_nodes + 1)",
866
            dcf.len(),
×
867
            num_nodes + 1
×
868
        );
869
        ensure!(dcf.get(0) == 0, "DCF does not start with 0");
×
870
        let num_arcs: u64 = num_arcs.expect("num_arcs_hint required for --dcf");
×
871
        ensure!(
×
872
            dcf.get(num_nodes) == num_arcs,
×
873
            "DCF ends with {}, expected {} (num_arcs)",
874
            dcf.get(num_nodes),
×
875
            num_arcs
876
        );
877
        let num_threads = rayon::current_num_threads();
×
878
        let target_weight = num_arcs.div_ceil(num_threads as u64);
×
879
        let cutpoints: Vec<usize> = std::iter::once(0)
×
880
            .chain(FairChunks::new(target_weight, &dcf).map(|r| r.end))
×
881
            .collect();
882
        log::info!(
×
883
            "Using DCF-based splitting into {} parts",
884
            cutpoints.len() - 1
×
885
        );
886
        Ok(cutpoints)
×
887
    } else {
888
        let dcf_path = basename.with_extension(DEG_CUMUL_EXTENSION);
30✔
889
        if dcf_path.exists() {
10✔
890
            log::warn!(
×
891
                "A DCF (degree cumulative function) file exists at {}; consider using --dcf for better load balancing",
892
                dcf_path.display()
×
893
            );
894
        } else {
895
            log::warn!(
10✔
896
                "No DCF (degree cumulative function) file found; consider building one with `webgraph build dcf {}` for better load balancing",
897
                basename.display()
20✔
898
            );
899
        }
900
        let n = rayon::current_num_threads();
20✔
901
        let step = num_nodes.div_ceil(n);
40✔
902
        Ok((0..n + 1).map(move |i| (i * step).min(num_nodes)).collect())
180✔
903
    }
904
}
905

906
/// Parses a duration from a string.
907
/// For compatibility with Java, if no suffix is given, it is assumed to be in milliseconds.
908
/// You can use suffixes, the available ones are:
909
/// - `s` for seconds
910
/// - `m` for minutes
911
/// - `h` for hours
912
/// - `d` for days
913
///
914
/// Example: `1d2h3m4s567` this is parsed as: 1 day, 2 hours, 3 minutes, 4 seconds, and 567 milliseconds.
915
fn parse_duration(value: &str) -> Result<Duration> {
×
916
    if value.is_empty() {
×
917
        bail!("Empty duration string, if you want every 0 milliseconds use `0`.");
×
918
    }
919
    let mut duration = Duration::from_secs(0);
×
920
    let mut acc = String::new();
×
921
    for c in value.chars() {
×
922
        if c.is_ascii_digit() {
×
923
            acc.push(c);
×
924
        } else if c.is_whitespace() {
×
925
            continue;
×
926
        } else {
927
            let dur = acc.parse::<u64>()?;
×
928
            match c {
×
929
                's' => duration += Duration::from_secs(dur),
×
930
                'm' => duration += Duration::from_secs(dur * 60),
×
931
                'h' => duration += Duration::from_secs(dur * 60 * 60),
×
932
                'd' => duration += Duration::from_secs(dur * 60 * 60 * 24),
×
933
                _ => return Err(anyhow!("Invalid duration suffix: {}", c)),
×
934
            }
935
            acc.clear();
×
936
        }
937
    }
938
    if !acc.is_empty() {
×
939
        let dur = acc.parse::<u64>()?;
×
940
        duration += Duration::from_millis(dur);
×
941
    }
942
    Ok(duration)
×
943
}
944

945
/// Initializes the `env_logger` logger with a custom format including
946
/// timestamps with elapsed time since initialization.
947
pub fn init_env_logger() -> Result<()> {
5✔
948
    use jiff::SpanRound;
949
    use jiff::fmt::friendly::{Designator, Spacing, SpanPrinter};
950

951
    let mut builder =
5✔
952
        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"));
15✔
953

954
    let start = std::time::Instant::now();
10✔
955
    let printer = SpanPrinter::new()
10✔
956
        .spacing(Spacing::None)
10✔
957
        .designator(Designator::Compact);
10✔
958
    let span_round = SpanRound::new()
10✔
959
        .largest(jiff::Unit::Day)
10✔
960
        .smallest(jiff::Unit::Millisecond)
10✔
961
        .days_are_24_hours();
962

963
    builder.format(move |buf, record| {
4,815✔
964
        let Ok(ts) = jiff::Timestamp::try_from(SystemTime::now()) else {
9,610✔
965
            return Err(std::io::Error::other("Failed to get timestamp"));
×
966
        };
967
        let style = buf.default_level_style(record.level());
24,025✔
968
        let elapsed = start.elapsed();
14,415✔
969
        let span = jiff::Span::new()
9,610✔
970
            .seconds(elapsed.as_secs() as i64)
9,610✔
971
            .milliseconds(elapsed.subsec_millis() as i64);
9,610✔
972
        let span = span.round(span_round).expect("Failed to round span");
28,830✔
973
        writeln!(
4,805✔
974
            buf,
4,805✔
975
            "{} {} {style}{}{style:#} [{:?}] {} - {}",
976
            ts.strftime("%F %T%.3f"),
14,415✔
977
            printer.span_to_string(&span),
14,415✔
978
            record.level(),
9,610✔
979
            std::thread::current().id(),
9,610✔
980
            record.target(),
9,610✔
981
            record.args()
9,610✔
982
        )
983
    });
984
    builder.init();
10✔
985
    Ok(())
5✔
986
}
987

988
/// Shared CLI arguments for commands that use a log interval.​
989
#[derive(Args, Debug, Clone)]
990
pub struct LogIntervalArg {
991
    #[arg(long, value_parser = parse_duration)]
992
    /// How often to log progress (default: 10s). Supported suffixes: "s"
993
    /// (seconds), "m" (minutes), "h" (hours), "d" (days). Without a suffix,
994
    /// the value is interpreted as milliseconds. Example: "1d2h3m4s567" means
995
    /// 1 day + 2 hours + 3 minutes + 4 seconds + 567 milliseconds.​
996
    pub log_interval: Option<Duration>,
997
}
998

999
#[derive(Subcommand, Debug)]
1000
pub enum SubCommands {
1001
    #[command(subcommand)]
1002
    Analyze(analyze::SubCommands),
1003
    #[command(subcommand)]
1004
    Bench(bench::SubCommands),
1005
    #[command(subcommand)]
1006
    Build(build::SubCommands),
1007
    #[command(subcommand)]
1008
    Check(check::SubCommands),
1009
    #[command(subcommand)]
1010
    From(from::SubCommands),
1011
    #[command(subcommand)]
1012
    Perm(perm::SubCommands),
1013
    #[command(subcommand)]
1014
    Run(run::SubCommands),
1015
    #[command(subcommand)]
1016
    Seq(seq::SubCommands),
1017
    #[command(subcommand)]
1018
    To(to::SubCommands),
1019
    #[command(subcommand)]
1020
    Transform(transform::SubCommands),
1021
}
1022

1023
#[derive(Parser, Debug)]
1024
#[command(name = "webgraph", version=build_info::version_string(), max_term_width = 100)]
1025
/// WebGraph tools to build, convert, modify, and analyze graphs.​
1026
#[doc = include_str!("common_env.txt")]
1027
pub struct Cli {
1028
    #[command(subcommand)]
1029
    pub command: SubCommands,
1030
}
1031

1032
pub mod dist;
1033
pub mod rank;
1034
pub mod sccs;
1035

1036
pub mod analyze;
1037
pub mod bench;
1038
pub mod build;
1039
pub mod check;
1040
pub mod from;
1041
pub mod perm;
1042
pub mod run;
1043
pub mod seq;
1044
pub mod to;
1045
pub mod transform;
1046

1047
/// The entry point of the command-line interface.
1048
pub fn cli_main<I, T>(args: I) -> Result<()>
12✔
1049
where
1050
    I: IntoIterator<Item = T>,
1051
    T: Into<std::ffi::OsString> + Clone,
1052
{
1053
    let start = std::time::Instant::now();
24✔
1054
    let cli = Cli::parse_from(args);
36✔
1055
    match cli.command {
12✔
1056
        SubCommands::Analyze(args) => {
×
1057
            analyze::main(args)?;
×
1058
        }
1059
        SubCommands::Bench(args) => {
×
1060
            bench::main(args)?;
×
1061
        }
1062
        SubCommands::Build(args) => {
7✔
1063
            build::main(args, Cli::command())?;
21✔
1064
        }
1065
        SubCommands::Check(args) => {
×
1066
            check::main(args)?;
×
1067
        }
1068
        SubCommands::From(args) => {
×
1069
            from::main(args)?;
×
1070
        }
1071
        SubCommands::Perm(args) => {
2✔
1072
            perm::main(args)?;
4✔
1073
        }
1074
        SubCommands::Run(args) => {
1✔
1075
            run::main(args)?;
2✔
1076
        }
1077
        SubCommands::Seq(args) => {
×
1078
            seq::main(args)?;
×
1079
        }
1080
        SubCommands::To(args) => {
1✔
1081
            to::main(args)?;
2✔
1082
        }
1083
        SubCommands::Transform(args) => {
1✔
1084
            transform::main(args)?;
2✔
1085
        }
1086
    }
1087

1088
    log::info!(
12✔
1089
        "The command took {}",
×
1090
        pretty_print_elapsed(start.elapsed().as_secs_f64())
36✔
1091
    );
1092

1093
    Ok(())
12✔
1094
}
1095

1096
/// Pretty-prints seconds in a human-readable format.
1097
fn pretty_print_elapsed(elapsed: f64) -> String {
60✔
1098
    let mut result = String::new();
120✔
1099
    let mut elapsed_seconds = elapsed as u64;
120✔
1100
    let weeks = elapsed_seconds / (60 * 60 * 24 * 7);
120✔
1101
    elapsed_seconds %= 60 * 60 * 24 * 7;
60✔
1102
    let days = elapsed_seconds / (60 * 60 * 24);
120✔
1103
    elapsed_seconds %= 60 * 60 * 24;
60✔
1104
    let hours = elapsed_seconds / (60 * 60);
120✔
1105
    elapsed_seconds %= 60 * 60;
60✔
1106
    let minutes = elapsed_seconds / 60;
120✔
1107
    //elapsed_seconds %= 60;
1108

1109
    match weeks {
60✔
1110
        0 => {}
60✔
1111
        1 => result.push_str("1 week "),
×
1112
        _ => result.push_str(&format!("{} weeks ", weeks)),
×
1113
    }
1114
    match days {
60✔
1115
        0 => {}
60✔
1116
        1 => result.push_str("1 day "),
×
1117
        _ => result.push_str(&format!("{} days ", days)),
×
1118
    }
1119
    match hours {
60✔
1120
        0 => {}
60✔
1121
        1 => result.push_str("1 hour "),
×
1122
        _ => result.push_str(&format!("{} hours ", hours)),
×
1123
    }
1124
    match minutes {
60✔
1125
        0 => {}
55✔
1126
        1 => result.push_str("1 minute "),
×
1127
        _ => result.push_str(&format!("{} minutes ", minutes)),
15✔
1128
    }
1129

1130
    result.push_str(&format!("{:.3} seconds ({}s)", elapsed % 60.0, elapsed));
180✔
1131
    result
60✔
1132
}
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