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

0xmichalis / nftbk / 18381785397

09 Oct 2025 03:39PM UTC coverage: 34.64% (-0.01%) from 34.653%
18381785397

push

github

0xmichalis
feat: update CLI to recreate backups on --force

0 of 59 new or added lines in 1 file covered. (0.0%)

180 existing lines in 2 files now uncovered.

928 of 2679 relevant lines covered (34.64%)

6.07 hits per line

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

0.0
/src/bin/cli.rs
1
use anyhow::{Context, Result};
2
use clap::Parser;
3
use dotenv::dotenv;
4
use flate2::read::GzDecoder;
5
use prettytable::{row, Table};
6
use reqwest::Client;
7
use std::env;
8
use std::fs::File;
9
use std::io::Cursor;
10
use std::path::PathBuf;
11
use tar::Archive;
12
use tokio::fs;
13
use tracing::{debug, error, warn};
14
use zip::ZipArchive;
15

16
use nftbk::backup::{backup_from_config, BackupConfig, ChainConfig, TokenConfig};
17
use nftbk::envvar::is_defined;
18
use nftbk::ipfs::IpfsProviderConfig;
19
use nftbk::logging;
20
use nftbk::logging::LogLevel;
21
use nftbk::server::api::{BackupRequest, BackupResponse, StatusResponse, Tokens};
22
use nftbk::server::archive::archive_format_from_user_agent;
23
use nftbk::{ProcessManagementConfig, StorageConfig};
24

25
#[derive(serde::Deserialize)]
26
struct IpfsConfigFile {
27
    ipfs_provider: Vec<IpfsProviderConfig>,
28
}
29

30
#[derive(Parser, Debug)]
31
#[command(author, version, about, long_about = None)]
32
struct Args {
33
    /// The path to the chains configuration file
34
    #[arg(short = 'c', long, default_value = "config_chains.toml")]
35
    chains_config_path: PathBuf,
36

37
    /// The path to the tokens configuration file
38
    #[arg(short = 't', long, default_value = "config_tokens.toml")]
39
    tokens_config_path: PathBuf,
40

41
    /// The directory to save the backup to
42
    #[arg(short, long, default_value = "nft_backup")]
43
    output_path: Option<PathBuf>,
44

45
    /// Set the log level
46
    #[arg(short, long, value_enum, default_value = "info")]
47
    log_level: LogLevel,
48

49
    /// Delete redundant files in the backup folder
50
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
51
    prune_redundant: bool,
52

53
    /// Request a backup from the server instead of running locally
54
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
55
    server_mode: bool,
56

57
    /// The server address to request backups from
58
    #[arg(long, default_value = "http://127.0.0.1:8080")]
59
    server_address: String,
60

61
    /// Exit on the first error encountered
62
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
63
    exit_on_error: bool,
64

65
    /// Force rerunning a completed backup task
66
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
67
    force: bool,
68

69
    /// List existing backups in the server
70
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
71
    list: bool,
72

73
    /// User-Agent to send to the server (affects archive format)
74
    #[arg(long, default_value = "Linux")]
75
    user_agent: String,
76

77
    /// Disable colored log output
78
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
79
    no_color: bool,
80

81
    /// Path to a TOML file with IPFS provider configuration
82
    #[arg(long)]
83
    ipfs_config: Option<String>,
84

85
    /// Request server to pin downloaded assets on IPFS
86
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
87
    pin_on_ipfs: bool,
88
}
89

90
enum BackupStart {
91
    Created(BackupResponse),
92
    Exists(BackupResponse),
93
    Conflict {
94
        task_id: String,
95
        retry_url: String,
96
        message: String,
97
    },
98
}
99

