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

0xmichalis / nftbk / 18592416813

17 Oct 2025 12:16PM UTC coverage: 36.357% (-0.07%) from 36.423%
18592416813

Pull #74

github

web-flow
Merge 353d050c7 into ee025b510
Pull Request #74: chore: make GET responses consistent across /backups

28 of 93 new or added lines in 6 files covered. (30.11%)

2 existing lines in 1 file now uncovered.

1515 of 4167 relevant lines covered (36.36%)

6.05 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::IpfsPinningConfig;
19
use nftbk::logging;
20
use nftbk::logging::LogLevel;
21
use nftbk::server::api::{BackupCreateResponse, BackupRequest, BackupResponse, Tokens};
22
use nftbk::server::archive::archive_format_from_user_agent;
23
use nftbk::{ProcessManagementConfig, StorageConfig};
24

25
const BACKUPS_API_PATH: &str = "/v1/backups";
26

27
#[derive(serde::Deserialize)]
28
struct IpfsConfigFile {
29
    ipfs_pinning_provider: Vec<IpfsPinningConfig>,
30
}
31

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

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

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

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

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

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

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

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

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

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

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

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

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

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

92
enum BackupStart {
93
    Created(BackupCreateResponse),
94
    Exists(BackupCreateResponse),
95
    Conflict {
96
        task_id: String,
97
        retry_url: String,
98
        message: String,
99
    },
100
}
101

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

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

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

219
    wait_for_done_backup(&client, &server_address, &task_id, auth_token.as_deref()).await?;
×
220

221
    return download_backup(
×
222
        &client,
×
223
        &server_address,
×
224
        &task_id,
×
225
        output_path.as_ref(),
×
226
        auth_token.as_deref(),
×
227
        &archive_format_from_user_agent(&user_agent),
×
228
    )
229
    .await;
×
230
}
231
async fn delete_backup(
×
232
    client: &Client,
233
    server_address: &str,
234
    task_id: &str,
235
    auth_token: Option<&str>,
236
) -> Result<()> {
237
    let server = server_address.trim_end_matches('/');
×
238
    let url = format!("{server}{BACKUPS_API_PATH}/{task_id}",);
×
239
    let mut req = client.delete(url);
×
240
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
241
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
242
    }
243
    let resp = req
×
244
        .send()
245
        .await
×
246
        .context("Failed to send DELETE to server")?;
247

248
    match resp.status().as_u16() {
×
249
        202 => {
250
            println!("Deletion request sent for backup {task_id}, waiting for completion...");
×
251
            // Poll until backup is actually deleted (returns 404)
252
            loop {
253
                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
×
254
                let status_url = format!("{server}{BACKUPS_API_PATH}/{task_id}");
×
255
                let mut status_req = client.get(&status_url);
×
256
                if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
257
                    status_req = status_req
×
258
                        .header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
259
                }
260
                match status_req.send().await {
×
261
                    Ok(status_resp) => {
×
262
                        if status_resp.status().as_u16() == 404 {
×
263
                            println!("Backup {task_id} successfully deleted");
×
264
                            return Ok(());
×
265
                        }
266
                        // Still exists, continue polling
267
                    }
268
                    Err(_) => {
×
269
                        // Network error, continue polling
270
                    }
271
                }
272
            }
273
        }
274
        404 => {
275
            println!("Backup {task_id} already deleted");
×
276
            Ok(())
×
277
        }
278
        409 => {
279
            // In progress; proceed with new request anyway
280
            println!("Existing backup {task_id} is in progress; proceeding to create new request");
×
281
            Ok(())
×
282
        }
283
        code => {
×
284
            let text = resp.text().await.unwrap_or_default();
×
285
            println!("Warning: failed to delete existing backup ({code}): {text}");
×
286
            Ok(())
×
287
        }
288
    }
289
}
290

291
async fn request_backup(
×
292
    token_config: &TokenConfig,
293
    server_address: &str,
294
    client: &Client,
295
    auth_token: Option<&str>,
296
    user_agent: &str,
297
    pin_on_ipfs: bool,
298
) -> Result<BackupStart> {
299
    let mut backup_req = BackupRequest {
300
        tokens: Vec::new(),
×
301
        pin_on_ipfs,
302
        create_archive: true,
303
    };
304
    for (chain, tokens) in &token_config.chains {
×
305
        backup_req.tokens.push(Tokens {
×
306
            chain: chain.clone(),
×
307
            tokens: tokens.clone(),
×
308
        });
309
    }
310

311
    let server = server_address.trim_end_matches('/');
×
312
    println!("Submitting backup request to server at {server}{BACKUPS_API_PATH} ...");
×
313
    let mut req = client
×
314
        .post(format!("{server}{BACKUPS_API_PATH}"))
×
315
        .json(&backup_req);
×
316
    req = req.header("User-Agent", user_agent);
×
317
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
318
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
319
    }
