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

moeyensj / difi / 24739069238

21 Apr 2026 06:19PM UTC coverage: 89.381% (+2.7%) from 86.704%
24739069238

Pull #61

github

web-flow
Merge b7b0ebe74 into f87511030
Pull Request #61: Add difi CLI binary (v2.0.0-rc6)

612 of 649 new or added lines in 4 files covered. (94.3%)

2180 of 2439 relevant lines covered (89.38%)

240.88 hits per line

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

94.12
/src/bin/difi/common.rs
1
//! Shared CLI helpers: argument groups, metric/partition builders, manifest,
2
//! and NDJSON progress event emission.
3
//!
4
//! Kept intentionally small — the CLI is glue, not an algorithm surface.
5

6
use std::io::{Read, Write};
7
use std::path::{Path, PathBuf};
8
use std::sync::Mutex;
9
use std::time::{Instant, SystemTime, UNIX_EPOCH};
10

11
use anyhow::{Context, Result, bail};
12
use clap::{Args, ValueEnum};
13
use serde::{Deserialize, Serialize};
14
use sha2::{Digest, Sha256};
15

16
use difi::metrics::FindabilityMetric;
17
use difi::metrics::singleton::SingletonMetric;
18
use difi::metrics::tracklet::TrackletMetric;
19
use difi::partitions::{self, Partition};
20

21
// ---------------------------------------------------------------------------
22
// Global run context
23
// ---------------------------------------------------------------------------
24

25
/// Context threaded through every subcommand: whether to emit NDJSON progress
26
/// events on stdout, and the timing origin for `elapsed_s`/`total_elapsed_s`.
27
pub struct RunContext {
28
    pub progress_json: bool,
29
    started_at: Instant,
30
    started_at_unix_s: f64,
31
    /// The full argv, recorded in the manifest.
32
    pub command: Vec<String>,
33
    /// Serializes stdout writes so progress events don't interleave.
34
    stdout_lock: Mutex<()>,
35
}
36

37
impl RunContext {
38
    pub fn new(progress_json: bool, command: Vec<String>) -> Self {
12✔
39
        Self {
12✔
40
            progress_json,
12✔
41
            started_at: Instant::now(),
12✔
42
            started_at_unix_s: unix_seconds_now(),
12✔
43
            command,
12✔
44
            stdout_lock: Mutex::new(()),
12✔
45
        }
12✔
46
    }
12✔
47

48
    pub fn elapsed_s(&self) -> f64 {
9✔
49
        self.started_at.elapsed().as_secs_f64()
9✔
50
    }
9✔
51

52
    pub fn started_at_unix_s(&self) -> f64 {
9✔
53
        self.started_at_unix_s
9✔
54
    }
9✔
55

56
    /// Emit a progress event. Under `--progress-json` it goes to stdout as
57
    /// NDJSON; otherwise it's formatted for humans on stderr.
58
    pub fn emit(&self, event: ProgressEvent<'_>) {
51✔
59
        if self.progress_json {
51✔
60
            let mut payload = event.to_json();
6✔
61
            payload["ts_unix_s"] = serde_json::json!(unix_seconds_now());
6✔
62
            let line = payload.to_string();
6✔
63
            let _guard = self.stdout_lock.lock().unwrap();
6✔
64
            let mut out = std::io::stdout().lock();
6✔
65
            // Write + newline; ignore EPIPE so `| head` doesn't panic.
6✔
66
            let _ = writeln!(out, "{line}");
6✔
67
            let _ = out.flush();
6✔
68
        } else {
45✔
69
            let _ = writeln!(std::io::stderr(), "{}", event.human());
45✔
70
        }
45✔
71
    }
51✔
72

73
    /// Emit an error event. Always logs a single-line human message to stderr,
74
    /// and additionally emits an NDJSON `{"event":"error", ...}` on stdout when
75
    /// `--progress-json` is set. Piping stdout through `jq` never suppresses
76
    /// the stderr line.
77
    pub fn emit_error(&self, err: &anyhow::Error) {
3✔
78
        let _ = writeln!(std::io::stderr(), "difi: error: {err:#}");
3✔
79
        if self.progress_json {
3✔
80
            let payload = serde_json::json!({
1✔
81
                "event": "error",
1✔
82
                "message": format!("{err:#}"),
1✔
83
                "ts_unix_s": unix_seconds_now(),
1✔
84
            });
1✔
85
            let _guard = self.stdout_lock.lock().unwrap();
1✔
86
            let mut out = std::io::stdout().lock();
1✔
87
            let _ = writeln!(out, "{payload}");
1✔
88
            let _ = out.flush();
1✔
89
        }
2✔
90
    }
3✔
91
}
92

