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

0xmichalis / nftbk / 18504652892

14 Oct 2025 05:20PM UTC coverage: 34.818% (-1.4%) from 36.262%
18504652892

Pull #63

github

web-flow
Merge 541a6229c into fcba7227a
Pull Request #63: Improve database schema architecture

88 of 546 new or added lines in 16 files covered. (16.12%)

10 existing lines in 6 files now uncovered.

1321 of 3794 relevant lines covered (34.82%)

5.34 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
const BACKUPS_API_PATH: &str = "/v1/backups";
26

27
#[derive(serde::Deserialize)]
28
struct IpfsConfigFile {
29
    ipfs_provider: Vec<IpfsProviderConfig>,
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(BackupResponse),
94
    Exists(BackupResponse),
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() {
×
326
        let backup_resp: BackupResponse = resp.json().await.context("Invalid server response")?;
×
327
        if status.as_u16() == 201 {
×
328
            return Ok(BackupStart::Created(backup_resp));
×
329
        } else {
330
            return Ok(BackupStart::Exists(backup_resp));
×
331
        }
332
    }
333
    if status.as_u16() == 409 {
×
334
        let body: serde_json::Value = resp
×
335
            .json()
336
            .await
×
337
            .context("Invalid conflict response from server")?;
338
        let task_id = body
×
339
            .get("task_id")
340
            .and_then(|v| v.as_str())
×
341
            .unwrap_or_default()
342
            .to_string();
343
        let retry_url = body
×
344
            .get("retry_url")
345
            .and_then(|v| v.as_str())
×
346
            .unwrap_or("")
347
            .to_string();
348
        let message = body
×
349
            .get("error")
350
            .and_then(|v| v.as_str())
×
351
            .unwrap_or(&format!("Server returned conflict for {BACKUPS_API_PATH}"))
×
352
            .to_string();
353
        return Ok(BackupStart::Conflict {
×
354
            task_id,
×
355
            retry_url,
×
356
            message,
×
357
        });
358
    }
359
    let text = resp.text().await.unwrap_or_default();
×
360
    anyhow::bail!("Server error: {}", text);
×
361
}
362

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