320
    let resp = req
×
321
        .send()
322
        .await
×
323
        .context("Failed to send backup request to server")?;
324
    let status = resp.status();
×
325
    if status.is_success() {
×
NEW
326
        let backup_resp: BackupCreateResponse =
×
NEW
327
            resp.json().await.context("Invalid server response")?;
×
328
        if status.as_u16() == 201 {
×
329
            return Ok(BackupStart::Created(backup_resp));
×
330
        } else {
331
            return Ok(BackupStart::Exists(backup_resp));
×
332
        }
333
    }
334
    if status.as_u16() == 409 {
×
335
        let body: serde_json::Value = resp
×
336
            .json()
337
            .await
×
338
            .context("Invalid conflict response from server")?;
339
        let task_id = body
×
340
            .get("task_id")
341
            .and_then(|v| v.as_str())
×
342
            .unwrap_or_default()
343
            .to_string();
344
        let retry_url = body
×
345
            .get("retry_url")
346
            .and_then(|v| v.as_str())
×
347
            .unwrap_or("")
348
            .to_string();
349
        let message = body
×
350
            .get("error")
351
            .and_then(|v| v.as_str())
×
352
            .unwrap_or(&format!("Server returned conflict for {BACKUPS_API_PATH}"))
×
353
            .to_string();
354
        return Ok(BackupStart::Conflict {
×
355
            task_id,
×
356
            retry_url,
×
357
            message,
×
358
        });
359
    }
360
    let text = resp.text().await.unwrap_or_default();
×
361
    anyhow::bail!("Server error: {}", text);
×
362
}
363

364
async fn wait_for_done_backup(
×
365
    client: &reqwest::Client,
366
    server_address: &str,
367
    task_id: &str,
368
    auth_token: Option<&str>,
369
) -> Result<()> {
370
    let status_url = format!(
×
371
        "{}{}/{}",
372
        server_address.trim_end_matches('/'),
×
373
        BACKUPS_API_PATH,
374
        task_id
375
    );
376
    let mut in_progress_logged = false;
×
377
    loop {
378
        let mut req = client.get(&status_url);
×
379
        if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
380
            req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
381
        }
382
        let resp = req.send().await;
×
383
        match resp {
×
384
            Ok(r) => {
×
385
                if r.status().is_success() {
×
NEW
386
                    let status: BackupResponse = r.json().await.unwrap_or({
×
387
                        // Fallback shape with nulls
NEW
388
                        BackupResponse {
×
NEW
389
                            task_id: task_id.to_string(),
×
NEW
390
                            created_at: String::new(),
×
391
                            tokens: Vec::new(),
×
392
                            total_tokens: 0,
×
393
                            page: 1,
×
394
                            limit: 50,
×
NEW
395
                            archive: nftbk::server::api::Archive {
×
NEW
396
                                status: nftbk::server::api::SubresourceStatus {
×
NEW
397
                                    status: None,
×
NEW
398
                                    fatal_error: None,
×
NEW
399
                                    error_log: None,
×
NEW
400
                                    deleted_at: None,
×
401
                                },
NEW
402
                                format: None,
×
NEW
403
                                expires_at: None,
×
404
                            },
NEW
405
                            pins: nftbk::server::api::Pins {
×
NEW
406
                                status: nftbk::server::api::SubresourceStatus {
×
NEW
407
                                    status: None,
×
NEW
408
                                    fatal_error: None,
×
NEW
409
                                    error_log: None,
×
NEW
410
                                    deleted_at: None,
×
411
                                },
412
                            },
413
                        }
414
                    });
415
                    // Aggregate a coarse "overall" view for UX: in_progress if any subresource is in_progress
NEW
416
                    let archive_status = status.archive.status.status.as_deref();
×
NEW
417
                    let ipfs_status = status.pins.status.status.as_deref();
×
418
                    let any_in_progress = matches!(archive_status, Some("in_progress"))
×
419
                        || matches!(ipfs_status, Some("in_progress"));
×
420
                    let any_error = matches!(archive_status, Some("error"))
×
421
                        || matches!(ipfs_status, Some("error"))
×
NEW
422
                        || status.archive.status.fatal_error.is_some()
×
NEW
423
                        || status.pins.status.fatal_error.is_some();
×
424
                    let all_done = matches!(archive_status, Some("done"))
×
425
                        && (ipfs_status.is_none() || matches!(ipfs_status, Some("done")));
×
426
                    if any_in_progress {
×
427
                        if !in_progress_logged {
×
428
                            println!("Waiting for backup to complete...");
×
429
                            in_progress_logged = true;
×
430
                        }
431
                    } else if all_done {
×
432
                        println!("Backup complete.");
×
NEW
433
                        if let Some(ref a) = status.archive.status.error_log {
×
434
                            if !a.is_empty() {
×
435
                                warn!("{}", a);
×
436
                            }
437
                        }
NEW
438
                        if let Some(ref i) = status.pins.status.error_log {
×
439
                            if !i.is_empty() {
×
440
                                warn!("{}", i);
×
441
                            }
442
                        }
443
                        break;
×
444
                    } else if any_error {
×
445
                        let msg = status
×
446
                            .archive
×
NEW
447
                            .status
×
UNCOV
448
                            .fatal_error
×
449
                            .clone()
NEW
450
                            .or(status.pins.status.fatal_error.clone())
×
451
                            .unwrap_or_else(|| "Unknown error".to_string());
×
452
                        anyhow::bail!("Server error: {}", msg);
×
453
                    } else {
454
                        println!(
×
455
                            "Unknown status: archive={:?} ipfs={:?}",
×
NEW
456
                            status.archive.status.status, status.pins.status.status
×
457
                        );
458
                    }
459
                } else {
460
                    println!("Failed to get status: {}", r.status());
×
461
                }
462
            }
463
            Err(e) => {
×
464
                println!("Error polling status: {e}");
×
465
            }
466
        }
467
        tokio::time::sleep(std::time::Duration::from_secs(10)).await;
×
468
    }