93
fn unix_seconds_now() -> f64 {
28✔
94
    SystemTime::now()
28✔
95
        .duration_since(UNIX_EPOCH)
28✔
96
        .map(|d| d.as_secs_f64())
28✔
97
        .unwrap_or(0.0)
28✔
98
}
28✔
99

100
// ---------------------------------------------------------------------------
101
// Progress events
102
// ---------------------------------------------------------------------------
103

104
pub enum ProgressEvent<'a> {
105
    Start {
106
        subcommand: &'a str,
107
        step: &'a str,
108
        input: &'a str,
109
    },
110
    LoadedObservations {
111
        count: usize,
112
        elapsed_s: f64,
113
    },
114
    ScenarioStart {
115
        name: &'a str,
116
        metric: &'a str,
117
        partitions: usize,
118
    },
119
    ScenarioDone {
120
        name: &'a str,
121
        findable: i64,
122
        found: Option<i64>,
123
        elapsed_s: f64,
124
    },
125
    Done {
126
        total_elapsed_s: f64,
127
        output_dir: &'a Path,
128
    },
129
}
130

131
impl ProgressEvent<'_> {
132
    fn to_json(&self) -> serde_json::Value {
6✔
133
        match self {
6✔
134
            ProgressEvent::Start {
135
                subcommand,
2✔
136
                step,
2✔
137
                input,
2✔
138
            } => serde_json::json!({
2✔
139
                "event": "start",
2✔
140
                "subcommand": subcommand,
2✔
141
                "step": step,
2✔
142
                "input": input,
2✔
143
            }),
144
            ProgressEvent::LoadedObservations { count, elapsed_s } => serde_json::json!({
1✔
145
                "event": "loaded_observations",
1✔
146
                "count": count,
1✔
147
                "elapsed_s": elapsed_s,
1✔
148
            }),
149
            ProgressEvent::ScenarioStart {
150
                name,
1✔
151
                metric,
1✔
152
                partitions,
1✔
153
            } => serde_json::json!({
1✔
154
                "event": "scenario_start",
1✔
155
                "name": name,
1✔
156
                "metric": metric,
1✔
157
                "partitions": partitions,
1✔
158
            }),
159
            ProgressEvent::ScenarioDone {
160
                name,
1✔
161
                findable,
1✔
162
                found,
1✔
163
                elapsed_s,
1✔
164
            } => serde_json::json!({
1✔
165
                "event": "scenario_done",
1✔
166
                "name": name,
1✔
167
                "findable": findable,
1✔
168
                "found": found,
1✔
169
                "elapsed_s": elapsed_s,
1✔
170
            }),
171
            ProgressEvent::Done {
172
                total_elapsed_s,
1✔
173
                output_dir,
1✔
174
            } => serde_json::json!({
1✔
175
                "event": "done",
1✔
176
                "total_elapsed_s": total_elapsed_s,
1✔
177
                "output_dir": output_dir.display().to_string(),
1✔
178
            }),
179
        }
180
    }
6✔
181

182
    fn human(&self) -> String {
45✔
183
        match self {
45✔
184
            ProgressEvent::Start {
185
                subcommand,
10✔
186
                step,
10✔
187
                input,
10✔
188
            } => format!("[{step}] {subcommand}: input={input}"),
10✔
189
            ProgressEvent::LoadedObservations { count, elapsed_s } => {
9✔
190
                format!("loaded {count} observations in {elapsed_s:.2}s")
9✔
191
            }
192
            ProgressEvent::ScenarioStart {
193
                name,
9✔
194
                metric,
9✔
195
                partitions,
9✔
196
            } => format!("scenario {name} ({metric}, {partitions} partition(s))"),
9✔
197
            ProgressEvent::ScenarioDone {
198
                name,
9✔
199
                findable,
9✔
200
                found,
9✔
201
                elapsed_s,
9✔
202
            } => match found {
9✔
203
                Some(f) => format!(
2✔
204
                    "scenario {name} done in {elapsed_s:.2}s: findable={findable} found={f}"
205
                ),
206
                None => format!("scenario {name} done in {elapsed_s:.2}s: findable={findable}"),
7✔
207
            },
208
            ProgressEvent::Done {
209
                total_elapsed_s,
8✔
210
                output_dir,
8✔
211
            } => format!(
8✔
212
                "done in {:.2}s — wrote to {}",
213
                total_elapsed_s,
214
                output_dir.display()
8✔
215
            ),
216
        }
217
    }
