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

0xmichalis / nftbk / 18247396385

04 Oct 2025 05:29PM UTC coverage: 42.766% (+3.2%) from 39.547%
18247396385

push

github

0xmichalis
fix: fix clippy warnings

4 of 9 new or added lines in 2 files covered. (44.44%)

395 existing lines in 9 files now uncovered.

2223 of 5198 relevant lines covered (42.77%)

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

24
#[derive(Parser, Debug)]
25
#[command(author, version, about, long_about = None)]
26
struct Args {
27
    /// The path to the chains configuration file
28
    #[arg(short = 'c', long, default_value = "config_chains.toml")]
29
    chains_config_path: PathBuf,
30

31
    /// The path to the tokens configuration file
32
    #[arg(short = 't', long, default_value = "config_tokens.toml")]
33
    tokens_config_path: PathBuf,
34

35
    /// The directory to save the backup to
36
    #[arg(short, long, default_value = "nft_backup")]
37
    output_path: Option<PathBuf>,
38

39
    /// Set the log level
40
    #[arg(short, long, value_enum, default_value = "info")]
41
    log_level: LogLevel,
42

43
    /// Delete redundant files in the backup folder
44
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
45
    prune_redundant: bool,
46

47
    /// Request a backup from the server instead of running locally
48
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
49
    server_mode: bool,
50

51
    /// The server address to request backups from
52
    #[arg(long, default_value = "http://127.0.0.1:8080")]
53
    server_address: String,
54

55
    /// Exit on the first error encountered
56
    #[arg(long, default_value_t = false, action = clap::ArgAction::Set)]
57
    exit_on_error: bool,
58

59
    /// IPFS pinning service base URL (enables IPFS pinning when provided)
60
    #[arg(long)]
61
    ipfs_pin_url: Option<String>,
62

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

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

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

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

80
enum BackupStart {
81
    Created(BackupResponse),
82
    Conflict {
83
        task_id: String,
84
        retry_url: String,
85
        message: String,
86
    },
87
}
88

89
async fn backup_from_server(
×
90
    token_config: TokenConfig,
×
91
    server_address: String,
×
92
    output_path: Option<PathBuf>,
×
93
    force: bool,
×
UNCOV
94
    user_agent: String,
×
UNCOV
95
) -> Result<()> {
×
96
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
97
    let client = Client::new();
×
98

99
    // First, try to create the backup
100
    let start = request_backup(
×
101
        &token_config,
×
102
        &server_address,
×
103
        &client,
×
UNCOV
104
        auth_token.as_deref(),
×
105
        &user_agent,
×
106
    )
×
107
    .await?;
×
108

109
    let task_id = match start {
×
UNCOV
110
        BackupStart::Created(resp) => {
×
UNCOV
111
            let task_id = resp.task_id.clone();
×
112
            println!("Task ID: {task_id}");
×
113
            task_id
×
114
        }
115
        BackupStart::Conflict {
116
            task_id,
×
117
            retry_url,
×
118
            message,
×
119
        } => {
120
            println!("Task ID: {task_id}");
×
121
            if force {
×
122
                println!("Server response: {message}");
×
123
                let server = server_address.trim_end_matches('/');
×
124
                println!("Retrying via {server}{retry_url} ...");
×
125
                retry_backup(
×
126
                    &client,
×
127
                    &server_address,
×
128
                    &retry_url,
×
129
                    &task_id,
×
UNCOV
130
                    auth_token.as_deref(),
×
131
                )
×
132
                .await?;
×
UNCOV
133
                task_id
×
134
            } else {
UNCOV
135
                anyhow::bail!(
×
UNCOV
136
                    "{}\nRun the CLI with --force true, or POST to {}{}",
×
137
                    message,
UNCOV
138
                    server_address.trim_end_matches('/'),
×
139
                    retry_url
140
                );
141
            }
142
        }
143
    };
144

145
    wait_for_done_backup(&client, &server_address, &task_id, auth_token.as_deref()).await?;
×
146

147
    return download_backup(
×
148
        &client,
×
149
        &server_address,
×
150
        &task_id,
×
151
        output_path.as_ref(),
×
152
        auth_token.as_deref(),
×
UNCOV
153
        &archive_format_from_user_agent(&user_agent),
×
154
    )
×
155
    .await;
×
156
}
×
157

158
async fn request_backup(
×
159
    token_config: &TokenConfig,
×
160
    server_address: &str,
×
161
    client: &Client,
×
162
    auth_token: Option<&str>,
×
163
    user_agent: &str,
×
164
) -> Result<BackupStart> {
×
165
    let mut backup_req = BackupRequest { tokens: Vec::new() };
×
166
    for (chain, tokens) in &token_config.chains {
×
167
        backup_req.tokens.push(Tokens {
×
UNCOV
168
            chain: chain.clone(),
×
169
            tokens: tokens.clone(),
×
170
        });
×
171
    }
×
172

173
    let server = server_address.trim_end_matches('/');
×
174
    println!("Submitting backup request to server at {server}/backup ...",);
×
175
    let mut req = client.post(format!("{server}/backup")).json(&backup_req);
×
176
    req = req.header("User-Agent", user_agent);
×
177
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
178
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
179
    }
×
180
    let resp = req
×
181
        .send()
×
182
        .await
×
183
        .context("Failed to send backup request to server")?;
×
184
    let status = resp.status();
×
185
    if status.is_success() {
×
186
        let backup_resp: BackupResponse = resp.json().await.context("Invalid server response")?;
×
187
        return Ok(BackupStart::Created(backup_resp));
×
188
    }
×
189
    if status.as_u16() == 409 {
×
190
        let body: serde_json::Value = resp
×
191
            .json()
×
192
            .await
×
193
            .context("Invalid conflict response from server")?;
×
194
        let task_id = body
×
195
            .get("task_id")
×
196
            .and_then(|v| v.as_str())
×
197
            .unwrap_or_default()
×
198
            .to_string();
×
199
        let retry_url = body
×
200
            .get("retry_url")
×
201
            .and_then(|v| v.as_str())
×
202
            .unwrap_or("")
×
203
            .to_string();
×
204
        let message = body
×
205
            .get("error")
×
206
            .and_then(|v| v.as_str())
×
207
            .unwrap_or("Server returned conflict for /backup")
×
208
            .to_string();
×
209
        return Ok(BackupStart::Conflict {
×
210
            task_id,
×
211
            retry_url,
×
212
            message,
×
213
        });
×
UNCOV
214
    }
×
215
    let text = resp.text().await.unwrap_or_default();
×
216
    anyhow::bail!("Server error: {}", text);
×
217
}
×
218

