forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
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}