UNCOV
100
async fn backup_from_server(
×
101
    token_config: TokenConfig,
102
    server_address: String,
103
    output_path: Option<PathBuf>,
104
    force: bool,
105
    user_agent: String,
106
    pin_on_ipfs: bool,
107
) -> Result<()> {
UNCOV
108
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
109
    let client = Client::new();
×
110

111
    // First, try to create the backup
112
    let start = request_backup(
UNCOV
113
        &token_config,
×
114
        &server_address,
×
115
        &client,
×
116
        auth_token.as_deref(),
×
117
        &user_agent,
×
118
        pin_on_ipfs,
×
119
    )
UNCOV
120
    .await?;
×
121

UNCOV
122
    let task_id = match start {
×
123
        BackupStart::Created(resp) => {
×
124
            let task_id = resp.task_id.clone();
×
125
            println!("Task ID: {task_id}");
×
126
            task_id
×
127
        }
NEW
128
        BackupStart::Exists(resp) => {
×
NEW
129
            let task_id = resp.task_id.clone();
×
NEW
130
            println!("Task ID (exists): {task_id}");
×
NEW
131
            if force {
×
132
                // Delete and re-request
133
                let _ =
NEW
134
                    delete_backup(&client, &server_address, &task_id, auth_token.as_deref()).await;
×
135
                let second_try = request_backup(
NEW
136
                    &token_config,
×
NEW
137
                    &server_address,
×
NEW
138
                    &client,
×
NEW
139
                    auth_token.as_deref(),
×
NEW
140
                    &user_agent,
×
NEW
141
                    pin_on_ipfs,
×
142
                )
NEW
143
                .await?;
×
NEW
144
                match second_try {
×
NEW
145
                    BackupStart::Created(resp2) | BackupStart::Exists(resp2) => {
×
NEW
146
                        let task_id2 = resp2.task_id.clone();
×
NEW
147
                        println!("Task ID (after delete): {task_id2}");
×
NEW
148
                        task_id2
×
149
                    }
150
                    BackupStart::Conflict {
NEW
151
                        message,
×
NEW
152
                        retry_url,
×
153
                        task_id: _,
154
                    } => {
NEW
155
                        anyhow::bail!(
×
NEW
156
                            "{}\nCould not create new task after delete. Try POST to {}{}",
×
157
                            message,
NEW
158
                            server_address.trim_end_matches('/'),
×
159
                            retry_url
160
                        );
161
                    }
162
                }
163
            } else {
NEW
164
                task_id
×
165
            }
166
        }
167
        BackupStart::Conflict {
168
            task_id,
×
169
            retry_url,
×
170
            message,
×
171
        } => {
UNCOV
172
            println!("Task ID: {task_id}");
×
UNCOV
173
            if force {
×
174
                println!("Server response: {message}");
×
175
                // Try to delete the existing task by task_id, then request again
176
                let _ =
NEW
177
                    delete_backup(&client, &server_address, &task_id, auth_token.as_deref()).await;
×
178
                let second_try = request_backup(
NEW
179
                    &token_config,
×
180
                    &server_address,
×
NEW
181
                    &client,
×
182
                    auth_token.as_deref(),
×
NEW
183
                    &user_agent,
×
NEW
184
                    pin_on_ipfs,
×
185
                )
186
                .await?;
×
NEW
187
                match second_try {
×
NEW
188
                    BackupStart::Created(resp2) | BackupStart::Exists(resp2) => {
×
NEW
189
                        let task_id2 = resp2.task_id.clone();
×
NEW
190
                        println!("Task ID (after delete): {task_id2}");
×
NEW
191
                        task_id2
×
192
                    }
193
                    BackupStart::Conflict {
NEW
194
                        message,
×
NEW
195
                        retry_url,
×
196
                        task_id: _,
197
                    } => {
NEW
198
                        anyhow::bail!(
×
NEW
199
                            "{}\nCould not create new task after delete. Try POST to {}{}",
×
200
                            message,
NEW
201
                            server_address.trim_end_matches('/'),
×
202
                            retry_url
203
                        );
204
                    }
205
                }
206
            } else {
207
                anyhow::bail!(
×
208
                    "{}\nRun the CLI with --force true, or POST to {}{}",
×
209
                    message,
UNCOV
210
                    server_address.trim_end_matches('/'),
×
211
                    retry_url
212
                );
213
            }
214
        }
215
    };
216

UNCOV
217
    wait_for_done_backup(&client, &server_address, &task_id, auth_token.as_deref()).await?;
×
218

UNCOV
219
    return download_backup(
×
UNCOV
220
        &client,
×
UNCOV
221
        &server_address,
×
UNCOV
222
        &task_id,
×
UNCOV
223
        output_path.as_ref(),
×
224
        auth_token.as_deref(),
×
UNCOV
225
        &archive_format_from_user_agent(&user_agent),
×
226
    )
UNCOV
227
    .await;
×
228
}
NEW
229
async fn delete_backup(
×
230
    client: &Client,
231
    server_address: &str,
232
    task_id: &str,
233
    auth_token: Option<&str>,
234
) -> Result<()> {
NEW
235
    let server = server_address.trim_end_matches('/');
×
NEW
236
    let url = format!("{server}/backup/{task_id}");
×
NEW
237
    let mut req = client.delete(url);
×
NEW
238
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
NEW
239
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
240
    }