45✔
218
}
219

220
// ---------------------------------------------------------------------------
221
// Metric arguments
222
// ---------------------------------------------------------------------------
223

224
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
225
#[serde(rename_all = "lowercase")]
226
#[value(rename_all = "lowercase")]
227
pub enum MetricKind {
228
    Singletons,
229
    Tracklets,
230
}
231

232
impl MetricKind {
233
    pub fn as_str(self) -> &'static str {
8✔
234
        match self {
8✔
235
            MetricKind::Singletons => "singletons",
6✔
236
            MetricKind::Tracklets => "tracklets",
2✔
237
        }
238
    }
8✔
239
}
240

241
/// Metric configuration for both singleton and tracklet metrics. Unused fields
242
/// for the selected metric are ignored.
243
#[derive(Args, Clone, Debug)]
244
pub struct MetricArgs {
245
    /// Findability metric.
246
    #[arg(short = 'm', long = "metric", value_enum, default_value_t = MetricKind::Singletons)]
247
    pub metric: MetricKind,
248

249
    // ---- Singleton-specific ----
250
    /// Minimum total observations for singleton findability. Also used as the
251
    /// DIFI linkage threshold in `analyze-linkages`.
252
    #[arg(long, default_value_t = 6)]
253
    pub min_obs: usize,
254

255
    /// Minimum distinct nights for singleton findability.
256
    #[arg(long, default_value_t = 3)]
257
    pub min_nights: usize,
258

259
    /// Minimum per-night observations when exactly `min_nights` are present.
260
    #[arg(long, default_value_t = 1)]
261
    pub min_nightly_obs_in_min_nights: usize,
262

263
    // ---- Tracklet-specific ----
264
    /// Minimum observations per intra-night tracklet.
265
    #[arg(long, default_value_t = 2)]
266
    pub tracklet_min_obs: usize,
267

268
    /// Maximum intra-tracklet time separation, in hours.
269
    #[arg(long, default_value_t = 1.5)]
270
    pub max_obs_separation_hours: f64,
271

272
    /// Minimum distinct nights with valid tracklets.
273
    #[arg(long, default_value_t = 3)]
274
    pub min_linkage_nights: usize,
275

276
    /// Minimum angular separation between tracklet observations, in arcseconds.
277
    #[arg(long, default_value_t = 1.0)]
278
    pub min_obs_angular_separation_arcsec: f64,
279
}
280

281
impl MetricArgs {
282
    pub fn build(&self) -> Box<dyn FindabilityMetric> {
11✔
283
        match self.metric {
11✔
284
            MetricKind::Singletons => Box::new(SingletonMetric {
9✔
285
                min_obs: self.min_obs,
9✔
286
                min_nights: self.min_nights,
9✔
287
                min_nightly_obs_in_min_nights: self.min_nightly_obs_in_min_nights,
9✔
288
            }),
9✔
289
            MetricKind::Tracklets => Box::new(TrackletMetric {
2✔
290
                tracklet_min_obs: self.tracklet_min_obs,
2✔
291
                max_obs_separation: self.max_obs_separation_hours / 24.0,
2✔
292
                min_linkage_nights: self.min_linkage_nights,
2✔
293
                min_obs_angular_separation: self.min_obs_angular_separation_arcsec,
2✔
294
            }),
2✔
295
        }
296
    }
11✔
297

298
    pub fn to_manifest(&self) -> serde_json::Value {
10✔
299
        match self.metric {
10✔
300
            MetricKind::Singletons => serde_json::json!({
8✔
301
                "kind": "singletons",
8✔
302
                "min_obs": self.min_obs,
8✔
303
                "min_nights": self.min_nights,
8✔
304
                "min_nightly_obs_in_min_nights": self.min_nightly_obs_in_min_nights,
8✔
305
            }),
306
            MetricKind::Tracklets => serde_json::json!({
2✔
307
                "kind": "tracklets",
2✔
308
                "tracklet_min_obs": self.tracklet_min_obs,
2✔
309
                "max_obs_separation_hours": self.max_obs_separation_hours,
2✔
310
                "min_linkage_nights": self.min_linkage_nights,
2✔
311
                "min_obs_angular_separation_arcsec": self.min_obs_angular_separation_arcsec,
2✔
312
            }),
313
        }
314
    }
10✔
315
}
316