469
    Ok(())
×
470
}
471

472
async fn download_backup(
×
473
    client: &Client,
474
    server_address: &str,
475
    task_id: &str,
476
    output_path: Option<&PathBuf>,
477
    _auth_token: Option<&str>,
478
    archive_format: &str,
479
) -> Result<()> {
480
    // Step 1: Get download token
481
    let token_url = format!(
×
482
        "{}{}/{}/download-tokens",
483
        server_address.trim_end_matches('/'),
×
484
        BACKUPS_API_PATH,
485
        task_id
486
    );
487
    let mut token_req = client.post(&token_url);
×
488
    if is_defined(&_auth_token.as_ref().map(|s| s.to_string())) {
×
489
        token_req = token_req.header("Authorization", format!("Bearer {}", _auth_token.unwrap()));
×
490
    }
491
    let token_resp = token_req
×
492
        .send()
493
        .await
×
494
        .context("Failed to get download token")?;
495
    if !token_resp.status().is_success() {
×
496
        anyhow::bail!("Failed to get download token: {}", token_resp.status());
×
497
    }
498
    let token_json: serde_json::Value =
×
499
        token_resp.json().await.context("Invalid token response")?;
×
500
    let download_token = token_json
×
501
        .get("token")
502
        .and_then(|v| v.as_str())
×
503
        .ok_or_else(|| anyhow::anyhow!("No token in response"))?;
×
504

505
    // Step 2: Download using token
506
    let download_url = format!(
×
507
        "{}{}/{}/download?token={}",
508
        server_address.trim_end_matches('/'),
×
509
        BACKUPS_API_PATH,
510
        task_id,
511
        urlencoding::encode(download_token)
×
512
    );
513
    println!("Downloading archive ...");
×
514
    let resp = client
×
515
        .get(&download_url)
×
516
        .send()
517
        .await
×
518
        .context("Failed to download archive")?;
519
    if !resp.status().is_success() {
×
520
        anyhow::bail!("Failed to download archive: {}", resp.status());
×
521
    }
522
    let bytes = resp.bytes().await.context("Failed to read archive bytes")?;
×
523
    let output_path = output_path.cloned().unwrap_or_else(|| PathBuf::from("."));
×
524

525
    println!("Extracting backup to {}...", output_path.display());
×
526
    match archive_format {
×
527
        "tar.gz" => {
×
528
            let gz = GzDecoder::new(Cursor::new(bytes));
×
529
            let mut archive = Archive::new(gz);
×
530
            archive
×
531
                .unpack(&output_path)
×
532
                .context("Failed to extract backup archive (tar.gz)")?;
533
        }
534
        "zip" => {
×
535
            let mut zip =
×
536
                ZipArchive::new(Cursor::new(bytes)).context("Failed to read zip archive")?;
×
537
            for i in 0..zip.len() {
×
538
                let mut file = zip.by_index(i).context("Failed to access file in zip")?;
×
539
                let outpath = match file.enclosed_name() {
×
540
                    Some(path) => output_path.join(path),
×
541
                    None => continue,
×
542
                };
543
                if file.name().ends_with('/') {
×
544
                    std::fs::create_dir_all(&outpath)
×
545
                        .context("Failed to create directory from zip")?;
546
                } else {
547
                    if let Some(p) = outpath.parent() {
×
548
                        std::fs::create_dir_all(p)
×
549
                            .context("Failed to create parent directory for zip file")?;
550
                    }
551
                    let mut outfile =
×
552
                        File::create(&outpath).context("Failed to create file from zip")?;
×
553
                    std::io::copy(&mut file, &mut outfile)
×
554
                        .context("Failed to extract file from zip")?;
555
                }
556
            }
557
        }
558
        _ => anyhow::bail!("Unknown archive format: {archive_format}"),
×
559
    }
560
    println!("Backup extracted to {}", output_path.display());
×
561
    Ok(())
×
562
}
563