456
async fn download_backup(
×
457
    client: &Client,
458
    server_address: &str,
459
    task_id: &str,
460
    output_path: Option<&PathBuf>,
461
    _auth_token: Option<&str>,
462
    archive_format: &str,
463
) -> Result<()> {
464
    // Step 1: Get download token
465
    let token_url = format!(
×
466
        "{}{}/{}/download-tokens",
467
        server_address.trim_end_matches('/'),
×
468
        BACKUPS_API_PATH,
469
        task_id
470
    );
471
    let mut token_req = client.post(&token_url);
×
472
    if is_defined(&_auth_token.as_ref().map(|s| s.to_string())) {
×
473
        token_req = token_req.header("Authorization", format!("Bearer {}", _auth_token.unwrap()));
×
474
    }
475
    let token_resp = token_req
×
476
        .send()
477
        .await
×
478
        .context("Failed to get download token")?;
479
    if !token_resp.status().is_success() {
×
480
        anyhow::bail!("Failed to get download token: {}", token_resp.status());
×
481
    }
482
    let token_json: serde_json::Value =
×
483
        token_resp.json().await.context("Invalid token response")?;
×
484
    let download_token = token_json
×
485
        .get("token")
486
        .and_then(|v| v.as_str())
×
487
        .ok_or_else(|| anyhow::anyhow!("No token in response"))?;
×
488

489
    // Step 2: Download using token
490
    let download_url = format!(
×
491
        "{}{}/{}/download?token={}",
492
        server_address.trim_end_matches('/'),
×
493
        BACKUPS_API_PATH,
494
        task_id,
495
        urlencoding::encode(download_token)
×
496
    );
497
    println!("Downloading archive ...");
×
498
    let resp = client
×
499
        .get(&download_url)
×
500
        .send()
501
        .await
×
502
        .context("Failed to download archive")?;
503
    if !resp.status().is_success() {
×
504
        anyhow::bail!("Failed to download archive: {}", resp.status());
×
505
    }
506
    let bytes = resp.bytes().await.context("Failed to read archive bytes")?;
×
507
    let output_path = output_path.cloned().unwrap_or_else(|| PathBuf::from("."));
×
508

509
    println!("Extracting backup to {}...", output_path.display());
×
510
    match archive_format {
×
511
        "tar.gz" => {
×
512
            let gz = GzDecoder::new(Cursor::new(bytes));
×
513
            let mut archive = Archive::new(gz);
×
514
            archive
×
515
                .unpack(&output_path)
×
516
                .context("Failed to extract backup archive (tar.gz)")?;
517
        }
518
        "zip" => {
×
519
            let mut zip =
×
520
                ZipArchive::new(Cursor::new(bytes)).context("Failed to read zip archive")?;
×
521
            for i in 0..zip.len() {
×
522
                let mut file = zip.by_index(i).context("Failed to access file in zip")?;
×
523
                let outpath = match file.enclosed_name() {
×
524
                    Some(path) => output_path.join(path),
×
525
                    None => continue,
×
526
                };
527
                if file.name().ends_with('/') {
×
528
                    std::fs::create_dir_all(&outpath)
×
529
                        .context("Failed to create directory from zip")?;
530
                } else {
531
                    if let Some(p) = outpath.parent() {
×
532
                        std::fs::create_dir_all(p)
×
533
                            .context("Failed to create parent directory for zip file")?;
534
                    }
535
                    let mut outfile =
×
536
                        File::create(&outpath).context("Failed to create file from zip")?;
×
537
                    std::io::copy(&mut file, &mut outfile)
×
538
                        .context("Failed to extract file from zip")?;
539
                }
540
            }
541
        }
542
        _ => anyhow::bail!("Unknown archive format: {archive_format}"),
×
543
    }
544
    println!("Backup extracted to {}", output_path.display());
×
545
    Ok(())
×
546
}
547

548
async fn list_server_backups(server_address: &str) -> Result<()> {
×
549
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
550
    let client = Client::new();
×
551
    let url = format!(
×
552
        "{}{}",
553
        server_address.trim_end_matches('/'),
×
554
        BACKUPS_API_PATH
555
    );
556
    let mut req = client.get(&url);
×
557
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
558
        req = req.header(
×
559
            "Authorization",
×
560
            format!("Bearer {}", auth_token.as_deref().unwrap()),
×
561
        );
562
    }
563
    let resp = req
×
564
        .send()
565
        .await
×
566
        .context("Failed to fetch backups from server")?;
567
    if !resp.status().is_success() {
×
568
        let text = resp.text().await.unwrap_or_default();
×
569
        anyhow::bail!("Server error: {}", text);
×
570
    }
571
    let backups: serde_json::Value = resp.json().await.context("Invalid server response")?;
×
572
    let arr = backups
×
573
        .as_array()
574
        .ok_or_else(|| anyhow::anyhow!("Expected array response"))?;
×
575
    let mut table = Table::new();
×
576
    table.add_row(row!["Task ID", "Status", "Error", "Error Log", "NFT Count"]);
×
577
    for entry in arr {
×
578
        let task_id = entry.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
×
579
        let status = entry.get("status").and_then(|v| v.as_str()).unwrap_or("");
×
580
        let error = entry.get("error").and_then(|v| v.as_str()).unwrap_or("");
×
NEW
581
        let archive_error_log = entry
×
582
            .get("archive_error_log")
UNCOV
583
            .and_then(|v| v.as_str())
×
584
            .unwrap_or("");
NEW
585
        let ipfs_error_log = entry
×
586
            .get("ipfs_error_log")
NEW
587
            .and_then(|v| v.as_str())
×
588
            .unwrap_or("");
NEW
589
        let combined = [archive_error_log, ipfs_error_log]
×
590
            .iter()
NEW
591
            .filter(|s| !s.is_empty())
×
592
            .cloned()
593
            .collect::<Vec<_>>()
594
            .join(" | ");
595
        let nft_count = entry.get("nft_count").and_then(|v| v.as_u64()).unwrap_or(0);
×
NEW
596
        table.add_row(row![task_id, status, error, combined, nft_count]);
×
597
    }