317
// ---------------------------------------------------------------------------
318
// Partition arguments
319
// ---------------------------------------------------------------------------
320

321
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
322
#[serde(rename_all = "lowercase")]
323
#[value(rename_all = "lowercase")]
324
pub enum PartitionMode {
325
    Single,
326
    Sliding,
327
    Blocks,
328
}
329

330
#[derive(Args, Clone, Debug)]
331
pub struct PartitionArgs {
332
    /// Partition mode.
333
    #[arg(long = "partition-mode", value_enum, default_value_t = PartitionMode::Single)]
334
    pub partition_mode: PartitionMode,
335

336
    /// Window size in nights. Required for sliding/blocks.
337
    #[arg(long = "partition-window")]
338
    pub partition_window: Option<i64>,
339

340
    /// Ramp-up cap for sliding windows. Defaults to `--partition-window`.
341
    #[arg(long = "partition-min-nights")]
342
    pub partition_min_nights: Option<i64>,
343
}
344

345
impl PartitionArgs {
346
    pub fn build(&self, nights: &[i64]) -> Result<Vec<Partition>> {
9✔
347
        match self.partition_mode {
9✔
348
            PartitionMode::Single => Ok(vec![partitions::create_single(nights)?]),
7✔
349
            PartitionMode::Sliding => {
350
                let window = self.partition_window.ok_or_else(|| {
2✔
351
                    anyhow::anyhow!("--partition-window is required for --partition-mode sliding")
1✔
352
                })?;
1✔
353
                Ok(partitions::create_linking_windows(
1✔
354
                    nights,
1✔
355
                    Some(window),
1✔
356
                    Some(self.partition_min_nights.unwrap_or(window)),
1✔
357
                    true,
NEW
358
                )?)
×
359
            }
360
            PartitionMode::Blocks => {
NEW
361
                let window = self.partition_window.ok_or_else(|| {
×
NEW
362
                    anyhow::anyhow!("--partition-window is required for --partition-mode blocks")
×
NEW
363
                })?;
×
NEW
364
                Ok(partitions::create_linking_windows(
×
NEW
365
                    nights,
×
NEW
366
                    Some(window),
×
NEW
367
                    None,
×
368
                    false,
NEW
369
                )?)
×
370
            }
371
        }
372
    }
9✔
373

374
    pub fn to_manifest(&self) -> serde_json::Value {
8✔
375
        match self.partition_mode {
8✔
376
            PartitionMode::Single => serde_json::json!({ "mode": "single" }),
7✔
377
            PartitionMode::Sliding => serde_json::json!({
1✔
378
                "mode": "sliding",
1✔
379
                "window": self.partition_window,
1✔
380
                "min_nights": self.partition_min_nights.or(self.partition_window),
1✔
381
            }),
NEW
382
            PartitionMode::Blocks => serde_json::json!({
×
NEW
383
                "mode": "blocks",
×
NEW
384
                "window": self.partition_window,
×
385
            }),
386
        }
387
    }
8✔
388
}
389

390
// ---------------------------------------------------------------------------
391
// Input fingerprinting
392
// ---------------------------------------------------------------------------
393

394
#[derive(Debug, Clone, Serialize)]
395
pub struct InputFingerprint {
396
    pub path: String,
397
    pub size_bytes: u64,
398
    /// SHA-256 of the first 1 MiB of the file. Cheap integrity check — not a
399
    /// cryptographic guarantee.
400
    pub sha256_prefix: String,
401
}
402

403
pub fn fingerprint_input(path: &Path) -> Result<InputFingerprint> {
14✔
404
    let meta = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
14✔
405
    let size_bytes = meta.len();
12✔
406
    let sha256_prefix = sha256_first_mib(path)?;
12✔
407
    Ok(InputFingerprint {
12✔
408
        path: path.display().to_string(),
12✔
409
        size_bytes,
12✔
410
        sha256_prefix,
12✔
411
    })
12✔
412
}
14✔
413