NEW
241
    let resp = req
×
242
        .send()
NEW
243
        .await
×
244
        .context("Failed to send DELETE to server")?;
NEW
245
    match resp.status().as_u16() {
×
246
        204 => {
NEW
247
            println!("Deleted existing backup {task_id}");
×
NEW
248
            Ok(())
×
249
        }
NEW
250
        404 => Ok(()), // nothing to delete is fine
×
251
        409 => {
252
            // In progress; proceed with new request anyway
NEW
253
            println!("Existing backup {task_id} is in progress; proceeding to create new request");
×
NEW
254
            Ok(())
×
255
        }
NEW
256
        code => {
×
NEW
257
            let text = resp.text().await.unwrap_or_default();
×
NEW
258
            println!("Warning: failed to delete existing backup ({code}): {text}");
×
NEW
259
            Ok(())
×
260
        }
261
    }
262
}
263

264
async fn request_backup(
×
265
    token_config: &TokenConfig,
266
    server_address: &str,
267
    client: &Client,
268
    auth_token: Option<&str>,
269
    user_agent: &str,
270
    pin_on_ipfs: bool,
271
) -> Result<BackupStart> {
272
    let mut backup_req = BackupRequest {
273
        tokens: Vec::new(),
×
274
        pin_on_ipfs,
275
    };
276
    for (chain, tokens) in &token_config.chains {
×
277
        backup_req.tokens.push(Tokens {
×
278
            chain: chain.clone(),
×
UNCOV
279
            tokens: tokens.clone(),
×
280
        });
281
    }
282

283
    let server = server_address.trim_end_matches('/');
×
284
    println!("Submitting backup request to server at {server}/backup ...",);
×
285
    let mut req = client.post(format!("{server}/backup")).json(&backup_req);
×
UNCOV
286
    req = req.header("User-Agent", user_agent);
×
UNCOV
287
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
288
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
289
    }
UNCOV
290
    let resp = req
×
291
        .send()
292
        .await
×
293
        .context("Failed to send backup request to server")?;
UNCOV
294
    let status = resp.status();
×
295
    if status.is_success() {
×
UNCOV
296
        let backup_resp: BackupResponse = resp.json().await.context("Invalid server response")?;
×
NEW
297
        if status.as_u16() == 201 {
×
NEW
298
            return Ok(BackupStart::Created(backup_resp));
×
299
        } else {
NEW
300
            return Ok(BackupStart::Exists(backup_resp));
×
301
        }
302
    }
UNCOV
303
    if status.as_u16() == 409 {
×
UNCOV
304
        let body: serde_json::Value = resp
×
305
            .json()
306
            .await
×
307
            .context("Invalid conflict response from server")?;
308
        let task_id = body
×
309
            .get("task_id")
UNCOV
310
            .and_then(|v| v.as_str())
×
311
            .unwrap_or_default()
312
            .to_string();
UNCOV
313
        let retry_url = body
×
314
            .get("retry_url")
315
            .and_then(|v| v.as_str())
×
316
            .unwrap_or("")
317
            .to_string();
318
        let message = body
×
319
            .get("error")
320
            .and_then(|v| v.as_str())
×
321
            .unwrap_or("Server returned conflict for /backup")
322
            .to_string();
323
        return Ok(BackupStart::Conflict {
×
UNCOV
324
            task_id,
×
325
            retry_url,
×
UNCOV
326
            message,
×
327
        });
328
    }