219
async fn retry_backup(
×
220
    client: &Client,
×
221
    server_address: &str,
×
222
    retry_url: &str,
×
223
    task_id: &str,
×
224
    auth_token: Option<&str>,
×
225
) -> Result<BackupResponse> {
×
226
    let server = server_address.trim_end_matches('/');
×
227
    let full_url = format!("{server}{retry_url}");
×
228
    println!("Retrying backup task {task_id} at {full_url} ...");
×
229
    let mut req = client.post(full_url);
×
230
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
231
        req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
232
    }
×
233
    let resp = req
×
234
        .send()
×
235
        .await
×
236
        .context("Failed to send retry request to server")?;
×
237
    if !resp.status().is_success() {
×
238
        let text = resp.text().await.unwrap_or_default();
×
239
        anyhow::bail!("Server error during retry: {}", text);
×
240
    }
×
241
    let backup_resp: BackupResponse = resp
×
242
        .json()
×
UNCOV
243
        .await
×
244
        .context("Invalid server response during retry")?;
×
245
    Ok(backup_resp)
×
246
}
×
247

248
async fn wait_for_done_backup(
×
249
    client: &reqwest::Client,
×
250
    server_address: &str,
×
251
    task_id: &str,
×
252
    auth_token: Option<&str>,
×
UNCOV
253
) -> Result<()> {
×
UNCOV
254
    let status_url = format!(
×
255
        "{}/backup/{}/status",
×
UNCOV
256
        server_address.trim_end_matches('/'),
×
257
        task_id
258
    );
259
    let mut in_progress_logged = false;
×
260
    loop {
261
        let mut req = client.get(&status_url);
×
262
        if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
263
            req = req.header("Authorization", format!("Bearer {}", auth_token.unwrap()));
×
264
        }
×
265
        let resp = req.send().await;
×
266
        match resp {
×
267
            Ok(r) => {
×
268
                if r.status().is_success() {
×
269
                    let status: StatusResponse = r.json().await.unwrap_or(StatusResponse {
×
270
                        status: "error".to_string(),
×
271
                        error: Some("Invalid status response".to_string()),
×
272
                        error_log: None,
×
273
                    });
×
274
                    match status.status.as_str() {
×
275
                        "in_progress" => {
×
UNCOV
276
                            if !in_progress_logged {
×
277
                                println!("Waiting for backup to complete...");
×
278
                                in_progress_logged = true;
×
279
                            }
×
280
                        }
281
                        "done" => {
×
282
                            println!("Backup complete.");
×
283
                            if let Some(error_log) = &status.error_log {
×
284
                                if !error_log.is_empty() {
×
UNCOV
285
                                    warn!("{}", error_log);
×
286
                                }
×
287
                            }
×
UNCOV
288
                            break;
×
289
                        }
290
                        "error" => {
×
291
                            anyhow::bail!("Server error: {}", status.error.unwrap_or_default());
×
292
                        }
293
                        _ => {
×
294
                            println!("Unknown status: {}", status.status);
×
295
                        }
×
296
                    }
297
                } else {
×
298
                    println!("Failed to get status: {}", r.status());
×
299
                }
×
300
            }
301
            Err(e) => {
×
UNCOV
302
                println!("Error polling status: {e}");
×
303
            }
×
304
        }
UNCOV
305
        tokio::time::sleep(std::time::Duration::from_secs(10)).await;
×
306
    }