414
fn sha256_first_mib(path: &Path) -> Result<String> {
12✔
415
    const PREFIX_BYTES: u64 = 1 << 20;
416
    let f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
12✔
417
    let mut reader = std::io::BufReader::new(f).take(PREFIX_BYTES);
12✔
418
    let mut buf = Vec::new();
12✔
419
    reader
12✔
420
        .read_to_end(&mut buf)
12✔
421
        .with_context(|| format!("read {}", path.display()))?;
12✔
422
    let mut hasher = Sha256::new();
12✔
423
    hasher.update(&buf);
12✔
424
    Ok(format!("{:x}", hasher.finalize()))
12✔
425
}
12✔
426

427
// ---------------------------------------------------------------------------
428
// Manifest
429
// ---------------------------------------------------------------------------
430

431
#[derive(Debug, Clone, Serialize)]
432
pub struct Manifest {
433
    pub difi_version: String,
434
    pub command: Vec<String>,
435
    pub observations_input: InputFingerprint,
436
    #[serde(skip_serializing_if = "Option::is_none")]
437
    pub linkages_input: Option<InputFingerprint>,
438
    pub scenarios: Vec<ScenarioManifest>,
439
    pub host: HostInfo,
440
    pub started_at_unix_s: f64,
441
    pub finished_at_unix_s: f64,
442
}
443

444
#[derive(Debug, Clone, Serialize)]
445
pub struct ScenarioManifest {
446
    pub name: String,
447
    pub metric: serde_json::Value,
448
    pub partitions: serde_json::Value,
449
    pub cifi_elapsed_s: f64,
450
    #[serde(skip_serializing_if = "Option::is_none")]
451
    pub difi_elapsed_s: Option<f64>,
452
    pub findable_count: i64,
453
    #[serde(skip_serializing_if = "Option::is_none")]
454
    pub found_count: Option<i64>,
455
    pub outputs: std::collections::BTreeMap<String, String>,
456
}
457

458
#[derive(Debug, Clone, Serialize)]
459
pub struct HostInfo {
460
    pub hostname: Option<String>,
461
    pub threads: usize,
462
}
463

464
impl HostInfo {
465
    pub fn capture() -> Self {
9✔
466
        let hostname = std::env::var("HOSTNAME")
9✔
467
            .ok()
9✔
468
            .filter(|s| !s.is_empty())
9✔
469
            .or_else(|| {
9✔
470
                std::fs::read_to_string("/etc/hostname")
9✔
471
                    .ok()
9✔
472
                    .map(|s| s.trim().to_string())
9✔
473
                    .filter(|s| !s.is_empty())
9✔
474
            });
9✔
475
        Self {
9✔
476
            hostname,
9✔
477
            threads: rayon::current_num_threads(),
9✔
478
        }
9✔
479
    }
9✔
480
}
481

482
pub fn write_manifest(path: &Path, manifest: &Manifest) -> Result<()> {
9✔
483
    let json = serde_json::to_string_pretty(manifest)?;
9✔
484
    std::fs::write(path, json + "\n")
9✔
485
        .with_context(|| format!("write manifest to {}", path.display()))?;
9✔
486
    Ok(())
9✔
487
}
9✔
488

489
// ---------------------------------------------------------------------------
490
// Scenarios TOML
491
// ---------------------------------------------------------------------------
492

493
#[derive(Debug, Clone, Deserialize)]
494
pub struct ScenariosFile {
495
    #[serde(default)]
496
    pub defaults: ScenarioDefaults,
497
    #[serde(rename = "scenario", default)]
498
    pub scenarios: Vec<ScenarioEntry>,
499
}
500

501
#[derive(Debug, Clone, Default, Deserialize)]
502
pub struct ScenarioDefaults {
503
    pub observations: Option<PathBuf>,
504
}
505

