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

0xmichalis / nftbk / 18314421435

07 Oct 2025 01:32PM UTC coverage: 55.404% (+3.1%) from 52.265%
18314421435

push

github

0xmichalis
fix: clippy again

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

346 existing lines in 7 files now uncovered.

3542 of 6393 relevant lines covered (55.4%)

3.08 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

86
enum BackupStart {
87
    Created(BackupResponse),
88
    Conflict {
89
        task_id: String,
90
        retry_url: String,
91
        message: String,
92
    },
93
}
94

95
async fn backup_from_server(
×
96
    token_config: TokenConfig,
×
97
    server_address: String,
×
UNCOV
98
    output_path: Option<PathBuf>,
×
UNCOV
99
    force: bool,
×
100
    user_agent: String,
×
101
) -> Result<()> {
×
102
    let auth_token = env::var("NFTBK_AUTH_TOKEN").ok();
×
103
    let client = Client::new();
×
104

105
    // First, try to create the backup
106
    let start = request_backup(
×
107
        &token_config,
×
UNCOV
108
        &server_address,
×
109
        &client,
×
110
        auth_token.as_deref(),
×
111
        &user_agent,
×
112
    )
×
113
    .await?;
×
114

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

151
    wait_for_done_backup(&client, &server_address, &task_id, auth_token.as_deref()).await?;
×
152

153
    return download_backup(
×
154
        &client,
×
155
        &server_address,
×
156
        &task_id,
×
UNCOV
157
        output_path.as_ref(),
×
158
        auth_token.as_deref(),
×
159
        &archive_format_from_user_agent(&user_agent),
×
160
    )
×
161
    .await;
×
162
}
×
163

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

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

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

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

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

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

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

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

446
#[tokio::main]
UNCOV
447
async fn main() -> Result<()> {
×
UNCOV
448
    dotenv().ok();
×
UNCOV
449
    let args = Args::parse();
×
UNCOV
450
    logging::init(args.log_level, !args.no_color);
×
UNCOV
451
    debug!(
×
452
        "Starting {} {} (commit {})",
×
453
        env!("CARGO_BIN_NAME"),
454
        env!("CARGO_PKG_VERSION"),
455
        env!("GIT_COMMIT")
456
    );
457

458
    if args.server_mode && args.list {
×
459
        return list_server_backups(&args.server_address).await;
×
460
    }
×
461

462
    let tokens_content = fs::read_to_string(&args.tokens_config_path)
×
463
        .await
×
464
        .context("Failed to read tokens config file")?;
×
465
    let token_config: TokenConfig =
×
466
        toml::from_str(&tokens_content).context("Failed to parse tokens config file")?;
×
467

468
    if args.server_mode {
×
469
        return backup_from_server(
×
470
            token_config,
×
471
            args.server_address,
×
UNCOV
472
            args.output_path,
×
473
            args.force,
×
474
            args.user_agent,
×
475
        )
×
476
        .await;
×
477
    }
×
478

UNCOV
479
    let chains_content = fs::read_to_string(&args.chains_config_path)
×
UNCOV
480
        .await
×
481
        .context("Failed to read chains config file")?;
×
482
    let mut chain_config: ChainConfig =
×
UNCOV
483
        toml::from_str(&chains_content).context("Failed to parse chains config file")?;
×
484
    chain_config.resolve_env_vars()?;
×
485

486
    // Load IPFS provider configuration from file if provided
487
    let ipfs_providers = if let Some(path) = &args.ipfs_config {
×
488
        let contents = std::fs::read_to_string(path)
×
NEW
489
            .with_context(|| format!("Failed to read IPFS config file '{path}'"))?;
×
490
        let config: IpfsConfigFile = toml::from_str(&contents)
×
NEW
491
            .with_context(|| format!("Failed to parse IPFS config file '{path}'"))?;
×
492
        config.ipfs_provider
×
493
    } else {
494
        Vec::new()
×
495
    };
496

497
    let output_path = args.output_path.clone();
×
498
    let backup_config = BackupConfig {
×
499
        chain_config,
×
500
        token_config,
×
501
        storage_config: StorageConfig {
×
502
            output_path: output_path.clone(),
×
503
            prune_redundant: args.prune_redundant,
×
UNCOV
504
            ipfs_providers,
×
505
        },
×
506
        process_config: ProcessManagementConfig {
×
507
            exit_on_error: args.exit_on_error,
×
508
            shutdown_flag: None,
×
509
        },
×
510
    };
×
511
    let (_files, _pin_requests, error_log) = backup_from_config(backup_config, None).await?;
×
512
    // Write error log to file if present
513
    if !error_log.is_empty() {
×
514
        if let Some(ref out_path) = output_path {
×
515
            let mut log_path = out_path.clone();
×
516
            log_path.set_extension("log");
×
517
            let log_content = error_log.join("\n") + "\n";
×
UNCOV
518
            use tokio::io::AsyncWriteExt;
×
UNCOV
519
            let mut file = tokio::fs::File::create(&log_path).await?;
×
UNCOV
520
            file.write_all(log_content.as_bytes()).await?;
×
UNCOV
521
            error!("Error log written to {}", log_path.display());
×
UNCOV
522
        }
×
UNCOV
523
    }
×
UNCOV
524
    Ok(())
×
UNCOV
525
}
×
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