307
    Ok(())
×
308
}
×
309

310
async fn download_backup(
×
311
    client: &Client,
×
312
    server_address: &str,
×
313
    task_id: &str,
×
UNCOV
314
    output_path: Option<&PathBuf>,
×
315
    _auth_token: Option<&str>,
×
316
    archive_format: &str,
×
317
) -> Result<()> {
×
318
    // Step 1: Get download token
UNCOV
319
    let token_url = format!(
×
320
        "{}/backup/{}/download_token",
×
321
        server_address.trim_end_matches('/'),
×
322
        task_id
323
    );
324
    let mut token_req = client.get(&token_url);
×
325
    if is_defined(&_auth_token.as_ref().map(|s| s.to_string())) {
×
326
        token_req = token_req.header("Authorization", format!("Bearer {}", _auth_token.unwrap()));
×
327
    }
×
328
    let token_resp = token_req
×
329
        .send()
×
330
        .await
×
331
        .context("Failed to get download token")?;
×
332
    if !token_resp.status().is_success() {
×
333
        anyhow::bail!("Failed to get download token: {}", token_resp.status());
×
334
    }
×
335
    let token_json: serde_json::Value =
×
336
        token_resp.json().await.context("Invalid token response")?;
×
UNCOV
337
    let download_token = token_json
×
UNCOV
338
        .get("token")
×
339
        .and_then(|v| v.as_str())
×
340
        .ok_or_else(|| anyhow::anyhow!("No token in response"))?;
×
341

342
    // Step 2: Download using token
343
    let download_url = format!(
×
UNCOV
344
        "{}/backup/{}/download?token={}",
×
345
        server_address.trim_end_matches('/'),
×
346
        task_id,
347
        urlencoding::encode(download_token)
×
348
    );
349
    println!("Downloading archive ...");
×
350
    let resp = client
×
351
        .get(&download_url)
×
352
        .send()
×
353
        .await
×
354
        .context("Failed to download archive")?;
×
355
    if !resp.status().is_success() {
×
UNCOV
356
        anyhow::bail!("Failed to download archive: {}", resp.status());
×
357
    }
×
358
    let bytes = resp.bytes().await.context("Failed to read archive bytes")?;
×
359
    let output_path = output_path.cloned().unwrap_or_else(|| PathBuf::from("."));
×
360

361
    println!("Extracting backup to {}...", output_path.display());
×
362
    match archive_format {
×
363
        "tar.gz" => {
×
364
            let gz = GzDecoder::new(Cursor::new(bytes));
×
UNCOV
365
            let mut archive = Archive::new(gz);
×
366
            archive
×
367
                .unpack(&output_path)
×
368
                .context("Failed to extract backup archive (tar.gz)")?;
×
369
        }
370
        "zip" => {
×
371
            let mut zip =
×
372
                ZipArchive::new(Cursor::new(bytes)).context("Failed to read zip archive")?;
×
373
            for i in 0..zip.len() {
×
UNCOV
374
                let mut file = zip.by_index(i).context("Failed to access file in zip")?;
×
375
                let outpath = match file.enclosed_name() {
×
376
                    Some(path) => output_path.join(path),
×
377
                    None => continue,
×
378
                };
379
                if file.name().ends_with('/') {
×
380
                    std::fs::create_dir_all(&outpath)
×
381
                        .context("Failed to create directory from zip")?;
×
382
                } else {
383
                    if let Some(p) = outpath.parent() {
×
384
                        std::fs::create_dir_all(p)
×
385
                            .context("Failed to create parent directory for zip file")?;
×
386
                    }
×
UNCOV
387
                    let mut outfile =
×
UNCOV
388
                        File::create(&outpath).context("Failed to create file from zip")?;
×
UNCOV
389
                    std::io::copy(&mut file, &mut outfile)
×
390
                        .context("Failed to extract file from zip")?;
×
391
                }
392
            }
393
        }
394
        _ => anyhow::bail!("Unknown archive format: {archive_format}"),
×
395
    }
396
    println!("Backup extracted to {}", output_path.display());
×
397
    Ok(())
×
398
}
×
399