598
    table.printstd();
×
599
    Ok(())
×
600
}
601

602
#[tokio::main]
603
async fn main() -> Result<()> {
×
604
    dotenv().ok();
×
605
    let args = Args::parse();
×
606
    logging::init(args.log_level, !args.no_color);
×
607
    debug!(
×
608
        "Starting {} {} (commit {})",
×
609
        env!("CARGO_BIN_NAME"),
610
        env!("CARGO_PKG_VERSION"),
611
        env!("GIT_COMMIT")
612
    );
613

614
    if args.server_mode && args.list {
×
615
        return list_server_backups(&args.server_address).await;
×
616
    }
617

618
    let tokens_content = fs::read_to_string(&args.tokens_config_path)
×
619
        .await
×
620
        .context("Failed to read tokens config file")?;
621
    let token_config: TokenConfig =
×
622
        toml::from_str(&tokens_content).context("Failed to parse tokens config file")?;
×
623

624
    if args.server_mode {
×
625
        return backup_from_server(
×
626
            token_config,
×
627
            args.server_address,
×
628
            args.output_path,
×
629
            args.force,
×
630
            args.user_agent,
×
631
            args.pin_on_ipfs,
×
632
        )
633
        .await;
×
634
    }
635

636
    let chains_content = fs::read_to_string(&args.chains_config_path)
×
637
        .await
×
638
        .context("Failed to read chains config file")?;
639
    let mut chain_config: ChainConfig =
×
640
        toml::from_str(&chains_content).context("Failed to parse chains config file")?;
×
641
    chain_config.resolve_env_vars()?;
×
642

643
    // Load IPFS provider configuration from file if provided
644
    let ipfs_providers = if args.ipfs_config.is_none() {
×
645
        Vec::new()
×
646
    } else {
647
        let path = args.ipfs_config.as_ref().unwrap();
×
648
        let contents = std::fs::read_to_string(path)
×
649
            .with_context(|| format!("Failed to read IPFS config file '{path}'"))?;
×
650
        let config: IpfsConfigFile = toml::from_str(&contents)
×
651
            .with_context(|| format!("Failed to parse IPFS config file '{path}'"))?;
×
652
        config.ipfs_provider
×
653
    };
654

655
    let output_path = args.output_path.clone();
×
656
    let backup_config = BackupConfig {
657
        chain_config,
658
        token_config,
659
        storage_config: StorageConfig {
×
660
            output_path: output_path.clone(),
661
            prune_redundant: args.prune_redundant,
662
            ipfs_providers,
663
        },
664
        process_config: ProcessManagementConfig {
×
665
            exit_on_error: args.exit_on_error,
666
            shutdown_flag: None,
667
        },
668
        task_id: None, // CLI doesn't have a task ID
669
    };
NEW
670
    let (archive_out, ipfs_out) = backup_from_config(backup_config, None).await?;
×
671
    // Write combined error log to file if present
NEW
672
    let mut merged = Vec::new();
×
NEW
673
    if !archive_out.errors.is_empty() {
×
NEW
674
        merged.extend(archive_out.errors);
×
675
    }
NEW
676
    if !ipfs_out.errors.is_empty() {
×
NEW
677
        merged.extend(ipfs_out.errors);
×
678
    }
NEW
679
    if !merged.is_empty() {
×
680
        if let Some(ref out_path) = output_path {
×
681
            let mut log_path = out_path.clone();
×
682
            log_path.set_extension("log");
×
NEW
683
            let log_content = merged.join("\n") + "\n";
×
684
            use tokio::io::AsyncWriteExt;
685
            let mut file = tokio::fs::File::create(&log_path).await?;
×
686
            file.write_all(log_content.as_bytes()).await?;
×
687
            error!("Error log written to {}", log_path.display());
×
688
        }
689
    }
690
    Ok(())
×
691
}
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