UNCOV
329
    let text = resp.text().await.unwrap_or_default();
×
UNCOV
330
    anyhow::bail!("Server error: {}", text);
×
331
}
332

333
async fn wait_for_done_backup(
×
334
    client: &reqwest::Client,
335
    server_address: &str,
336
    task_id: &str,
337
    auth_token: Option<&str>,
338
) -> Result<()> {
UNCOV
339
    let status_url = format!(
×
340
        "{}/backup/{}/status",
UNCOV
341
        server_address.trim_end_matches('/'),
×
342
        task_id
343
    );
UNCOV
344
    let mut in_progress_logged = false;
×
345
    loop {
346
        let mut req = client.get(&status_url);
×
347
        if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
348
            req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
349
        }
UNCOV
350
        let resp = req.send().await;
×
UNCOV
351
        match resp {
×
352
            Ok(r) => {
×
353
                if r.status().is_success() {
×
354
                    let status: StatusResponse = r.json().await.unwrap_or(StatusResponse {
×
355
                        status: "error".to_string(),
×
356
                        error: Some("Invalid status response".to_string()),
×
357
                        error_log: None,
×
358
                    });
359
                    match status.status.as_str() {
×
UNCOV
360
                        "in_progress" => {
×
361
                            if !in_progress_logged {
×
UNCOV
362
                                println!("Waiting for backup to complete...");
×
363
                                in_progress_logged = true;
×
364
                            }
365
                        }
366
                        "done" => {
×
367
                            println!("Backup complete.");
×
UNCOV
368
                            if let Some(error_log) = &status.error_log {
×
369
                                if !error_log.is_empty() {
×
UNCOV
370
                                    warn!("{}", error_log);
×
371
                                }
372
                            }
373
                            break;
×
374
                        }
375
                        "error" => {
×
UNCOV
376
                            anyhow::bail!("Server error: {}", status.error.unwrap_or_default());
×
377
                        }
UNCOV
378
                        _ => {
×
379
                            println!("Unknown status: {}", status.status);
×
380
                        }
381
                    }
382
                } else {
UNCOV
383
                    println!("Failed to get status: {}", r.status());
×
384
                }
385
            }
UNCOV
386
            Err(e) => {
×
387
                println!("Error polling status: {e}");
×
388
            }
389
        }
UNCOV
390
        tokio::time::sleep(std::time::Duration::from_secs(10)).await;
×
391
    }
392
    Ok(())
×
393
}
394

