High-performance implementation of plcbundle written in Rust
at main 920 lines 31 kB view raw
1// Inspect command - deep analysis of bundle contents 2use anyhow::Result; 3use chrono::DateTime; 4use clap::{Args, ValueHint}; 5use plcbundle::format::{format_bytes, format_duration_verbose, format_number}; 6use plcbundle::{BundleManager, LoadOptions, Operation}; 7use serde::Serialize; 8use sonic_rs::{JsonContainerTrait, JsonValueTrait}; 9use std::collections::HashMap; 10use std::path::PathBuf; 11 12#[derive(Args)] 13#[command( 14 about = "Deep analysis of bundle contents", 15 long_about = "Perform comprehensive analysis of a bundle's contents, structure, and patterns. 16This command provides detailed insights into what operations are stored, how DIDs 17are distributed, what handles and domains are present, and how operations are 18distributed over time. 19 20The analysis includes embedded metadata extraction (from the zstd skippable frame), 21operation type distribution, DID activity patterns (which DIDs appear most frequently), 22handle and domain statistics, service endpoint analysis, temporal distribution 23(peak hours, time spans), and detailed size analysis. 24 25You can inspect bundles either by bundle number (from the repository index) or by 26direct file path. Use --skip-patterns to speed up analysis by skipping handle and 27service pattern extraction. Use --samples to see example operations from the bundle. 28 29This command is invaluable for understanding bundle composition, identifying data 30quality issues, and analyzing patterns in the PLC directory data.", 31 help_template = crate::clap_help!( 32 examples: " # Inspect from repository\n \ 33 {bin} inspect 42\n\n \ 34 # Inspect specific file\n \ 35 {bin} inspect /path/to/000042.jsonl.zst\n \ 36 {bin} inspect 000042.jsonl.zst\n\n \ 37 # Skip certain analysis sections\n \ 38 {bin} inspect 42 --skip-patterns\n\n \ 39 # Show sample operations\n \ 40 {bin} inspect 42 --samples --sample-count 20\n\n \ 41 # JSON output (for scripting)\n \ 42 {bin} inspect 42 --json" 43 ) 44)] 45pub struct InspectCommand { 46 /// Bundle number or file path to inspect 47 #[arg(value_hint = ValueHint::AnyPath)] 48 pub target: String, 49 50 /// Output as JSON 51 #[arg(long)] 52 pub json: bool, 53 54 /// Skip embedded metadata section 55 #[arg(long)] 56 pub skip_metadata: bool, 57 58 /// Skip pattern analysis (handles, services) 59 #[arg(long)] 60 pub skip_patterns: bool, 61 62 /// Show sample operations 63 #[arg(long)] 64 pub samples: bool, 65 66 /// Number of samples to show 67 #[arg(long, default_value = "10")] 68 pub sample_count: usize, 69} 70 71#[derive(Debug, Serialize)] 72struct InspectResult { 73 // File info 74 file_path: String, 75 file_size: u64, 76 has_metadata_frame: bool, 77 78 // Embedded metadata (from skippable frame) 79 #[serde(skip_serializing_if = "Option::is_none")] 80 embedded_metadata: Option<EmbeddedMetadataInfo>, 81 82 // Index metadata (from plc_bundles.json) 83 #[serde(skip_serializing_if = "Option::is_none")] 84 index_metadata: Option<IndexMetadataInfo>, 85 86 // Basic stats 87 total_ops: usize, 88 nullified_ops: usize, 89 active_ops: usize, 90 unique_dids: usize, 91 92 // Operation types 93 operation_types: HashMap<String, usize>, 94 95 // DID patterns 96 #[serde(skip_serializing_if = "Vec::is_empty")] 97 top_dids: Vec<DIDActivity>, 98 single_op_dids: usize, 99 multi_op_dids: usize, 100 101 // Handle patterns 102 #[serde(skip_serializing_if = "Option::is_none")] 103 total_handles: Option<usize>, 104 #[serde(skip_serializing_if = "Vec::is_empty")] 105 top_domains: Vec<DomainCount>, 106 #[serde(skip_serializing_if = "Option::is_none")] 107 invalid_handles: Option<usize>, 108 109 // Service patterns 110 #[serde(skip_serializing_if = "Option::is_none")] 111 total_services: Option<usize>, 112 #[serde(skip_serializing_if = "Option::is_none")] 113 unique_endpoints: Option<usize>, 114 #[serde(skip_serializing_if = "Vec::is_empty")] 115 top_pds_endpoints: Vec<EndpointCount>, 116 117 // Temporal 118 #[serde(skip_serializing_if = "Option::is_none")] 119 time_distribution: Option<TimeDistribution>, 120 avg_ops_per_minute: f64, 121 122 // Size analysis 123 avg_op_size: usize, 124 min_op_size: usize, 125 max_op_size: usize, 126 total_op_size: u64, 127} 128 129#[derive(Debug, Serialize)] 130struct EmbeddedMetadataInfo { 131 format: String, 132 origin: String, 133 bundle_number: u32, 134 created_by: String, 135 created_at: String, 136 operation_count: usize, 137 did_count: usize, 138 frame_count: usize, 139 frame_size: usize, 140 start_time: String, 141 end_time: String, 142 content_hash: String, 143 parent_hash: Option<String>, 144 frame_offsets: Vec<i64>, 145 metadata_frame_size: Option<u64>, 146} 147 148#[derive(Debug, Serialize)] 149struct IndexMetadataInfo { 150 hash: String, 151 parent: String, 152 cursor: String, 153 compressed_hash: String, 154 compressed_size: u64, 155 uncompressed_size: u64, 156 compression_ratio: f64, 157} 158 159#[derive(Debug, Serialize)] 160struct DIDActivity { 161 did: String, 162 count: usize, 163} 164 165#[derive(Debug, Serialize)] 166struct DomainCount { 167 domain: String, 168 count: usize, 169} 170 171#[derive(Debug, Serialize)] 172struct EndpointCount { 173 endpoint: String, 174 count: usize, 175} 176 177#[derive(Debug, Serialize)] 178struct TimeDistribution { 179 earliest_op: String, 180 latest_op: String, 181 time_span: String, 182 peak_hour: String, 183 peak_hour_ops: usize, 184 total_hours: usize, 185} 186 187pub fn run(cmd: InspectCommand, dir: PathBuf) -> Result<()> { 188 let manager = super::utils::create_manager(dir.clone(), false, false, false)?; 189 190 // Resolve target to bundle number or file path 191 let (bundle_num, file_path) = super::utils::resolve_bundle_target(&manager, &cmd.target, &dir)?; 192 193 // Get file size - always use bundle metadata if available, otherwise read from filesystem 194 let file_size = if let Some(num) = bundle_num { 195 // Use bundle metadata from index (avoids direct file access per RULES.md) 196 manager 197 .get_bundle_metadata(num)? 198 .map(|meta| meta.compressed_size) 199 .unwrap_or(0) 200 } else { 201 // For arbitrary file paths, we still need filesystem access - this should be refactored 202 // to use a manager method for loading from arbitrary paths in the future if supported. 203 // For now, it will return an error as per `resolve_bundle_target`. 204 anyhow::bail!( 205 "Loading from arbitrary paths not yet implemented. Please specify a bundle number." 206 ); 207 }; 208 209 if !cmd.json { 210 eprintln!("Inspecting: {}", file_path.display()); 211 eprintln!("File size: {}\n", format_bytes(file_size)); 212 } 213 214 // Load bundle 215 let load_result = if let Some(num) = bundle_num { 216 manager.load_bundle(num, LoadOptions::default())? 217 } else { 218 // TODO: Add method to load from arbitrary path 219 anyhow::bail!("Loading from arbitrary paths not yet implemented"); 220 }; 221 222 let operations = load_result.operations; 223 224 // Analyze operations 225 let analysis = analyze_operations(&operations, &cmd)?; 226 227 // Extract metadata information using BundleManager API 228 let (embedded_metadata, index_metadata, has_metadata) = if let Some(num) = bundle_num { 229 // Get embedded metadata from skippable frame via BundleManager 230 let embedded = manager.get_embedded_metadata(num)?; 231 let index = manager.get_bundle_metadata(num)?; 232 233 let embedded_info = embedded.as_ref().map(|meta| { 234 let metadata_frame_size = meta 235 .frame_offsets 236 .last() 237 .map(|&last_offset| file_size as i64 - last_offset) 238 .filter(|&size| size > 0) 239 .map(|s| s as u64); 240 241 EmbeddedMetadataInfo { 242 format: meta.format.clone(), 243 origin: meta.origin.clone(), 244 bundle_number: meta.bundle_number, 245 created_by: meta.created_by.clone(), 246 created_at: meta.created_at.clone(), 247 operation_count: meta.operation_count, 248 did_count: meta.did_count, 249 frame_count: meta.frame_count, 250 frame_size: meta.frame_size, 251 start_time: meta.start_time.clone(), 252 end_time: meta.end_time.clone(), 253 content_hash: meta.content_hash.clone(), 254 parent_hash: meta.parent_hash.clone(), 255 frame_offsets: meta.frame_offsets.clone(), 256 metadata_frame_size, 257 } 258 }); 259 260 let index_info = index.as_ref().map(|meta| { 261 let compression_ratio = 262 (1.0 - meta.compressed_size as f64 / meta.uncompressed_size as f64) * 100.0; 263 IndexMetadataInfo { 264 hash: meta.hash.clone(), 265 parent: meta.parent.clone(), 266 cursor: meta.cursor.clone(), 267 compressed_hash: meta.compressed_hash.clone(), 268 compressed_size: meta.compressed_size, 269 uncompressed_size: meta.uncompressed_size, 270 compression_ratio, 271 } 272 }); 273 274 (embedded_info, index_info, embedded.is_some()) 275 } else { 276 (None, None, false) 277 }; 278 279 let result = InspectResult { 280 file_path: file_path.display().to_string(), 281 file_size, 282 has_metadata_frame: has_metadata, 283 embedded_metadata, 284 index_metadata, 285 total_ops: analysis.total_ops, 286 nullified_ops: analysis.nullified_ops, 287 active_ops: analysis.active_ops, 288 unique_dids: analysis.unique_dids, 289 operation_types: analysis.operation_types, 290 top_dids: analysis.top_dids, 291 single_op_dids: analysis.single_op_dids, 292 multi_op_dids: analysis.multi_op_dids, 293 total_handles: analysis.total_handles, 294 top_domains: analysis.top_domains, 295 invalid_handles: analysis.invalid_handles, 296 total_services: analysis.total_services, 297 unique_endpoints: analysis.unique_endpoints, 298 top_pds_endpoints: analysis.top_pds_endpoints, 299 time_distribution: analysis.time_distribution, 300 avg_ops_per_minute: analysis.avg_ops_per_minute, 301 avg_op_size: analysis.avg_op_size, 302 min_op_size: analysis.min_op_size, 303 max_op_size: analysis.max_op_size, 304 total_op_size: analysis.total_op_size, 305 }; 306 307 if cmd.json { 308 println!("{}", sonic_rs::to_string_pretty(&result)?); 309 } else { 310 display_human(&result, &operations, &cmd, bundle_num, &manager)?; 311 } 312 313 Ok(()) 314} 315 316#[derive(Debug)] 317struct Analysis { 318 total_ops: usize, 319 nullified_ops: usize, 320 active_ops: usize, 321 unique_dids: usize, 322 operation_types: HashMap<String, usize>, 323 top_dids: Vec<DIDActivity>, 324 single_op_dids: usize, 325 multi_op_dids: usize, 326 total_handles: Option<usize>, 327 top_domains: Vec<DomainCount>, 328 invalid_handles: Option<usize>, 329 total_services: Option<usize>, 330 unique_endpoints: Option<usize>, 331 top_pds_endpoints: Vec<EndpointCount>, 332 time_distribution: Option<TimeDistribution>, 333 avg_ops_per_minute: f64, 334 avg_op_size: usize, 335 min_op_size: usize, 336 max_op_size: usize, 337 total_op_size: u64, 338} 339 340fn analyze_operations(operations: &[Operation], cmd: &InspectCommand) -> Result<Analysis> { 341 let total_ops = operations.len(); 342 let mut nullified_ops = 0; 343 let mut did_activity: HashMap<String, usize> = HashMap::new(); 344 let mut operation_types: HashMap<String, usize> = HashMap::new(); 345 let mut domain_counts: HashMap<String, usize> = HashMap::new(); 346 let mut endpoint_counts: HashMap<String, usize> = HashMap::new(); 347 let mut total_handles = 0; 348 let mut invalid_handles = 0; 349 let mut total_services = 0; 350 let mut total_op_size = 0u64; 351 let mut min_op_size = usize::MAX; 352 let mut max_op_size = 0; 353 354 // Temporal analysis - group by minute 355 let mut time_slots: HashMap<i64, usize> = HashMap::new(); 356 357 for op in operations { 358 // Count nullified 359 if op.nullified { 360 nullified_ops += 1; 361 } 362 363 // DID activity 364 *did_activity.entry(op.did.clone()).or_insert(0) += 1; 365 366 // Operation size 367 let op_size = op.raw_json.as_ref().map(|s| s.len()).unwrap_or(0); 368 total_op_size += op_size as u64; 369 min_op_size = min_op_size.min(op_size); 370 max_op_size = max_op_size.max(op_size); 371 372 // Parse operation for detailed analysis 373 let op_val = &op.operation; 374 // Operation type 375 if let Some(op_type) = op_val.get("type").and_then(|v| v.as_str()) { 376 *operation_types.entry(op_type.to_string()).or_insert(0) += 1; 377 } 378 379 // Pattern analysis (if not skipped) 380 if !cmd.skip_patterns { 381 // Handle analysis 382 if let Some(aka) = op_val.get("alsoKnownAs").and_then(|v| v.as_array()) { 383 for item in aka.iter() { 384 if let Some(aka_str) = item.as_str() 385 && aka_str.starts_with("at://") 386 { 387 total_handles += 1; 388 389 // Extract domain 390 let handle = aka_str.strip_prefix("at://").unwrap_or(""); 391 let handle = handle.split('/').next().unwrap_or(""); 392 393 // Count domain (TLD) 394 let parts: Vec<&str> = handle.split('.').collect(); 395 if parts.len() >= 2 { 396 let domain = 397 format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]); 398 *domain_counts.entry(domain).or_insert(0) += 1; 399 } 400 401 // Check for invalid patterns 402 if handle.contains('_') { 403 invalid_handles += 1; 404 } 405 } 406 } 407 } 408 409 // Service analysis 410 if let Some(services) = op_val.get("services").and_then(|v| v.as_object()) { 411 total_services += services.len(); 412 413 // Extract PDS endpoints 414 if let Some(pds_val) = op_val.get("services").and_then(|v| v.get("atproto_pds")) 415 && let Some(_pds) = pds_val.as_object() 416 && let Some(endpoint) = pds_val.get("endpoint").and_then(|v| v.as_str()) 417 { 418 // Normalize endpoint 419 let endpoint = endpoint 420 .strip_prefix("https://") 421 .or_else(|| endpoint.strip_prefix("http://")) 422 .unwrap_or(endpoint); 423 let endpoint = endpoint.split('/').next().unwrap_or(endpoint); 424 *endpoint_counts.entry(endpoint.to_string()).or_insert(0) += 1; 425 } 426 } 427 } 428 429 // Time distribution (group by minute) 430 if let Ok(dt) = DateTime::parse_from_rfc3339(&op.created_at) { 431 let timestamp = dt.timestamp(); 432 let time_slot = timestamp / 60; // Group by minute 433 *time_slots.entry(time_slot).or_insert(0) += 1; 434 } 435 } 436 437 // Calculate derived stats 438 let active_ops = total_ops - nullified_ops; 439 let unique_dids = did_activity.len(); 440 441 // Count single vs multi-op DIDs 442 let mut single_op_dids = 0; 443 let mut multi_op_dids = 0; 444 for &count in did_activity.values() { 445 if count == 1 { 446 single_op_dids += 1; 447 } else { 448 multi_op_dids += 1; 449 } 450 } 451 452 // Top DIDs 453 let top_dids = get_top_n(&did_activity, 10); 454 455 // Top domains 456 let top_domains = if !cmd.skip_patterns { 457 get_top_domains(&domain_counts, 10) 458 } else { 459 Vec::new() 460 }; 461 462 // Top endpoints 463 let top_pds_endpoints = if !cmd.skip_patterns { 464 get_top_endpoints(&endpoint_counts, 10) 465 } else { 466 Vec::new() 467 }; 468 469 // Time distribution 470 let time_distribution = calculate_time_distribution(&time_slots, operations); 471 472 // Ops per minute 473 let avg_ops_per_minute = if operations.len() > 1 { 474 if let (Ok(first), Ok(last)) = ( 475 DateTime::parse_from_rfc3339(&operations[0].created_at), 476 DateTime::parse_from_rfc3339(&operations[operations.len() - 1].created_at), 477 ) { 478 let duration = last.signed_duration_since(first); 479 let minutes = duration.num_minutes() as f64; 480 if minutes > 0.0 { 481 operations.len() as f64 / minutes 482 } else { 483 0.0 484 } 485 } else { 486 0.0 487 } 488 } else { 489 0.0 490 }; 491 492 // Average operation size 493 let avg_op_size = if total_ops > 0 { 494 (total_op_size / total_ops as u64) as usize 495 } else { 496 0 497 }; 498 499 Ok(Analysis { 500 total_ops, 501 nullified_ops, 502 active_ops, 503 unique_dids, 504 operation_types, 505 top_dids, 506 single_op_dids, 507 multi_op_dids, 508 total_handles: if cmd.skip_patterns { 509 None 510 } else { 511 Some(total_handles) 512 }, 513 top_domains, 514 invalid_handles: if cmd.skip_patterns { 515 None 516 } else { 517 Some(invalid_handles) 518 }, 519 total_services: if cmd.skip_patterns { 520 None 521 } else { 522 Some(total_services) 523 }, 524 unique_endpoints: if cmd.skip_patterns { 525 None 526 } else { 527 Some(endpoint_counts.len()) 528 }, 529 top_pds_endpoints, 530 time_distribution, 531 avg_ops_per_minute, 532 avg_op_size, 533 min_op_size: if min_op_size == usize::MAX { 534 0 535 } else { 536 min_op_size 537 }, 538 max_op_size, 539 total_op_size, 540 }) 541} 542 543fn get_top_n(map: &HashMap<String, usize>, limit: usize) -> Vec<DIDActivity> { 544 let mut results: Vec<_> = map 545 .iter() 546 .map(|(did, &count)| DIDActivity { 547 did: did.clone(), 548 count, 549 }) 550 .collect(); 551 552 results.sort_by(|a, b| b.count.cmp(&a.count)); 553 results.truncate(limit); 554 results 555} 556 557fn get_top_domains(map: &HashMap<String, usize>, limit: usize) -> Vec<DomainCount> { 558 let mut results: Vec<_> = map 559 .iter() 560 .map(|(domain, &count)| DomainCount { 561 domain: domain.clone(), 562 count, 563 }) 564 .collect(); 565 566 results.sort_by(|a, b| b.count.cmp(&a.count)); 567 results.truncate(limit); 568 results 569} 570 571fn get_top_endpoints(map: &HashMap<String, usize>, limit: usize) -> Vec<EndpointCount> { 572 let mut results: Vec<_> = map 573 .iter() 574 .map(|(endpoint, &count)| EndpointCount { 575 endpoint: endpoint.clone(), 576 count, 577 }) 578 .collect(); 579 580 results.sort_by(|a, b| b.count.cmp(&a.count)); 581 results.truncate(limit); 582 results 583} 584 585fn calculate_time_distribution( 586 time_slots: &HashMap<i64, usize>, 587 operations: &[Operation], 588) -> Option<TimeDistribution> { 589 if time_slots.is_empty() || operations.is_empty() { 590 return None; 591 } 592 593 // Parse timestamps 594 let earliest = DateTime::parse_from_rfc3339(&operations[0].created_at).ok()?; 595 let latest = DateTime::parse_from_rfc3339(&operations[operations.len() - 1].created_at).ok()?; 596 597 // Group by hour 598 let mut hourly_slots: HashMap<i64, usize> = HashMap::new(); 599 for (&slot, &count) in time_slots { 600 let hour = (slot / 60) * 60; // Truncate to hour 601 *hourly_slots.entry(hour).or_insert(0) += count; 602 } 603 604 // Find peak hour 605 let (peak_hour, peak_count) = hourly_slots 606 .iter() 607 .max_by_key(|&(_, count)| count) 608 .map(|(&hour, &count)| (hour, count)) 609 .unwrap_or((0, 0)); 610 611 let duration = latest.signed_duration_since(earliest); 612 613 Some(TimeDistribution { 614 earliest_op: operations[0].created_at.clone(), 615 latest_op: operations[operations.len() - 1].created_at.clone(), 616 time_span: format_duration_verbose(duration), 617 peak_hour: chrono::DateTime::from_timestamp(peak_hour * 60, 0) 618 .unwrap() 619 .format("%Y-%m-%d %H:%M") 620 .to_string(), 621 peak_hour_ops: peak_count, 622 total_hours: hourly_slots.len(), 623 }) 624} 625 626fn display_human( 627 result: &InspectResult, 628 operations: &[Operation], 629 cmd: &InspectCommand, 630 _bundle_num: Option<u32>, 631 _manager: &BundleManager, 632) -> Result<()> { 633 println!(); 634 println!("═══════════════════════════════════════════════════════════════"); 635 println!(" Bundle Deep Inspection"); 636 println!("═══════════════════════════════════════════════════════════════\n"); 637 638 // File info 639 println!("📁 File Information"); 640 println!("───────────────────"); 641 println!(" Path: {}", result.file_path); 642 println!(" Has metadata frame: {}", result.has_metadata_frame); 643 644 // Show size information from index if available 645 if let Some(ref index_meta) = result.index_metadata { 646 println!("\n Size:"); 647 println!(" File size: {}", format_bytes(result.file_size)); 648 println!( 649 " Uncompressed: {}", 650 format_bytes(index_meta.uncompressed_size) 651 ); 652 println!( 653 " Compressed: {}", 654 format_bytes(index_meta.compressed_size) 655 ); 656 println!( 657 " Compression: {:.1}%", 658 index_meta.compression_ratio 659 ); 660 println!(" Compressed hash: {}", index_meta.compressed_hash); 661 } else { 662 println!(" File size: {}", format_bytes(result.file_size)); 663 } 664 println!(); 665 666 // Embedded metadata (if available and not skipped) 667 if !cmd.skip_metadata 668 && result.has_metadata_frame 669 && let Some(ref meta) = result.embedded_metadata 670 { 671 println!("📋 Embedded Metadata (Skippable Frame)"); 672 println!("───────────────────────────────────────"); 673 println!(" Format: {}", meta.format); 674 println!(" Origin: {}", meta.origin); 675 println!(" Bundle Number: {}", meta.bundle_number); 676 677 if !meta.created_by.is_empty() { 678 println!(" Created by: {}", meta.created_by); 679 } 680 println!(" Created at: {}", meta.created_at); 681 682 println!("\n Content:"); 683 println!( 684 " Operations: {}", 685 format_number(meta.operation_count) 686 ); 687 println!(" Unique DIDs: {}", format_number(meta.did_count)); 688 println!( 689 " Frames: {} × {} ops", 690 meta.frame_count, 691 format_number(meta.frame_size) 692 ); 693 println!( 694 " Timespan: {}{}", 695 meta.start_time, meta.end_time 696 ); 697 698 let duration = if let (Ok(start), Ok(end)) = ( 699 DateTime::parse_from_rfc3339(&meta.start_time), 700 DateTime::parse_from_rfc3339(&meta.end_time), 701 ) { 702 end.signed_duration_since(start) 703 } else { 704 chrono::Duration::seconds(0) 705 }; 706 println!( 707 " Duration: {}", 708 format_duration_verbose(duration) 709 ); 710 711 println!("\n Integrity:"); 712 println!(" Content hash: {}", meta.content_hash); 713 if let Some(ref parent) = meta.parent_hash 714 && !parent.is_empty() 715 { 716 println!(" Parent hash: {}", parent); 717 } 718 719 // Index metadata for chain info 720 if let Some(ref index_meta) = result.index_metadata { 721 println!("\n Chain:"); 722 println!(" Chain hash: {}", index_meta.hash); 723 if !index_meta.parent.is_empty() { 724 println!(" Parent: {}", index_meta.parent); 725 } 726 if !index_meta.cursor.is_empty() { 727 println!(" Cursor: {}", index_meta.cursor); 728 } 729 } 730 731 if !meta.frame_offsets.is_empty() { 732 println!("\n Frame Index:"); 733 println!(" {} frame offsets (embedded)", meta.frame_offsets.len()); 734 735 if let Some(metadata_size) = meta.metadata_frame_size { 736 println!(" Metadata size: {}", format_bytes(metadata_size)); 737 } 738 739 // Show compact list of first few offsets 740 if meta.frame_offsets.len() <= 10 { 741 println!(" Offsets: {:?}", meta.frame_offsets); 742 } else { 743 println!( 744 " First offsets: {:?} ... ({} more)", 745 &meta.frame_offsets[..5], 746 meta.frame_offsets.len() - 5 747 ); 748 } 749 } 750 751 println!(); 752 } 753 754 // Operations breakdown 755 println!("📊 Operations Analysis"); 756 println!("──────────────────────"); 757 println!(" Total operations: {}", format_number(result.total_ops)); 758 println!( 759 " Active: {} ({:.1}%)", 760 format_number(result.active_ops), 761 (result.active_ops as f64 / result.total_ops as f64 * 100.0) 762 ); 763 if result.nullified_ops > 0 { 764 println!( 765 " Nullified: {} ({:.1}%)", 766 format_number(result.nullified_ops), 767 (result.nullified_ops as f64 / result.total_ops as f64 * 100.0) 768 ); 769 } 770 771 if !result.operation_types.is_empty() { 772 println!("\n Operation Types:"); 773 let mut types: Vec<_> = result.operation_types.iter().collect(); 774 types.sort_by(|a, b| b.1.cmp(a.1)); 775 for (op_type, count) in types { 776 let pct = *count as f64 / result.total_ops as f64 * 100.0; 777 println!( 778 " {:<25} {} ({:.1}%)", 779 op_type, 780 format_number(*count), 781 pct 782 ); 783 } 784 } 785 println!(); 786 787 // DID patterns 788 println!("👤 DID Activity Patterns"); 789 println!("────────────────────────"); 790 println!( 791 " Unique DIDs: {}", 792 format_number(result.unique_dids) 793 ); 794 println!( 795 " Single-op DIDs: {} ({:.1}%)", 796 format_number(result.single_op_dids), 797 (result.single_op_dids as f64 / result.unique_dids as f64 * 100.0) 798 ); 799 println!( 800 " Multi-op DIDs: {} ({:.1}%)", 801 format_number(result.multi_op_dids), 802 (result.multi_op_dids as f64 / result.unique_dids as f64 * 100.0) 803 ); 804 805 if !result.top_dids.is_empty() { 806 println!("\n Most Active DIDs:"); 807 for (i, da) in result.top_dids.iter().enumerate().take(5) { 808 println!(" {}. {} ({} ops)", i + 1, da.did, da.count); 809 } 810 } 811 println!(); 812 813 // Handle patterns 814 if let Some(total_handles) = result.total_handles { 815 println!("🏷️ Handle Statistics"); 816 println!("────────────────────"); 817 println!(" Total handles: {}", format_number(total_handles)); 818 if let Some(invalid) = result.invalid_handles 819 && invalid > 0 820 { 821 println!( 822 " Invalid patterns: {} ({:.1}%)", 823 format_number(invalid), 824 (invalid as f64 / total_handles as f64 * 100.0) 825 ); 826 } 827 828 if !result.top_domains.is_empty() { 829 println!("\n Top Domains:"); 830 for dc in &result.top_domains { 831 let pct = dc.count as f64 / total_handles as f64 * 100.0; 832 println!( 833 " {:<25} {} ({:.1}%)", 834 dc.domain, 835 format_number(dc.count), 836 pct 837 ); 838 } 839 } 840 println!(); 841 } 842 843 // Service patterns 844 if let Some(total_services) = result.total_services { 845 println!("🌐 Service Endpoints"); 846 println!("────────────────────"); 847 println!(" Total services: {}", format_number(total_services)); 848 if let Some(unique) = result.unique_endpoints { 849 println!(" Unique endpoints: {}", format_number(unique)); 850 } 851 852 if !result.top_pds_endpoints.is_empty() { 853 println!("\n Top PDS Endpoints:"); 854 for ec in &result.top_pds_endpoints { 855 println!(" {:<40} {} ops", ec.endpoint, format_number(ec.count)); 856 } 857 } 858 println!(); 859 } 860 861 // Temporal analysis 862 println!("⏱️ Time Distribution"); 863 println!("───────────────────────"); 864 if let Some(ref td) = result.time_distribution { 865 println!(" Earliest operation: {}", td.earliest_op); 866 println!(" Latest operation: {}", td.latest_op); 867 println!(" Time span: {}", td.time_span); 868 println!( 869 " Peak hour: {} ({} ops)", 870 td.peak_hour, td.peak_hour_ops 871 ); 872 println!(" Total active hours: {}", td.total_hours); 873 println!(" Avg ops/minute: {:.1}", result.avg_ops_per_minute); 874 } 875 println!(); 876 877 // Size analysis 878 println!("📏 Size Analysis"); 879 println!("────────────────"); 880 println!( 881 " Total data: {}", 882 format_bytes(result.total_op_size) 883 ); 884 println!( 885 " Average per op: {}", 886 format_bytes(result.avg_op_size as u64) 887 ); 888 println!( 889 " Min operation: {}", 890 format_bytes(result.min_op_size as u64) 891 ); 892 println!( 893 " Max operation: {}\n", 894 format_bytes(result.max_op_size as u64) 895 ); 896 897 // Sample operations 898 if cmd.samples && !operations.is_empty() { 899 println!( 900 "📝 Sample Operations (first {})", 901 cmd.sample_count.min(operations.len()) 902 ); 903 println!("────────────────────────────────"); 904 for (i, op) in operations.iter().enumerate().take(cmd.sample_count) { 905 println!( 906 " [{:04}] {}", 907 i, 908 op.cid.as_ref().unwrap_or(&"<no-cid>".to_string()) 909 ); 910 println!(" DID: {}", op.did); 911 println!(" Time: {}", op.created_at); 912 if op.nullified { 913 println!(" Nullified: true"); 914 } 915 } 916 println!(); 917 } 918 919 Ok(()) 920}