400
async fn list_server_backups(server_address: &str) -> Result<()> {
×
401
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
402
    let client = Client::new();
×
403
    let url = format!("{}/backups", server_address.trim_end_matches('/'));
×
404
    let mut req = client.get(&url);
×
405
    if is_defined(&auth_token.as_ref().map(|s| s.to_string())) {
×
406
        req = req.header(
×
407
            "Authorization",
×
408
            format!("Bearer {}", auth_token.as_deref().unwrap()),
×
409
        );
×
410
    }
×
411
    let resp = req
×
412
        .send()
×
413
        .await
×
414
        .context("Failed to fetch backups from server")?;
×
415
    if !resp.status().is_success() {
×
416
        let text = resp.text().await.unwrap_or_default();
×
417
        anyhow::bail!("Server error: {}", text);
×
418
    }
×
419
    let backups: serde_json::Value = resp.json().await.context("Invalid server response")?;
×
420
    let arr = backups
×
421
        .as_array()
×
422
        .ok_or_else(|| anyhow::anyhow!("Expected array response"))?;
×
423
    let mut table = Table::new();
×
424
    table.add_row(row!["Task ID", "Status", "Error", "Error Log", "NFT Count"]);
×
425
    for entry in arr {
×
426
        let task_id = entry.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
×
427
        let status = entry.get("status").and_then(|v| v.as_str()).unwrap_or("");
×
428
        let error = entry.get("error").and_then(|v| v.as_str()).unwrap_or("");
×
429
        let error_log = entry
×
430
            .get("error_log")
×
UNCOV
431
            .and_then(|v| v.as_str())
×
432
            .unwrap_or("");
×
433
        let nft_count = entry.get("nft_count").and_then(|v| v.as_u64()).unwrap_or(0);
×
434
        table.add_row(row![task_id, status, error, error_log, nft_count]);
×
435
    }
UNCOV
436
    table.printstd();
×
437
    Ok(())
×
438
}
×
439