564
async fn list_server_backups(server_address: &str) -> Result<()> {
×
565
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
566
    let client = Client::new();
×
567
    let url = format!(
×
568
        "{}{}",
569
        server_address.trim_end_matches('/'),
×
570
        BACKUPS_API_PATH
571
    );
572
    let mut req = client.get(&url);
×
573
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
574
        req = req.header(
×
575
            "Authorization",
×
576
            format!("Bearer {}", auth_token.as_deref().unwrap()),
×
577
        );
578
    }
579
    let resp = req
×
580
        .send()
581
        .await
×
582
        .context("Failed to fetch backups from server")?;
583
    if !resp.status().is_success() {
×
584
        let text = resp.text().await.unwrap_or_default();
×
585
        anyhow::bail!("Server error: {}", text);
×
586
    }
NEW
587
    let arr: Vec<BackupResponse> = resp.json().await.context("Invalid server response")?;
×
588
    let mut table = Table::new();
×
589
    table.add_row(row!["Task ID", "Status", "Error", "Error Log", "NFT Count"]);
×
590
    for entry in arr {
×
NEW
591
        let task_id = entry.task_id.as_str();
×
NEW
592
        let archive_status = entry.archive.status.status.as_deref();
×
NEW
593
        let pins_status = entry.pins.status.status.as_deref();
×
NEW
594
        let any_in_progress = matches!(archive_status, Some("in_progress"))
×
NEW
595
            || matches!(pins_status, Some("in_progress"));
×
NEW
596
        let any_error = matches!(archive_status, Some("error"))
×
NEW
597
            || matches!(pins_status, Some("error"))
×
NEW
598
            || entry.archive.status.fatal_error.is_some()
×
NEW
599
            || entry.pins.status.fatal_error.is_some();
×
NEW
600
        let all_done = matches!(archive_status, Some("done"))
×
NEW
601
            && (pins_status.is_none() || matches!(pins_status, Some("done")));
×
NEW
602
        let status = if any_in_progress {
×
NEW
603
            "in_progress"
×
NEW
604
        } else if any_error {
×
NEW
605
            "error"
×
NEW
606
        } else if all_done {
×
NEW
607
            "done"
×
608
        } else {
NEW
609
            ""
×
610
        };
NEW
611
        let error = entry
×
NEW
612
            .archive
×
NEW
613
            .status
×
NEW
614
            .fatal_error
×
615
            .clone()
NEW
616
            .or(entry.pins.status.fatal_error.clone())
×
617
            .unwrap_or_default();
NEW
618
        let mut logs = Vec::new();
×
NEW
619
        if let Some(a) = entry.archive.status.error_log.as_ref() {
×
NEW
620
            if !a.is_empty() {
×
NEW
621
                logs.push(a.as_str());
×
622
            }
623
        }
NEW
624
        if let Some(i) = entry.pins.status.error_log.as_ref() {
×
NEW
625
            if !i.is_empty() {
×
NEW
626
                logs.push(i.as_str());
×
627
            }
628
        }
NEW
629
        let combined = logs.join(" | ");
×
NEW
630
        let nft_count = entry.total_tokens as u64;
×
UNCOV
631
        table.add_row(row![task_id, status, error, combined, nft_count]);
×
632
    }