506
#[derive(Debug, Clone, Deserialize)]
507
pub struct ScenarioEntry {
508
    pub name: String,
509
    pub observations: Option<PathBuf>,
510

511
    // Metric (default: singletons)
512
    #[serde(default = "default_metric_kind")]
513
    pub metric: MetricKind,
514

515
    // Singleton
516
    #[serde(default = "default_min_obs")]
517
    pub min_obs: usize,
518
    #[serde(default = "default_min_nights")]
519
    pub min_nights: usize,
520
    #[serde(default = "default_one")]
521
    pub min_nightly_obs_in_min_nights: usize,
522

523
    // Tracklet
524
    #[serde(default = "default_tracklet_min_obs")]
525
    pub tracklet_min_obs: usize,
526
    #[serde(default = "default_max_obs_sep_hours")]
527
    pub max_obs_separation_hours: f64,
528
    #[serde(default = "default_min_linkage_nights")]
529
    pub min_linkage_nights: usize,
530
    #[serde(default = "default_min_ang_sep")]
531
    pub min_obs_angular_separation_arcsec: f64,
532

533
    // Partition
534
    #[serde(default = "default_partition_mode")]
535
    pub partition_mode: PartitionMode,
536
    pub partition_window: Option<i64>,
537
    pub partition_min_nights: Option<i64>,
538
}
539

NEW
540
fn default_metric_kind() -> MetricKind {
×
NEW
541
    MetricKind::Singletons
×
NEW
542
}
×
543
fn default_min_obs() -> usize {
2✔
544
    6
2✔
545
}
2✔
546
fn default_min_nights() -> usize {
2✔
547
    3
2✔
548
}
2✔
549
fn default_one() -> usize {
2✔
550
    1
2✔
551
}
2✔
552
fn default_tracklet_min_obs() -> usize {
2✔
553
    2
2✔
554
}
2✔
555
fn default_max_obs_sep_hours() -> f64 {
2✔
556
    1.5
2✔
557
}
2✔
558
fn default_min_linkage_nights() -> usize {
2✔
559
    3
2✔
560
}
2✔
561
fn default_min_ang_sep() -> f64 {
2✔
562
    1.0
2✔
563
}
2✔
564
fn default_partition_mode() -> PartitionMode {
2✔
565
    PartitionMode::Single
2✔
566
}
2✔
567

568
impl ScenarioEntry {
569
    pub fn to_metric_args(&self) -> MetricArgs {
2✔
570
        MetricArgs {
2✔
571
            metric: self.metric,
2✔
572
            min_obs: self.min_obs,
2✔
573
            min_nights: self.min_nights,
2✔
574
            min_nightly_obs_in_min_nights: self.min_nightly_obs_in_min_nights,
2✔
575
            tracklet_min_obs: self.tracklet_min_obs,
2✔
576
            max_obs_separation_hours: self.max_obs_separation_hours,
2✔
577
            min_linkage_nights: self.min_linkage_nights,
2✔
578
            min_obs_angular_separation_arcsec: self.min_obs_angular_separation_arcsec,
2✔
579
        }
2✔
580
    }
2✔
581

582
    pub fn to_partition_args(&self) -> PartitionArgs {
2✔
583
        PartitionArgs {
2✔
584
            partition_mode: self.partition_mode,
2✔
585
            partition_window: self.partition_window,
2✔
586
            partition_min_nights: self.partition_min_nights,
2✔
587
        }
2✔
588
    }
2✔
589
}
590

591
pub fn read_scenarios(path: &Path) -> Result<ScenariosFile> {
1✔
592
    let text = std::fs::read_to_string(path)
1✔
593
        .with_context(|| format!("read scenarios file {}", path.display()))?;
1✔
594
    let file: ScenariosFile = toml::from_str(&text)
1✔
595
        .with_context(|| format!("parse scenarios file {}", path.display()))?;
1✔
596
    if file.scenarios.is_empty() {
1✔
NEW
597
        bail!(
×
598
            "scenarios file {} contains no [[scenario]] entries",
NEW
599
            path.display()
×
600
        );
601
    }
1✔
602
    Ok(file)
1✔
603
}
1✔
604

605
// ---------------------------------------------------------------------------
606
// Misc helpers
607
// ---------------------------------------------------------------------------
608

609
pub fn version_string() -> String {
9✔
610
    env!("CARGO_PKG_VERSION").to_string()
9✔
611
}
9✔
612

613
pub fn now_unix_s() -> f64 {
9✔
614
    unix_seconds_now()
9✔
615
}
9✔
616

617
pub fn argv() -> Vec<String> {
12✔
618
    std::env::args().collect()
12✔
619
}
12✔
620

621
/// Ensure an output directory exists, creating it if needed.
622
pub fn ensure_dir(path: &Path) -> Result<()> {
14✔
623
    std::fs::create_dir_all(path)
14✔
624
        .with_context(|| format!("create output directory {}", path.display()))?;
14✔
625
    Ok(())
14✔
626
}
14✔
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