440
#[tokio::main]
441
async fn main() -> Result<()> {
×
442
    dotenv().ok();
×
UNCOV
443
    let args = Args::parse();
×
UNCOV
444
    logging::init(args.log_level, !args.no_color);
×
UNCOV
445
    debug!(
×
UNCOV
446
        "Starting {} {} (commit {})",
×
447
        env!("CARGO_BIN_NAME"),
448
        env!("CARGO_PKG_VERSION"),
449
        env!("GIT_COMMIT")
450
    );
451

452
    if args.server_mode && args.list {
×
453
        return list_server_backups(&args.server_address).await;
×
454
    }
×
455

456
    let tokens_content = fs::read_to_string(&args.tokens_config_path)
×
UNCOV
457
        .await
×
458
        .context("Failed to read tokens config file")?;
×
459
    let token_config: TokenConfig =
×
460
        toml::from_str(&tokens_content).context("Failed to parse tokens config file")?;
×
461

462
    if args.server_mode {
×
463
        return backup_from_server(
×
464
            token_config,
×
465
            args.server_address,
×
466
            args.output_path,
×
467
            args.force,
×
UNCOV
468
            args.user_agent,
×
469
        )
×
470
        .await;
×
471
    }
×
472

473
    let chains_content = fs::read_to_string(&args.chains_config_path)
×
474
        .await
×
UNCOV
475
        .context("Failed to read chains config file")?;
×
476
    let mut chain_config: ChainConfig =
×
477
        toml::from_str(&chains_content).context("Failed to parse chains config file")?;
×
478
    chain_config.resolve_env_vars()?;
×
479

480
    // Get IPFS pin token from environment variable if IPFS URL is provided
481
    let ipfs_pin_token = if args.ipfs_pin_url.is_some() {
×
482
        std::env::var("IPFS_PIN_TOKEN").ok()
×
483
    } else {
484
        None
×
485
    };
486

487
    let output_path = args.output_path.clone();
×
UNCOV
488
    let backup_config = BackupConfig {
×
489
        chain_config,
×
490
        token_config,
×
491
        storage_config: StorageConfig {
×
492
            output_path: output_path.clone(),
×
493
            prune_redundant: args.prune_redundant,
×
494
            enable_ipfs_pinning: args.ipfs_pin_url.is_some(),
×
495
            ipfs_pin_base_url: args.ipfs_pin_url,
×
496
            ipfs_pin_token,
×
497
        },
×
498
        process_config: ProcessManagementConfig {
×
499
            exit_on_error: args.exit_on_error,
×
500
            shutdown_flag: None,
×
501
        },
×
UNCOV
502
    };
×
UNCOV
503
    let (_files, _pins, error_log) = backup_from_config(backup_config, None).await?;
×
504
    // Write error log to file if present
UNCOV
505
    if !error_log.is_empty() {
×
UNCOV
506
        if let Some(ref out_path) = output_path {
×
UNCOV
507
            let mut log_path = out_path.clone();
×
UNCOV
508
            log_path.set_extension("log");
×
UNCOV
509
            let log_content = error_log.join("\n") + "\n";
×
UNCOV
510
            use tokio::io::AsyncWriteExt;
×
UNCOV
511
            let mut file = tokio::fs::File::create(&log_path).await?;
×
UNCOV
512
            file.write_all(log_content.as_bytes()).await?;
×
UNCOV
513
            error!("Error log written to {}", log_path.display());
×
UNCOV
514
        }
×
UNCOV
515
    }
×
UNCOV
516
    Ok(())
×
UNCOV
517
}
×
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