395
async fn download_backup(
×
396
    client: &Client,
397
    server_address: &str,
398
    task_id: &str,
399
    output_path: Option<&PathBuf>,
400
    _auth_token: Option<&str>,
401
    archive_format: &str,
402
) -> Result<()> {
403
    // Step 1: Get download token
UNCOV
404
    let token_url = format!(
×
405
        "{}/backup/{}/download_token",
UNCOV
406
        server_address.trim_end_matches('/'),
×
407
        task_id
408
    );
UNCOV
409
    let mut token_req = client.get(&token_url);
×
410
    if is_defined(&_auth_token.as_ref().map(|s| s.to_string())) {
×
UNCOV
411
        token_req = token_req.header("Authorization", format!("Bearer {}", _auth_token.unwrap()));
×
412
    }
413
    let token_resp = token_req
×
414
        .send()
415
        .await
×
416
        .context("Failed to get download token")?;
417
    if !token_resp.status().is_success() {
×
UNCOV
418
        anyhow::bail!("Failed to get download token: {}", token_resp.status());
×
419
    }
420
    let token_json: serde_json::Value =
×
421
        token_resp.json().await.context("Invalid token response")?;
×
422
    let download_token = token_json
×
423
        .get("token")
424
        .and_then(|v| v.as_str())
×
425
        .ok_or_else(|| anyhow::anyhow!("No token in response"))?;
×
426

427
    // Step 2: Download using token
428
    let download_url = format!(
×
429
        "{}/backup/{}/download?token={}",
430
        server_address.trim_end_matches('/'),
×
431
        task_id,
432
        urlencoding::encode(download_token)
×
433
    );
UNCOV
434
    println!("Downloading archive ...");
×
435
    let resp = client
×
436
        .get(&download_url)
×
437
        .send()
438
        .await
×
439
        .context("Failed to download archive")?;
UNCOV
440
    if !resp.status().is_success() {
×
UNCOV
441
        anyhow::bail!("Failed to download archive: {}", resp.status());
×
442
    }
UNCOV
443
    let bytes = resp.bytes().await.context("Failed to read archive bytes")?;
×
444
    let output_path = output_path.cloned().unwrap_or_else(|| PathBuf::from("."));
×
445

UNCOV
446
    println!("Extracting backup to {}...", output_path.display());
×
447
    match archive_format {
×
448
        "tar.gz" => {
×
UNCOV
449
            let gz = GzDecoder::new(Cursor::new(bytes));
×
UNCOV
450
            let mut archive = Archive::new(gz);
×
UNCOV
451
            archive
×
452
                .unpack(&output_path)
×
453
                .context("Failed to extract backup archive (tar.gz)")?;
454
        }
455
        "zip" => {
×
456
            let mut zip =
×
UNCOV
457
                ZipArchive::new(Cursor::new(bytes)).context("Failed to read zip archive")?;
×
UNCOV
458
            for i in 0..zip.len() {
×
459
                let mut file = zip.by_index(i).context("Failed to access file in zip")?;
×
UNCOV
460
                let outpath = match file.enclosed_name() {
×
461
                    Some(path) => output_path.join(path),
×
UNCOV
462
                    None => continue,
×
463
                };
464
                if file.name().ends_with('/') {
×
UNCOV
465
                    std::fs::create_dir_all(&outpath)
×
466
                        .context("Failed to create directory from zip")?;
467
                } else {
UNCOV
468
                    if let Some(p) = outpath.parent() {
×
UNCOV
469
                        std::fs::create_dir_all(p)
×
470
                            .context("Failed to create parent directory for zip file")?;
471
                    }
UNCOV
472
                    let mut outfile =
×
473
                        File::create(&outpath).context("Failed to create file from zip")?;
×
UNCOV
474
                    std::io::copy(&mut file, &mut outfile)
×
475
                        .context("Failed to extract file from zip")?;
476
                }
477
            }
478
        }
479
        _ => anyhow::bail!("Unknown archive format: {archive_format}"),
×
480
    }
UNCOV
481
    println!("Backup extracted to {}", output_path.display());
×
482
    Ok(())
×
483
}
484

UNCOV
485
async fn list_server_backups(server_address: &str) -> Result<()> {
×
486
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
487
    let client = Client::new();
×
UNCOV
488
    let url = format!("{}/backups", server_address.trim_end_matches('/'));
×
489
    let mut req = client.get(&url);
×
490
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
491
        req = req.header(
×
UNCOV
492
            "Authorization",
×
493
            format!("Bearer {}", auth_token.as_deref().unwrap()),
×
494
        );
495
    }
UNCOV
496
    let resp = req
×
497
        .send()
UNCOV
498
        .await
×
499
        .context("Failed to fetch backups from server")?;
UNCOV
500
    if !resp.status().is_success() {
×
501
        let text = resp.text().await.unwrap_or_default();
×
UNCOV
502
        anyhow::bail!("Server error: {}", text);
×
503
    }
504
    let backups: serde_json::Value = resp.json().await.context("Invalid server response")?;
×
505
    let arr = backups
×
506
        .as_array()
507
        .ok_or_else(|| anyhow::anyhow!("Expected array response"))?;
×
UNCOV
508
    let mut table = Table::new();
×
509
    table.add_row(row!["Task ID", "Status", "Error", "Error Log", "NFT Count"]);
