ngdp_client/commands/
inspect.rs

1use crate::{
2    InspectCommands, OutputFormat,
3    cached_client::create_client,
4    output::{
5        OutputStyle, create_table, format_count_badge, format_header, format_key_value,
6        format_success, format_warning, header_cell, numeric_cell, print_section_header,
7        print_subsection_header, regular_cell,
8    },
9};
10use blte::decompress_blte;
11use ngdp_bpsv::BpsvDocument;
12use ngdp_cache::cached_cdn_client::CachedCdnClient;
13use ngdp_crypto::KeyService;
14use ribbit_client::{Endpoint, ProductCdnsResponse, ProductVersionsResponse, Region};
15use std::str::FromStr;
16use tact_parser::{
17    config::BuildConfig, download::DownloadManifest, encoding::EncodingFile,
18    install::InstallManifest, size::SizeFile,
19};
20
21pub async fn handle(
22    cmd: InspectCommands,
23    format: OutputFormat,
24) -> Result<(), Box<dyn std::error::Error>> {
25    match cmd {
26        InspectCommands::Bpsv { input, raw } => inspect_bpsv(input, raw, format).await?,
27        InspectCommands::BuildConfig {
28            product,
29            build,
30            region,
31        } => {
32            inspect_build_config(product, build, region, format).await?;
33        }
34        InspectCommands::CdnConfig { product, region } => {
35            inspect_cdn_config(product, region, format).await?;
36        }
37        InspectCommands::Encoding {
38            product,
39            region,
40            stats,
41            search,
42            limit,
43        } => {
44            inspect_encoding(product, region, stats, search, limit, format).await?;
45        }
46        InspectCommands::Install {
47            product,
48            region,
49            tags,
50            all,
51        } => {
52            inspect_install(product, region, tags, all, format).await?;
53        }
54        InspectCommands::DownloadManifest {
55            product,
56            region,
57            priority_limit,
58            tags,
59        } => {
60            inspect_download_manifest(product, region, priority_limit, tags, format).await?;
61        }
62        InspectCommands::Size {
63            product,
64            region,
65            largest,
66            tags,
67        } => {
68            inspect_size(product, region, largest, tags, format).await?;
69        }
70    }
71    Ok(())
72}
73
74async fn inspect_bpsv(
75    input: String,
76    raw: bool,
77    format: OutputFormat,
78) -> Result<(), Box<dyn std::error::Error>> {
79    // Read BPSV data from file or fetch from URL
80    let data = if input.starts_with("http://") || input.starts_with("https://") {
81        // Fetch from URL
82        let response = reqwest::get(&input).await?;
83        response.text().await?
84    } else {
85        // Read from file
86        std::fs::read_to_string(&input)?
87    };
88
89    if raw {
90        println!("{data}");
91        return Ok(());
92    }
93
94    let doc = BpsvDocument::parse(&data)?;
95
96    match format {
97        OutputFormat::Json | OutputFormat::JsonPretty => {
98            let json_data = serde_json::json!({
99                "schema": doc.schema().field_names(),
100                "sequence_number": doc.sequence_number(),
101                "row_count": doc.rows().len(),
102                "rows": doc.rows().iter().map(|row| {
103                    let mut map = serde_json::Map::new();
104                    for (name, value) in doc.schema().field_names().iter().zip(row.raw_values()) {
105                        map.insert(name.to_string(), serde_json::Value::String(value.to_string()));
106                    }
107                    map
108                }).collect::<Vec<_>>()
109            });
110
111            let output = if matches!(format, OutputFormat::JsonPretty) {
112                serde_json::to_string_pretty(&json_data)?
113            } else {
114                serde_json::to_string(&json_data)?
115            };
116            println!("{output}");
117        }
118        OutputFormat::Bpsv => {
119            println!("{}", doc.to_bpsv_string());
120        }
121        OutputFormat::Text => {
122            let style = OutputStyle::new();
123
124            print_section_header("BPSV Document Analysis", &style);
125
126            print_subsection_header("Schema", &style);
127            let mut schema_table = create_table(&style);
128            schema_table.set_header(vec![
129                header_cell("Index", &style),
130                header_cell("Field Name", &style),
131                header_cell("Type", &style),
132            ]);
133
134            for (i, field) in doc.schema().fields().iter().enumerate() {
135                schema_table.add_row(vec![
136                    numeric_cell(&i.to_string()),
137                    regular_cell(&field.name),
138                    regular_cell(&field.field_type.to_string()),
139                ]);
140            }
141            println!("{schema_table}");
142
143            if let Some(seq) = doc.sequence_number() {
144                println!();
145                println!(
146                    "{}",
147                    format_key_value("Sequence Number", &seq.to_string(), &style)
148                );
149            }
150
151            print_subsection_header(
152                &format!(
153                    "Data {}",
154                    format_count_badge(doc.rows().len(), "row", &style)
155                ),
156                &style,
157            );
158
159            if !doc.rows().is_empty() {
160                // Show first few rows in a table
161                let preview_count = std::cmp::min(5, doc.rows().len());
162                println!(
163                    "\n{}",
164                    format_header(&format!("Preview (first {preview_count} rows)"), &style)
165                );
166
167                let mut data_table = create_table(&style);
168
169                // Set headers from schema
170                let mut headers = vec![header_cell("#", &style)];
171                headers.extend(
172                    doc.schema()
173                        .field_names()
174                        .iter()
175                        .map(|name| header_cell(name, &style)),
176                );
177                data_table.set_header(headers);
178
179                // Add rows
180                for (i, row) in doc.rows().iter().take(preview_count).enumerate() {
181                    let mut cells = vec![numeric_cell(&(i + 1).to_string())];
182                    cells.extend(row.raw_values().iter().map(|v| regular_cell(v)));
183                    data_table.add_row(cells);
184                }
185
186                println!("{data_table}");
187
188                if doc.rows().len() > preview_count {
189                    println!(
190                        "\n{}",
191                        format_header(
192                            &format!("... and {} more rows", doc.rows().len() - preview_count),
193                            &style
194                        )
195                    );
196                }
197            }
198        }
199    }
200
201    Ok(())
202}
203
204/// Inspect a build configuration by downloading and parsing it
205async fn inspect_build_config(
206    product: String,
207    build: String,
208    region: String,
209    format: OutputFormat,
210) -> Result<(), Box<dyn std::error::Error>> {
211    let style = OutputStyle::new();
212    let region_enum = Region::from_str(&region)?;
213
214    // Step 1: Get product version information
215    print_section_header(
216        &format!("Build Config Analysis: {product} (Build {build})"),
217        &style,
218    );
219
220    let client = create_client(region_enum).await?;
221    let versions_endpoint = Endpoint::ProductVersions(product.clone());
222    let versions: ProductVersionsResponse = client.request_typed(&versions_endpoint).await?;
223
224    // Find the specific build
225    let build_entry = versions
226        .entries
227        .iter()
228        .filter(|e| e.region == region)
229        .find(|e| e.build_id.to_string() == build || e.versions_name == build);
230
231    let build_entry = match build_entry {
232        Some(entry) => entry,
233        None => {
234            eprintln!(
235                "{}",
236                format_warning(
237                    &format!("Build '{build}' not found for {product} in region {region}"),
238                    &style
239                )
240            );
241            return Ok(());
242        }
243    };
244
245    println!("{}", format_key_value("Product", &product, &style));
246    println!("{}", format_key_value("Region", &region, &style));
247    println!(
248        "{}",
249        format_key_value("Build ID", &build_entry.build_id.to_string(), &style)
250    );
251    println!(
252        "{}",
253        format_key_value("Version", &build_entry.versions_name, &style)
254    );
255    println!(
256        "{}",
257        format_key_value("Build Config Hash", &build_entry.build_config, &style)
258    );
259    println!();
260
261    // Step 2: Get CDN information
262    let cdns_endpoint = Endpoint::ProductCdns(product.clone());
263    let cdns: ProductCdnsResponse = client.request_typed(&cdns_endpoint).await?;
264
265    let cdn_entry = cdns.entries.iter().find(|e| e.name == region);
266    let cdn_entry = match cdn_entry {
267        Some(entry) => entry,
268        None => {
269            eprintln!(
270                "{}",
271                format_warning(
272                    &format!("No CDN configuration found for region {region}"),
273                    &style
274                )
275            );
276            return Ok(());
277        }
278    };
279
280    // Step 3: Download the build config file
281    print_subsection_header("Downloading Build Configuration", &style);
282
283    let cdn_client = CachedCdnClient::new().await?;
284    // Add CDN hosts
285    cdn_client.add_primary_hosts(cdn_entry.hosts.iter().cloned());
286    cdn_client.add_fallback_host("cdn.arctium.tools");
287    cdn_client.add_fallback_host("tact.mirror.reliquaryhq.com");
288    let cdn_host = &cdn_entry.hosts[0]; // Use first CDN host
289    let cdn_path = &cdn_entry.path;
290
291    println!(
292        "Downloading from: {}/{}/config/{}",
293        cdn_host, cdn_path, &build_entry.build_config
294    );
295
296    let response = cdn_client
297        .download_build_config(cdn_host, cdn_path, &build_entry.build_config)
298        .await?;
299    let config_text = response.text().await?;
300
301    // Step 4: Parse the build config
302    let build_config = BuildConfig::parse(&config_text)?;
303
304    match format {
305        OutputFormat::Json | OutputFormat::JsonPretty => {
306            output_build_config_json(&build_config, format)?;
307        }
308        OutputFormat::Text => {
309            output_build_config_tree(&build_config, &style);
310        }
311        OutputFormat::Bpsv => {
312            println!("{config_text}");
313        }
314    }
315
316    Ok(())
317}
318
319/// Output build config as a visual tree
320fn output_build_config_tree(config: &BuildConfig, style: &OutputStyle) {
321    print_subsection_header("Build Configuration Tree", style);
322
323    // Core Files Section
324    println!("📁 {}", format_header("Core Game Files", style));
325
326    if let Some(root_hash) = config.root_hash() {
327        println!("├── 🗂️  Root File");
328        println!("│   ├── Hash: {root_hash}");
329        if let Some(size) = config.config.get_size("root") {
330            println!(
331                "│   └── Size: {} bytes ({:.2} MB)",
332                size,
333                size as f64 / (1024.0 * 1024.0)
334            );
335        }
336    }
337
338    if let Some(encoding_hash) = config.encoding_hash() {
339        println!("├── 🔗 Encoding File (CKey ↔ EKey mapping)");
340        println!("│   ├── Hash: {encoding_hash}");
341        if let Some(size) = config.config.get_size("encoding") {
342            println!(
343                "│   └── Size: {} bytes ({:.2} KB)",
344                size,
345                size as f64 / 1024.0
346            );
347        }
348    }
349
350    if let Some(install_hash) = config.install_hash() {
351        println!("├── 📦 Install Manifest");
352        println!("│   ├── Hash: {install_hash}");
353        if let Some(size) = config.config.get_size("install") {
354            println!(
355                "│   └── Size: {} bytes ({:.2} KB)",
356                size,
357                size as f64 / 1024.0
358            );
359        }
360    }
361
362    if let Some(download_hash) = config.download_hash() {
363        println!("├── ⬇️  Download Manifest");
364        println!("│   ├── Hash: {download_hash}");
365        if let Some(size) = config.config.get_size("download") {
366            println!(
367                "│   └── Size: {} bytes ({:.2} KB)",
368                size,
369                size as f64 / 1024.0
370            );
371        }
372    }
373
374    if let Some(size_hash) = config.size_hash() {
375        println!("└── 📏 Size File");
376        println!("    ├── Hash: {size_hash}");
377        if let Some(size) = config.config.get_size("size") {
378            println!(
379                "    └── Size: {} bytes ({:.2} KB)",
380                size,
381                size as f64 / 1024.0
382            );
383        }
384    }
385
386    println!();
387
388    // Build Information Section
389    println!("📋 {}", format_header("Build Information", style));
390
391    if let Some(build_name) = config.build_name() {
392        println!("├── Version: {}", format_success(build_name, style));
393    }
394    if let Some(build_uid) = config.config.get_value("build-uid") {
395        println!("├── Build UID: {build_uid}");
396    }
397    if let Some(build_product) = config.config.get_value("build-product") {
398        println!("├── Product: {build_product}");
399    }
400    if let Some(installer) = config.config.get_value("build-playbuild-installer") {
401        println!("└── Installer: {installer}");
402    }
403
404    println!();
405
406    // Patching Section
407    println!("🔄 {}", format_header("Patching", style));
408
409    let has_patch = config
410        .config
411        .get_value("patch")
412        .is_some_and(|v| !v.is_empty());
413    if has_patch {
414        if let Some(patch_hash) = config.config.get_value("patch") {
415            println!("├── ✅ Patch Available");
416            println!("│   └── Hash: {patch_hash}");
417        }
418    } else {
419        println!("└── ❌ No patch data");
420    }
421
422    println!();
423
424    // VFS Section
425    println!("🗃️  {}", format_header("Virtual File System (VFS)", style));
426
427    let mut vfs_entries = Vec::new();
428    for key in config.config.keys() {
429        if key.starts_with("vfs-") {
430            if let Some(value) = config.config.get_value(key) {
431                vfs_entries.push((key, value));
432            }
433        }
434    }
435
436    if !vfs_entries.is_empty() {
437        vfs_entries.sort_by_key(|(k, _)| *k);
438        for (i, (key, value)) in vfs_entries.iter().enumerate() {
439            let is_last = i == vfs_entries.len() - 1;
440            let prefix = if is_last { "└──" } else { "├──" };
441
442            if value.is_empty() {
443                println!("{} {}: {}", prefix, key, format_warning("(empty)", style));
444            } else {
445                println!("{prefix} {key}: {value}");
446            }
447        }
448    } else {
449        println!("└── No VFS entries found");
450    }
451
452    println!();
453
454    // Raw Configuration Section
455    print_subsection_header("Raw Configuration Entries", style);
456
457    let mut table = create_table(style);
458    table.set_header(vec![
459        header_cell("Key", style),
460        header_cell("Value", style),
461        header_cell("Type", style),
462    ]);
463
464    let mut keys: Vec<_> = config.config.keys().into_iter().collect();
465    keys.sort();
466
467    for key in keys {
468        if let Some(value) = config.config.get_value(key) {
469            let value_type = if config.config.get_hash(key).is_some() {
470                "Hash + Size"
471            } else if value.is_empty() {
472                "Empty"
473            } else if value.chars().all(|c| c.is_ascii_digit()) {
474                "Number"
475            } else {
476                "String"
477            };
478
479            let display_value = if value.len() > 50 {
480                format!("{}...", &value[..47])
481            } else {
482                value.to_string()
483            };
484
485            table.add_row(vec![
486                regular_cell(key),
487                regular_cell(&display_value),
488                regular_cell(value_type),
489            ]);
490        }
491    }
492
493    println!("{table}");
494}
495
496/// Output build config as JSON
497fn output_build_config_json(
498    config: &BuildConfig,
499    format: OutputFormat,
500) -> Result<(), Box<dyn std::error::Error>> {
501    let mut json_data = serde_json::Map::new();
502
503    // Core hashes
504    if let Some(hash) = config.root_hash() {
505        json_data.insert(
506            "root_hash".to_string(),
507            serde_json::Value::String(hash.to_string()),
508        );
509    }
510    if let Some(hash) = config.encoding_hash() {
511        json_data.insert(
512            "encoding_hash".to_string(),
513            serde_json::Value::String(hash.to_string()),
514        );
515    }
516    if let Some(hash) = config.install_hash() {
517        json_data.insert(
518            "install_hash".to_string(),
519            serde_json::Value::String(hash.to_string()),
520        );
521    }
522    if let Some(hash) = config.download_hash() {
523        json_data.insert(
524            "download_hash".to_string(),
525            serde_json::Value::String(hash.to_string()),
526        );
527    }
528    if let Some(hash) = config.size_hash() {
529        json_data.insert(
530            "size_hash".to_string(),
531            serde_json::Value::String(hash.to_string()),
532        );
533    }
534
535    // Build info
536    if let Some(name) = config.build_name() {
537        json_data.insert(
538            "build_name".to_string(),
539            serde_json::Value::String(name.to_string()),
540        );
541    }
542
543    // All raw values
544    let mut raw_config = serde_json::Map::new();
545    for key in config.config.keys() {
546        if let Some(value) = config.config.get_value(key) {
547            raw_config.insert(
548                key.to_string(),
549                serde_json::Value::String(value.to_string()),
550            );
551        }
552    }
553    json_data.insert(
554        "raw_config".to_string(),
555        serde_json::Value::Object(raw_config),
556    );
557
558    // Hash pairs
559    let mut hash_pairs = serde_json::Map::new();
560    for key in config.config.keys() {
561        if let Some(hash_pair) = config.config.get_hash_pair(key) {
562            hash_pairs.insert(
563                key.to_string(),
564                serde_json::json!({
565                    "hash": hash_pair.hash,
566                    "size": hash_pair.size
567                }),
568            );
569        }
570    }
571    if !hash_pairs.is_empty() {
572        json_data.insert(
573            "hash_pairs".to_string(),
574            serde_json::Value::Object(hash_pairs),
575        );
576    }
577
578    let output = match format {
579        OutputFormat::JsonPretty => serde_json::to_string_pretty(&json_data)?,
580        _ => serde_json::to_string(&json_data)?,
581    };
582
583    println!("{output}");
584    Ok(())
585}
586
587/// Inspect CDN configuration for a product
588async fn inspect_cdn_config(
589    product: String,
590    region: String,
591    format: OutputFormat,
592) -> Result<(), Box<dyn std::error::Error>> {
593    let style = OutputStyle::new();
594    let region_enum = Region::from_str(&region)?;
595
596    print_section_header(&format!("CDN Configuration Inspector - {product}"), &style);
597
598    // Get CDN configuration
599    let client = create_client(region_enum).await?;
600    let cdns_endpoint = Endpoint::ProductCdns(product.clone());
601    let cdns: ProductCdnsResponse = client.request_typed(&cdns_endpoint).await?;
602
603    // Find the specific region
604    let cdn_entry = cdns.entries.iter().find(|e| e.name == region);
605
606    let cdn_entry = match cdn_entry {
607        Some(entry) => entry,
608        None => {
609            eprintln!(
610                "{}",
611                format_warning(
612                    &format!("No CDN configuration found for region {region}"),
613                    &style
614                )
615            );
616            return Ok(());
617        }
618    };
619
620    match format {
621        OutputFormat::Json | OutputFormat::JsonPretty => {
622            let json_data = serde_json::json!({
623                "product": product,
624                "region": region,
625                "cdn_name": cdn_entry.name,
626                "path": cdn_entry.path,
627                "hosts": cdn_entry.hosts,
628                "config_path": &cdn_entry.config_path,
629                "total_hosts": cdn_entry.hosts.len(),
630            });
631
632            let output = match format {
633                OutputFormat::JsonPretty => serde_json::to_string_pretty(&json_data)?,
634                _ => serde_json::to_string(&json_data)?,
635            };
636            println!("{output}");
637        }
638        OutputFormat::Text => {
639            // Basic Info
640            println!("{}", format_key_value("Product", &product, &style));
641            println!("{}", format_key_value("Region", &region, &style));
642            println!("{}", format_key_value("CDN Name", &cdn_entry.name, &style));
643            println!("{}", format_key_value("CDN Path", &cdn_entry.path, &style));
644            if !cdn_entry.config_path.is_empty() {
645                println!(
646                    "{}",
647                    format_key_value("Config Path", &cdn_entry.config_path, &style)
648                );
649            }
650
651            // CDN Hosts Section
652            print_subsection_header(
653                &format!(
654                    "Available CDN Hosts {}",
655                    format_count_badge(cdn_entry.hosts.len(), "host", &style)
656                ),
657                &style,
658            );
659
660            let mut hosts_table = create_table(&style);
661            hosts_table.set_header(vec![
662                header_cell("Index", &style),
663                header_cell("CDN Host", &style),
664                header_cell("Status", &style),
665            ]);
666
667            for (i, host) in cdn_entry.hosts.iter().enumerate() {
668                // Basic availability check - just test if the host looks valid
669                let status = if host.contains("blzddist") || host.contains("battle.net") {
670                    format_success("Active", &style)
671                } else {
672                    "Unknown".to_string()
673                };
674
675                hosts_table.add_row(vec![
676                    numeric_cell(&(i + 1).to_string()),
677                    regular_cell(host),
678                    regular_cell(&status),
679                ]);
680            }
681
682            println!("{hosts_table}");
683
684            // CDN URLs Section
685            print_subsection_header("Example CDN URLs", &style);
686
687            let primary_host = &cdn_entry.hosts[0];
688            let cdn_path = &cdn_entry.path;
689
690            println!("🌐 Base CDN URL:");
691            println!("   {primary_host}/{cdn_path}");
692            println!();
693            println!("📁 Common endpoints:");
694            println!("   Config:  {primary_host}/{cdn_path}/config/[hash]");
695            println!("   Data:    {primary_host}/{cdn_path}/data/[hash]");
696            println!("   Patch:   {primary_host}/{cdn_path}/patch/[hash]");
697        }
698        OutputFormat::Bpsv => {
699            // For BPSV format, we'll output in a structured way
700            println!("## CDN Configuration");
701            println!("product:{product}");
702            println!("region:{region}");
703            println!("cdn_name:{}", cdn_entry.name);
704            println!("path:{}", cdn_entry.path);
705            if !cdn_entry.config_path.is_empty() {
706                println!("config_path:{}", cdn_entry.config_path);
707            }
708            for (i, host) in cdn_entry.hosts.iter().enumerate() {
709                println!("host_{i}:{host}");
710            }
711        }
712    }
713
714    Ok(())
715}
716
717/// Helper to download and decompress a manifest file from CDN
718async fn download_and_decompress_manifest(
719    product: &str,
720    region: &str,
721    manifest_type: &str,
722) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
723    // Step 1: Get build config hash
724    let region = Region::from_str(region)?;
725    let client = create_client(region).await?;
726
727    let versions_endpoint = Endpoint::ProductVersions(product.to_string());
728    let versions: ProductVersionsResponse = client
729        .request_typed(&versions_endpoint)
730        .await
731        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
732
733    let entry = versions
734        .entries
735        .iter()
736        .find(|e| e.region == region.to_string())
737        .ok_or("Region not found")?;
738
739    // Step 2: Get CDN info
740    let cdns_endpoint = Endpoint::ProductCdns(product.to_string());
741    let cdns: ProductCdnsResponse = client
742        .request_typed(&cdns_endpoint)
743        .await
744        .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
745
746    let cdn_entry = cdns
747        .entries
748        .iter()
749        .find(|e| e.name == region.to_string())
750        .ok_or("CDN not found")?;
751
752    // Step 3: Download build config
753    let cdn_client = CachedCdnClient::new().await?;
754    // Add CDN hosts
755    cdn_client.add_primary_hosts(cdn_entry.hosts.iter().cloned());
756    cdn_client.add_fallback_host("cdn.arctium.tools");
757    cdn_client.add_fallback_host("tact.mirror.reliquaryhq.com");
758    let cdn_host = &cdn_entry.hosts[0];
759    let cdn_path = &cdn_entry.path;
760
761    let config_response = cdn_client
762        .download_build_config(cdn_host, cdn_path, &entry.build_config)
763        .await?;
764    let config_text = config_response.text().await?;
765    let build_config = BuildConfig::parse(&config_text)?;
766
767    // Step 4: Get the manifest hash based on type
768    // Build configs have two hashes: content key and encoding key (CDN key)
769    // We need the second hash (encoding key) for CDN downloads
770    let manifest_hash = match manifest_type {
771        "encoding" => {
772            // Get the raw value and extract the second hash if present
773            build_config
774                .config
775                .get_value("encoding")
776                .and_then(|v| v.split_whitespace().nth(1))
777                .ok_or("No encoding hash")?
778        }
779        "install" => build_config
780            .config
781            .get_value("install")
782            .and_then(|v| v.split_whitespace().nth(1))
783            .or_else(|| build_config.install_hash())
784            .ok_or("No install hash")?,
785        "download" => build_config
786            .config
787            .get_value("download")
788            .and_then(|v| v.split_whitespace().nth(1))
789            .or_else(|| build_config.download_hash())
790            .ok_or("No download hash")?,
791        "size" => build_config
792            .config
793            .get_value("size")
794            .and_then(|v| v.split_whitespace().nth(1))
795            .or_else(|| build_config.size_hash())
796            .ok_or("No size hash")?,
797        _ => return Err("Invalid manifest type".into()),
798    };
799
800    // Step 5: Download the manifest as data file
801    // Encoding, install, download, and size files are stored as data, not config
802    let response = cdn_client
803        .download_data(cdn_host, cdn_path, manifest_hash)
804        .await?;
805
806    let manifest_data = response.bytes().await?.to_vec();
807
808    // Step 6: Decompress with BLTE if needed
809    if manifest_data.len() >= 4 && &manifest_data[0..4] == b"BLTE" {
810        let key_service = KeyService::new();
811        let decompressed = decompress_blte(manifest_data, Some(&key_service))?;
812        Ok(decompressed)
813    } else {
814        Ok(manifest_data)
815    }
816}
817
818/// Inspect encoding file
819async fn inspect_encoding(
820    product: String,
821    region: String,
822    stats: bool,
823    search: Option<String>,
824    _limit: usize,
825    format: OutputFormat,
826) -> Result<(), Box<dyn std::error::Error>> {
827    let style = OutputStyle::new();
828    print_section_header(&format!("Encoding File Inspector - {product}"), &style);
829
830    // Download and decompress encoding file
831    let encoding_data = download_and_decompress_manifest(&product, &region, "encoding").await?;
832
833    // Parse encoding file
834    let encoding_file = EncodingFile::parse(&encoding_data)?;
835
836    match format {
837        OutputFormat::Json | OutputFormat::JsonPretty => {
838            let json_data = serde_json::json!({
839                "version": encoding_file.header.version,
840                "ckey_count": encoding_file.ckey_count(),
841                "ekey_count": encoding_file.ekey_count(),
842                "stats": if stats {
843                    Some(serde_json::json!({
844                        "total_ckeys": encoding_file.ckey_count(),
845                        "total_ekeys": encoding_file.ekey_count(),
846                    }))
847                } else {
848                    None
849                },
850            });
851
852            let output = match format {
853                OutputFormat::JsonPretty => serde_json::to_string_pretty(&json_data)?,
854                _ => serde_json::to_string(&json_data)?,
855            };
856            println!("{output}");
857        }
858        OutputFormat::Text => {
859            print_subsection_header("Encoding File Summary", &style);
860            println!("Version: {}", encoding_file.header.version);
861            println!(
862                "CKey entries: {}",
863                format_count_badge(encoding_file.ckey_count(), "entry", &style)
864            );
865            println!(
866                "EKey mappings: {}",
867                format_count_badge(encoding_file.ekey_count(), "mapping", &style)
868            );
869
870            if let Some(search_key) = search {
871                print_subsection_header("Search Results", &style);
872                let search_bytes = hex::decode(&search_key)?;
873
874                if let Some(entry) = encoding_file.lookup_by_ckey(&search_bytes) {
875                    println!("Found CKey: {search_key}");
876                    println!("  File size: {} bytes", entry.size);
877                    if !entry.encoding_keys.is_empty() {
878                        println!("  EKeys:");
879                        for ekey in &entry.encoding_keys {
880                            println!("    - {}", hex::encode(ekey));
881                        }
882                    }
883                } else if let Some(ckey) = encoding_file.lookup_by_ekey(&search_bytes) {
884                    println!("Found EKey: {search_key}");
885                    println!("  Maps to CKey: {}", hex::encode(ckey));
886                } else {
887                    println!("Key not found: {search_key}");
888                }
889            }
890
891            if stats {
892                print_subsection_header("Statistics", &style);
893                println!("Total unique content keys: {}", encoding_file.ckey_count());
894                println!(
895                    "Total encoding key mappings: {}",
896                    encoding_file.ekey_count()
897                );
898            }
899        }
900        _ => {
901            println!("Format not supported for encoding inspection");
902        }
903    }
904
905    Ok(())
906}
907
908/// Inspect install manifest
909async fn inspect_install(
910    product: String,
911    region: String,
912    tags: Option<String>,
913    all: bool,
914    format: OutputFormat,
915) -> Result<(), Box<dyn std::error::Error>> {
916    let style = OutputStyle::new();
917    print_section_header(&format!("Install Manifest Inspector - {product}"), &style);
918
919    // Download and decompress install manifest
920    let install_data = download_and_decompress_manifest(&product, &region, "install").await?;
921
922    // Parse install manifest
923    let install_manifest = InstallManifest::parse(&install_data)?;
924
925    match format {
926        OutputFormat::Json | OutputFormat::JsonPretty => {
927            let json_data = serde_json::json!({
928                "version": install_manifest.header.version,
929                "entry_count": install_manifest.entries.len(),
930                "tag_count": install_manifest.tags.len(),
931                "tags": install_manifest.tags.iter().map(|t| &t.name).collect::<Vec<_>>(),
932            });
933
934            let output = match format {
935                OutputFormat::JsonPretty => serde_json::to_string_pretty(&json_data)?,
936                _ => serde_json::to_string(&json_data)?,
937            };
938            println!("{output}");
939        }
940        OutputFormat::Text => {
941            print_subsection_header("Install Manifest Summary", &style);
942            println!("Version: {}", install_manifest.header.version);
943            println!(
944                "Total files: {}",
945                format_count_badge(install_manifest.entries.len(), "file", &style)
946            );
947            println!(
948                "Total tags: {}",
949                format_count_badge(install_manifest.tags.len(), "tag", &style)
950            );
951
952            if !install_manifest.tags.is_empty() {
953                print_subsection_header("Available Tags", &style);
954                for tag in &install_manifest.tags {
955                    println!("  - {} (type: {})", tag.name, tag.tag_type);
956                }
957            }
958
959            if let Some(tag_filter) = tags {
960                let filter_tags: Vec<&str> = tag_filter.split(',').collect();
961                let filtered_files = install_manifest.get_files_for_tags(&filter_tags);
962
963                print_subsection_header(&format!("Files for tags: {tag_filter}"), &style);
964                println!(
965                    "Found {} files",
966                    format_count_badge(filtered_files.len(), "file", &style)
967                );
968
969                if all || filtered_files.len() <= 20 {
970                    for (i, file) in filtered_files.iter().enumerate() {
971                        if i >= 20 && !all {
972                            println!("... and {} more", filtered_files.len() - i);
973                            break;
974                        }
975                        println!("  {}", file.path);
976                    }
977                }
978            }
979        }
980        _ => {
981            println!("Format not supported for install manifest inspection");
982        }
983    }
984
985    Ok(())
986}
987
988/// Inspect download manifest
989async fn inspect_download_manifest(
990    product: String,
991    region: String,
992    priority_limit: usize,
993    tags: Option<String>,
994    format: OutputFormat,
995) -> Result<(), Box<dyn std::error::Error>> {
996    let style = OutputStyle::new();
997    print_section_header(&format!("Download Manifest Inspector - {product}"), &style);
998
999    // Download and decompress download manifest
1000    let download_data = download_and_decompress_manifest(&product, &region, "download").await?;
1001
1002    // Parse download manifest
1003    let download_manifest = DownloadManifest::parse(&download_data)?;
1004
1005    match format {
1006        OutputFormat::Json | OutputFormat::JsonPretty => {
1007            let priority_files =
1008                download_manifest.get_priority_files(priority_limit.min(127) as i8);
1009            let json_data = serde_json::json!({
1010                "version": download_manifest.header.version,
1011                "entry_count": download_manifest.entries.len(),
1012                "tag_count": download_manifest.tags.len(),
1013                "priority_files": priority_files.iter().map(|entry| {
1014                    serde_json::json!({
1015                        "ekey": hex::encode(&entry.ekey),
1016                        "priority": entry.priority,
1017                    })
1018                }).collect::<Vec<_>>(),
1019            });
1020
1021            let output = match format {
1022                OutputFormat::JsonPretty => serde_json::to_string_pretty(&json_data)?,
1023                _ => serde_json::to_string(&json_data)?,
1024            };
1025            println!("{output}");
1026        }
1027        OutputFormat::Text => {
1028            print_subsection_header("Download Manifest Summary", &style);
1029            println!("Version: {}", download_manifest.header.version);
1030            println!(
1031                "Total entries: {}",
1032                format_count_badge(download_manifest.entries.len(), "entry", &style)
1033            );
1034            println!(
1035                "Total tags: {}",
1036                format_count_badge(download_manifest.tags.len(), "tag", &style)
1037            );
1038
1039            print_subsection_header(&format!("Top {priority_limit} Priority Files"), &style);
1040            let priority_files =
1041                download_manifest.get_priority_files(priority_limit.min(127) as i8);
1042            for (i, entry) in priority_files.iter().enumerate() {
1043                println!(
1044                    "  {}. Priority {}: {}",
1045                    i + 1,
1046                    entry.priority,
1047                    hex::encode(&entry.ekey)
1048                );
1049            }
1050
1051            if let Some(tag_filter) = tags {
1052                let filter_tags: Vec<&str> = tag_filter.split(',').collect();
1053                let filtered_files = download_manifest.get_files_for_tags(&filter_tags);
1054
1055                print_subsection_header(&format!("Files for tags: {tag_filter}"), &style);
1056                println!(
1057                    "Found {} files",
1058                    format_count_badge(filtered_files.len(), "file", &style)
1059                );
1060            }
1061        }
1062        _ => {
1063            println!("Format not supported for download manifest inspection");
1064        }
1065    }
1066
1067    Ok(())
1068}
1069
1070/// Inspect size file
1071async fn inspect_size(
1072    product: String,
1073    region: String,
1074    largest: usize,
1075    tags: Option<String>,
1076    format: OutputFormat,
1077) -> Result<(), Box<dyn std::error::Error>> {
1078    let style = OutputStyle::new();
1079    print_section_header(&format!("Size File Inspector - {product}"), &style);
1080
1081    // Download and decompress size file
1082    let size_data = download_and_decompress_manifest(&product, &region, "size").await?;
1083
1084    // Parse size file
1085    let size_file = SizeFile::parse(&size_data)?;
1086
1087    match format {
1088        OutputFormat::Json | OutputFormat::JsonPretty => {
1089            let largest_files = size_file.get_largest_files(largest);
1090            let stats = size_file.get_statistics();
1091            let json_data = serde_json::json!({
1092                "version": size_file.header.version,
1093                "entry_count": size_file.entries.len(),
1094                "tag_count": size_file.tags.len(),
1095                "total_size": size_file.get_total_size(),
1096                "statistics": {
1097                    "average_size": stats.average_size,
1098                    "min_size": stats.min_size,
1099                    "max_size": stats.max_size,
1100                },
1101                "largest_files": largest_files.iter().map(|(ekey, size)| {
1102                    serde_json::json!({
1103                        "ekey": hex::encode(ekey),
1104                        "size": size,
1105                    })
1106                }).collect::<Vec<_>>(),
1107            });
1108
1109            let output = match format {
1110                OutputFormat::JsonPretty => serde_json::to_string_pretty(&json_data)?,
1111                _ => serde_json::to_string(&json_data)?,
1112            };
1113            println!("{output}");
1114        }
1115        OutputFormat::Text => {
1116            print_subsection_header("Size File Summary", &style);
1117            println!("Version: {}", size_file.header.version);
1118            println!(
1119                "Total entries: {}",
1120                format_count_badge(size_file.entries.len(), "entry", &style)
1121            );
1122            println!(
1123                "Total tags: {}",
1124                format_count_badge(size_file.tags.len(), "tag", &style)
1125            );
1126
1127            let total_size = size_file.get_total_size();
1128            println!(
1129                "Total installation size: {} GB",
1130                total_size / (1024 * 1024 * 1024)
1131            );
1132
1133            let stats = size_file.get_statistics();
1134            print_subsection_header("File Size Statistics", &style);
1135            println!(
1136                "Average file size: {} MB",
1137                stats.average_size / (1024 * 1024)
1138            );
1139            println!("Minimum file size: {} bytes", stats.min_size);
1140            println!("Maximum file size: {} MB", stats.max_size / (1024 * 1024));
1141
1142            print_subsection_header(&format!("Top {largest} Largest Files"), &style);
1143            let largest_files = size_file.get_largest_files(largest);
1144            for (i, (ekey, size)) in largest_files.iter().enumerate() {
1145                let size_mb = size / (1024 * 1024);
1146                println!("  {}. {} MB - {}", i + 1, size_mb, hex::encode(&ekey[0..8]));
1147            }
1148
1149            if let Some(tag_filter) = tags {
1150                let filter_tags: Vec<&str> = tag_filter.split(',').collect();
1151                let tag_size = size_file.get_size_for_tags(&filter_tags);
1152
1153                print_subsection_header(&format!("Size for tags: {tag_filter}"), &style);
1154                println!("Total size: {} GB", tag_size / (1024 * 1024 * 1024));
1155            }
1156        }
1157        _ => {
1158            println!("Format not supported for size file inspection");
1159        }
1160    }
1161
1162    Ok(())
1163}