633
    table.printstd();
×
634
    Ok(())
×
635
}
636

637
#[tokio::main]
638
async fn main() -> Result<()> {
×
639
    dotenv().ok();
×
640
    let args = Args::parse();
×
641
    logging::init(args.log_level, !args.no_color);
×
642
    debug!(
×
643
        "Starting {} {} (commit {})",
×
644
        env!("CARGO_BIN_NAME"),
645
        env!("CARGO_PKG_VERSION"),
646
        env!("GIT_COMMIT")
647
    );
648

649
    if args.server_mode && args.list {
×
650
        return list_server_backups(&args.server_address).await;
×
651
    }
652

653
    let tokens_content = fs::read_to_string(&args.tokens_config_path)
×
654
        .await
×
655
        .context("Failed to read tokens config file")?;
656
    let token_config: TokenConfig =
×
657
        toml::from_str(&tokens_content).context("Failed to parse tokens config file")?;
×
658

659
    if args.server_mode {
×
660
        return backup_from_server(
×
661
            token_config,
×
662
            args.server_address,
×
663
            args.output_path,
×
664
            args.force,
×
665
            args.user_agent,
×
666
            args.pin_on_ipfs,
×
667
        )
668
        .await;
×
669
    }
670

671
    let chains_content = fs::read_to_string(&args.chains_config_path)
×
672
        .await
×
673
        .context("Failed to read chains config file")?;
674
    let mut chain_config: ChainConfig =
×
675
        toml::from_str(&chains_content).context("Failed to parse chains config file")?;
×
676
    chain_config.resolve_env_vars()?;
×
677

678
    // Load IPFS provider configuration from file if provided
679
    let ipfs_pinning_configs = if args.ipfs_config.is_none() {
×
680
        Vec::new()
×
681
    } else {
682
        let path = args.ipfs_config.as_ref().unwrap();
×
683
        let contents = std::fs::read_to_string(path)
×
684
            .with_context(|| format!("Failed to read IPFS config file '{path}'"))?;
×
685
        let config: IpfsConfigFile = toml::from_str(&contents)
×
686
            .with_context(|| format!("Failed to parse IPFS config file '{path}'"))?;
×
687
        config.ipfs_pinning_provider
×
688
    };
689

690
    let output_path = args.output_path.clone();
×
691
    let backup_config = BackupConfig {
692
        chain_config,
693
        token_config,
694
        storage_config: StorageConfig {
×
695
            output_path: output_path.clone(),
696
            prune_redundant: args.prune_redundant,
697
            ipfs_pinning_configs,
698
        },
699
        process_config: ProcessManagementConfig {
×
700
            exit_on_error: args.exit_on_error,
701
            shutdown_flag: None,
702
        },
703
        task_id: None, // CLI doesn't have a task ID
704
    };
705
    let (archive_out, ipfs_out) = backup_from_config(backup_config, None).await?;
×
706
    // Write combined error log to file if present
707
    let mut merged = Vec::new();
×
708
    if !archive_out.errors.is_empty() {
×
709
        merged.extend(archive_out.errors);
×
710
    }
711
    if !ipfs_out.errors.is_empty() {
×
712
        merged.extend(ipfs_out.errors);
×
713
    }
714
    if !merged.is_empty() {
×
715
        if let Some(ref out_path) = output_path {
×
716
            let mut log_path = out_path.clone();
×
717
            log_path.set_extension("log");
×
718
            let log_content = merged.join("\n") + "\n";
×
719
            use tokio::io::AsyncWriteExt;
720
            let mut file = tokio::fs::File::create(&log_path).await?;
×
721
            file.write_all(log_content.as_bytes()).await?;
×
722
            error!("Error log written to {}", log_path.display());
×
723
        }
724
    }
725
    Ok(())
×
726
}
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