×
510
    for entry in arr {
×
UNCOV
511
        let task_id = entry.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
×
512
        let status = entry.get("status").and_then(|v| v.as_str()).unwrap_or("");
×
513
        let error = entry.get("error").and_then(|v| v.as_str()).unwrap_or("");
×
UNCOV
514
        let error_log = entry
×
515
            .get("error_log")
516
            .and_then(|v| v.as_str())
×
517
            .unwrap_or("");
518
        let nft_count = entry.get("nft_count").and_then(|v| v.as_u64()).unwrap_or(0);
×
519
        table.add_row(row![task_id, status, error, error_log, nft_count]);
×
520
    }
521
    table.printstd();
×
UNCOV
522
    Ok(())
×
523
}
524

525
#[tokio::main]
526
async fn main() -> Result<()> {
×
527
    dotenv().ok();
×
528
    let args = Args::parse();
×
529
    logging::init(args.log_level, !args.no_color);
×
530
    debug!(
×
531
        "Starting {} {} (commit {})",
×
532
        env!("CARGO_BIN_NAME"),
533
        env!("CARGO_PKG_VERSION"),
534
        env!("GIT_COMMIT")
535
    );
536

537
    if args.server_mode && args.list {
×
538
        return list_server_backups(&args.server_address).await;
×
539
    }
540

541
    let tokens_content = fs::read_to_string(&args.tokens_config_path)
×
542
        .await
×
543
        .context("Failed to read tokens config file")?;
UNCOV
544
    let token_config: TokenConfig =
×
UNCOV
545
        toml::from_str(&tokens_content).context("Failed to parse tokens config file")?;
×
546

UNCOV
547
    if args.server_mode {
×
548
        return backup_from_server(
×
UNCOV
549
            token_config,
×
550
            args.server_address,
×
551
            args.output_path,
×
UNCOV
552
            args.force,
×
UNCOV
553
            args.user_agent,
×
554
            args.pin_on_ipfs,
×
555
        )
556
        .await;
×
557
    }
558

559
    let chains_content = fs::read_to_string(&args.chains_config_path)
×
560
        .await
×
561
        .context("Failed to read chains config file")?;
562
    let mut chain_config: ChainConfig =
×
UNCOV
563
        toml::from_str(&chains_content).context("Failed to parse chains config file")?;
×
UNCOV
564
    chain_config.resolve_env_vars()?;
×
565

566
    // Load IPFS provider configuration from file if provided
567
    let ipfs_providers = if let Some(path) = &args.ipfs_config {
×
UNCOV
568
        let contents = std::fs::read_to_string(path)
×
569
            .with_context(|| format!("Failed to read IPFS config file '{path}'"))?;
×
570
        let config: IpfsConfigFile = toml::from_str(&contents)
×
571
            .with_context(|| format!("Failed to parse IPFS config file '{path}'"))?;
×
UNCOV
572
        config.ipfs_provider
×
573
    } else {
574
        Vec::new()
×
575
    };
576

577
    let output_path = args.output_path.clone();
×
578
    let backup_config = BackupConfig {
579
        chain_config,
580
        token_config,
581
        storage_config: StorageConfig {
×
582
            output_path: output_path.clone(),
583
            prune_redundant: args.prune_redundant,
584
            ipfs_providers,
585
        },
UNCOV
586
        process_config: ProcessManagementConfig {
×
587
            exit_on_error: args.exit_on_error,
588
            shutdown_flag: None,
589
        },
590
    };
591
    let (_files, _pin_requests, error_log) = backup_from_config(backup_config, None).await?;
×
592
    // Write error log to file if present
UNCOV
593
    if !error_log.is_empty() {
×
UNCOV
594
        if let Some(ref out_path) = output_path {
×
595
            let mut log_path = out_path.clone();
×
596
            log_path.set_extension("log");
×
597
            let log_content = error_log.join("\n") + "\n";
×
598
            use tokio::io::AsyncWriteExt;
599
            let mut file = tokio::fs::File::create(&log_path).await?;
×
600
            file.write_all(log_content.as_bytes()).await?;
×
UNCOV
601
            error!("Error log written to {}", log_path.display());
×
602
        }
603
    }
UNCOV
604
    Ok(())
×
605
}
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