forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
1// DID Resolution and Query commands
2use anyhow::{Context, Result};
3use clap::{Args, Subcommand, ValueHint};
4use plcbundle::BundleManager;
5use sonic_rs::{JsonContainerTrait, JsonValueTrait};
6use std::path::PathBuf;
7
8#[derive(Args)]
9#[command(
10 about = "DID operations and queries",
11 long_about = "Query and analyze DIDs stored in the bundle repository. These commands provide
12comprehensive DID functionality including resolution to W3C DID documents, operation
13history lookup, and cryptographic validation of DID operation chains.
14
15All commands require a DID index to be built for optimal performance. The index
16enables fast O(1) lookups by mapping DIDs to their bundle locations. Use 'index build'
17to create the index if it doesn't exist.
18
19The 'resolve' subcommand converts a DID (or handle) into a complete W3C DID document
20by following the operation chain and applying all operations. The 'log' subcommand
21shows all operations for a DID in chronological order. The 'audit' subcommand performs
22cryptographic validation of the entire operation chain, detecting forks and verifying
23signatures.
24
25These commands are essential for DID-based applications, identity verification,
26and understanding how DIDs evolve over time through their operation history.",
27 help_template = crate::clap_help!(
28 examples: " # Resolve DID to current document\n \
29 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe\n\n \
30 # Show DID operation log\n \
31 {bin} did log did:plc:524tuhdhh3m7li5gycdn6boe\n\n \
32 # Show complete audit log\n \
33 {bin} did history did:plc:524tuhdhh3m7li5gycdn6boe\n\n \
34 # Validate DID chain\n \
35 {bin} did validate did:plc:524tuhdhh3m7li5gycdn6boe"
36 )
37)]
38pub struct DidCommand {
39 #[command(subcommand)]
40 pub command: DIDCommands,
41}
42
43#[derive(Args)]
44#[command(
45 about = "Resolve handle to DID",
46 long_about = "Resolve an AT Protocol handle (e.g., example.bsky.social) to its
47corresponding DID using a handle resolver service.
48
49Handles are human-readable identifiers that map to DIDs, which are the
50cryptographic identifiers used in the PLC directory. This command queries
51the handle resolver to perform the lookup and displays the resulting DID.
52
53By default uses the quickdid.smokesignal.tools resolver, but you can specify
54a custom resolver URL with --handle-resolver if needed. This is useful for
55testing with different resolver implementations or when the default resolver
56is unavailable."
57)]
58pub struct HandleCommand {
59 /// Handle to resolve (e.g., tree.fail)
60 pub handle: String,
61
62 /// Handle resolver URL (defaults to quickdid.smokesignal.tools)
63 #[arg(long, value_hint = ValueHint::Url)]
64 pub handle_resolver: Option<String>,
65}
66
67#[derive(Subcommand)]
68pub enum DIDCommands {
69 /// Resolve DID to current W3C DID document
70 ///
71 /// By default, pretty-prints with colors when outputting to a terminal.
72 /// Use --raw to force raw JSON output (useful for piping).
73 /// Use -q/--query to extract a value using JMESPath.
74 #[command(
75 alias = "doc",
76 alias = "document",
77 long_about = "Resolve a DID or handle to its current W3C DID Document.\n\n\
78This command follows the chain of operations for a DID to construct the most\n\
79up-to-date version of its DID document. It supports resolving both DIDs\n\
80(e.g., did:plc:...) and handles (e.g., example.bsky.social).\n\n\
81When a handle is provided, it is first resolved to a DID using a handle resolver\n\
82service. The resulting DID is then used to construct the document.\n\n\
83The command checks the mempool for the most recent operations before falling back\n\
84to the main bundle index. This ensures that the resolved document is always\n\
85current, even if the latest operations have not yet been incorporated into a bundle.",
86 help_template = crate::clap_help!(
87 examples: " # Resolve DID to full document\n \
88 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe\n\n \
89 # Query with JMESPath\n \
90 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe -q 'id'\n \
91 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe -q 'verificationMethod[0].id'\n \
92 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe -q 'service[].id'\n\n \
93 # Force raw JSON output\n \
94 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe --raw"
95 )
96 )]
97 Resolve {
98 /// DID or handle to resolve
99 #[arg(value_name = "DID")]
100 did: Option<String>,
101
102 /// Handle resolver URL (e.g., https://quickdid.smokesignal.tools)
103 #[arg(long, value_hint = ValueHint::Url)]
104 handle_resolver: Option<String>,
105
106 /// Query DID document JSON using JMESPath expression
107 ///
108 /// Extracts a value from the DID document using JMESPath.
109 /// Examples: 'id', 'verificationMethod[0].id', 'service[].id'
110 #[arg(short = 'q', long = "query")]
111 query: Option<String>,
112
113 /// Force raw JSON output (no pretty printing, no colors)
114 ///
115 /// By default, output is pretty-printed with colors when writing to a terminal.
116 /// Use this flag to force raw JSON output, useful for piping to other tools.
117 #[arg(long = "raw")]
118 raw: bool,
119
120 /// Compare DID document with remote PLC directory
121 ///
122 /// Fetches the DID document from the remote PLC directory and compares it
123 /// with the document resolved from local bundles. Shows differences if any.
124 /// If URL is not provided, uses the repository origin from the local index.
125 #[arg(long, value_name = "URL", num_args = 0..=1, value_hint = ValueHint::Url)]
126 compare: Option<Option<String>>,
127 },
128
129 /// Show DID operation log
130 #[command(alias = "lookup", alias = "find", alias = "get", alias = "history")]
131 Log {
132 /// DID to show log for
133 did: String,
134
135 /// Output as JSON
136 #[arg(long)]
137 json: bool,
138
139 /// Output format: bundle,position,global,status,cid,created_at,nullified
140 ///
141 /// Available fields:
142 /// - bundle: bundle number
143 /// - position: position within bundle
144 /// - global/global_pos: global position (bundle * 10000 + position)
145 /// - status: operation status (✓ or ✗)
146 /// - cid: operation CID
147 /// - created_at/created/date/time: creation timestamp
148 /// - nullified: nullified flag
149 #[arg(long, default_value = "bundle,position,global,status,cid,created_at")]
150 format: String,
151
152 /// Omit header row
153 #[arg(long)]
154 no_header: bool,
155
156 /// Field separator (default: tab)
157 #[arg(long, default_value = "\t")]
158 separator: String,
159
160 /// Reverse order (show newest first)
161 #[arg(long)]
162 reverse: bool,
163 },
164
165 /// Process multiple DIDs from file or stdin (TODO)
166 Batch {
167 /// Action: lookup, resolve, export
168 #[arg(long, default_value = "lookup")]
169 action: String,
170
171 /// Number of parallel threads (0 = auto-detect)
172 #[arg(short = 'j', long, default_value = "0")]
173 threads: usize,
174
175 /// Output file
176 #[arg(short, long, value_hint = ValueHint::FilePath)]
177 output: Option<PathBuf>,
178
179 /// Read from stdin
180 #[arg(long)]
181 stdin: bool,
182 },
183
184 /// Audit DID operation chain from local bundles
185 #[command(alias = "validate")]
186 Audit {
187 /// DID to audit (e.g., did:plc:ewvi7nxzyoun6zhxrhs64oiz)
188 did: String,
189
190 /// Show verbose output including all operations
191 #[arg(short, long)]
192 verbose: bool,
193
194 /// Only show summary (no operation details)
195 #[arg(short, long)]
196 quiet: bool,
197
198 /// Show fork visualization
199 #[arg(long)]
200 fork_viz: bool,
201 },
202}
203
204pub fn run_did(cmd: DidCommand, dir: PathBuf, global_verbose: bool) -> Result<()> {
205 match cmd.command {
206 DIDCommands::Resolve {
207 did,
208 handle_resolver,
209 query,
210 raw,
211 compare,
212 } => {
213 cmd_did_resolve(
214 dir,
215 did,
216 handle_resolver,
217 global_verbose,
218 query,
219 raw,
220 compare,
221 )?;
222 }
223 DIDCommands::Log {
224 did,
225 json,
226 format,
227 no_header,
228 separator,
229 reverse,
230 } => {
231 cmd_did_lookup(
232 dir,
233 did,
234 global_verbose,
235 json,
236 format,
237 no_header,
238 separator,
239 reverse,
240 )?;
241 }
242 DIDCommands::Batch {
243 action,
244 threads,
245 output,
246 stdin,
247 } => {
248 cmd_did_batch(dir, action, threads, output, stdin)?;
249 }
250 DIDCommands::Audit {
251 did,
252 verbose,
253 quiet,
254 fork_viz,
255 } => {
256 cmd_did_validate(dir, did, verbose, quiet, fork_viz)?;
257 }
258 }
259 Ok(())
260}
261
262pub fn run_handle(cmd: HandleCommand, dir: PathBuf) -> Result<()> {
263 cmd_did_handle(dir, cmd.handle, cmd.handle_resolver)?;
264 Ok(())
265}
266
267// DID RESOLVE - Convert DID to W3C DID Document
268
269pub fn cmd_did_resolve(
270 dir: PathBuf,
271 input: Option<String>,
272 handle_resolver_url: Option<String>,
273 verbose: bool,
274 query: Option<String>,
275 raw: bool,
276 compare: Option<Option<String>>,
277) -> Result<()> {
278 let input = input.ok_or_else(|| anyhow::anyhow!("DID or handle is required"))?;
279 use plcbundle::constants;
280
281 // Use default resolver if none provided
282 let resolver_url =
283 handle_resolver_url.or_else(|| Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string()));
284
285 // Initialize manager with handle resolver and preload mempool (for resolve command)
286 let options = plcbundle::ManagerOptions {
287 handle_resolver_url: resolver_url,
288 preload_mempool: true,
289 verbose: false,
290 };
291 let manager = BundleManager::new(dir, options)?;
292
293 // Resolve handle to DID if needed
294 let (did, handle_resolve_time) = manager.resolve_handle_or_did(&input)?;
295
296 if handle_resolve_time > 0 {
297 log::info!(
298 "Resolved handle: {} → {} (in {}ms)",
299 input,
300 did,
301 handle_resolve_time
302 );
303 } else {
304 log::info!("Resolving DID: {}", did);
305 }
306
307 // Get resolve result with stats
308 let result = manager.resolve_did(&did)?;
309
310 // If compare is enabled, fetch remote document and compare
311 if let Some(maybe_url) = compare {
312 use crate::plc_client::PLCClient;
313 use std::time::Instant;
314 use tokio::runtime::Runtime;
315
316 // Use provided URL, or use repository origin, or fall back to default
317 let plc_url = match maybe_url {
318 Some(url) if !url.is_empty() => {
319 if verbose {
320 log::info!("Using provided PLC directory URL: {}", url);
321 }
322 url
323 }
324 _ => {
325 // Get origin from local repository index
326 let local_index = manager.get_index();
327 let origin = &local_index.origin;
328
329 // If origin is "local" or empty, use default PLC directory
330 if origin == "local" || origin.is_empty() {
331 if verbose {
332 log::info!(
333 "Origin is '{}', using default PLC directory: {}",
334 origin,
335 constants::DEFAULT_PLC_DIRECTORY_URL
336 );
337 }
338 constants::DEFAULT_PLC_DIRECTORY_URL.to_string()
339 } else {
340 if verbose {
341 log::info!("Using repository origin as PLC directory: {}", origin);
342 }
343 origin.clone()
344 }
345 }
346 };
347
348 eprintln!("🔍 Comparing with remote PLC directory...");
349
350 if verbose {
351 log::info!("Target PLC directory: {}", plc_url);
352 log::info!("DID to fetch: {}", did);
353 }
354
355 let fetch_start = Instant::now();
356 let rt = Runtime::new().context("Failed to create tokio runtime")?;
357 use plcbundle::resolver::DIDDocument;
358 let (remote_doc, remote_json_raw): (DIDDocument, String) = rt
359 .block_on(async {
360 let client = PLCClient::new(&plc_url).context("Failed to create PLC client")?;
361 if verbose {
362 log::info!("Created PLC client, fetching DID document...");
363 }
364 // Fetch both the parsed document and raw JSON for accurate comparison
365 let raw_json = client.fetch_did_document_raw(&did).await?;
366 let parsed_doc = client.fetch_did_document(&did).await?;
367 Ok::<(DIDDocument, String), anyhow::Error>((parsed_doc, raw_json))
368 })
369 .context("Failed to fetch DID document from remote PLC directory")?;
370 let fetch_duration = fetch_start.elapsed();
371
372 if verbose {
373 log::info!("Fetched DID document in {:?}", fetch_duration);
374 }
375
376 eprintln!("✅ Fetched remote document from {}", plc_url);
377 eprintln!();
378
379 // Compare documents and return early (don't show full document)
380 compare_did_documents(
381 &result.document,
382 &remote_doc,
383 &remote_json_raw,
384 &did,
385 &plc_url,
386 fetch_duration,
387 )?;
388 return Ok(());
389 }
390
391 // Get DID index for shard calculation (only for PLC DIDs)
392 if let Some(identifier) = did.strip_prefix("did:plc:") {
393 let shard_num = calculate_shard_for_display(identifier);
394 log::debug!(
395 "DID {} -> identifier '{}' -> shard {:02x}",
396 did,
397 identifier,
398 shard_num
399 );
400 }
401
402 if let Some(stats) = &result.shard_stats {
403 log::debug!(
404 "Shard {:02x} loaded, size: {} bytes",
405 result.shard_num,
406 stats.shard_size
407 );
408
409 let reduction = if stats.total_entries > 0 {
410 ((stats.total_entries - stats.prefix_narrowed_to) as f64 / stats.total_entries as f64)
411 * 100.0
412 } else {
413 0.0
414 };
415
416 log::debug!(
417 "Prefix index narrowed search: {} entries → {} entries ({:.1}% reduction)",
418 stats.total_entries,
419 stats.prefix_narrowed_to,
420 reduction
421 );
422 log::debug!(
423 "Binary search found {} locations after {} attempts",
424 stats.locations_found,
425 stats.binary_search_attempts
426 );
427 }
428
429 if verbose {
430 // Convert handle resolution time to Duration
431 let handle_resolve_duration = std::time::Duration::from_millis(handle_resolve_time);
432
433 if handle_resolve_time > 0 {
434 log::info!("Handle resolution: {:?}", handle_resolve_duration);
435 }
436
437 // Show mempool lookup time
438 log::info!("Mempool check: {:?}", result.mempool_time);
439
440 // Show detailed index lookup timings if available
441 if let Some(ref timings) = result.lookup_timings {
442 log::info!("");
443 log::info!("Index Lookup Breakdown:");
444 log::info!(" Extract ID: {:?}", timings.extract_identifier);
445 log::info!(" Calc shard: {:?}", timings.calculate_shard);
446 log::info!(
447 " Load shard: {:?} ({})",
448 timings.load_shard,
449 if timings.cache_hit {
450 "cache hit"
451 } else {
452 "cache miss"
453 }
454 );
455
456 // Search breakdown
457 log::info!(" Search:");
458 if let Some(ref base_time) = timings.base_search_time {
459 log::info!(" Base shard: {:?}", base_time);
460 }
461 if !timings.delta_segment_times.is_empty() {
462 let total_delta_time: std::time::Duration = timings
463 .delta_segment_times
464 .iter()
465 .map(|(_, time)| *time)
466 .sum();
467 log::info!(
468 " Delta segs: {:?} ({} segment{})",
469 total_delta_time,
470 timings.delta_segment_times.len(),
471 if timings.delta_segment_times.len() == 1 {
472 ""
473 } else {
474 "s"
475 }
476 );
477
478 // Show individual delta segments if there are multiple or if verbose
479 if timings.delta_segment_times.len() > 1 || verbose {
480 for (seg_name, seg_time) in &timings.delta_segment_times {
481 log::info!(" - {}: {:?}", seg_name, seg_time);
482 }
483 }
484 }
485 if timings.merge_time.as_nanos() > 0 {
486 log::info!(" Merge/sort: {:?}", timings.merge_time);
487 }
488 log::info!(" Search total: {:?}", timings.search);
489 log::info!(" Index total: {:?}", result.index_time);
490 } else {
491 log::info!("Index: {:?}", result.index_time);
492 }
493
494 log::info!("Load operation: {:?}", result.load_time);
495
496 // Calculate true total including handle resolution
497 // Note: result.total_time already includes mempool_time + index_time + load_time
498 let true_total = handle_resolve_duration + result.total_time;
499 let did_resolve_time = result.mempool_time + result.index_time + result.load_time;
500 log::info!(
501 "Total: {:?} (handle: {:?} + did: {:?})",
502 true_total,
503 handle_resolve_duration,
504 did_resolve_time
505 );
506 log::info!(
507 " DID resolve breakdown: mempool: {:?} + index: {:?} + load: {:?}",
508 result.mempool_time,
509 result.index_time,
510 result.load_time
511 );
512
513 // Calculate global position and display source
514 if result.bundle_number == 0 {
515 // Operation from mempool
516 let index = manager.get_index();
517 let global_pos = plcbundle::constants::mempool_position_to_global(
518 index.last_bundle,
519 result.position,
520 );
521 log::info!(
522 "Source: mempool position {} (global: {})\n",
523 result.position,
524 global_pos
525 );
526 } else {
527 // Operation from bundle
528 let global_pos = plcbundle::constants::bundle_position_to_global(
529 result.bundle_number,
530 result.position,
531 );
532 log::info!(
533 "Source: bundle {}, position {} (global: {})\n",
534 result.bundle_number,
535 result.position,
536 global_pos
537 );
538 }
539 }
540
541 // Convert document to JSON string
542 let document_json = sonic_rs::to_string(&result.document)?;
543
544 // If query is provided, apply JMESPath query
545 let output_json = if let Some(query_expr) = query {
546 // Compile JMESPath expression
547 let expr = jmespath::compile(&query_expr).map_err(|e| {
548 anyhow::anyhow!("Failed to compile JMESPath query '{}': {}", query_expr, e)
549 })?;
550
551 // Execute query
552 let data = jmespath::Variable::from_json(&document_json)
553 .map_err(|e| anyhow::anyhow!("Failed to parse DID document JSON: {}", e))?;
554
555 let result = expr
556 .search(&data)
557 .map_err(|e| anyhow::anyhow!("JMESPath query failed: {}", e))?;
558
559 if result.is_null() {
560 anyhow::bail!("Query '{}' returned null (field not found)", query_expr);
561 }
562
563 // Convert result to JSON string
564 // Note: jmespath uses serde_json internally, so we use serde_json here (not bundle/operation data)
565 if result.is_string() {
566 result.as_string().unwrap().to_string()
567 } else {
568 serde_json::to_string(&*result)
569 .map_err(|e| anyhow::anyhow!("Failed to serialize query result: {}", e))?
570 }
571 } else {
572 document_json
573 };
574
575 // Output document (always to stdout)
576 // Determine if we should pretty print:
577 // - Pretty print if stdout is a TTY (interactive terminal) and --raw is not set
578 // - Use raw output if --raw is set or if output is piped (not a TTY)
579 #[cfg(feature = "cli")]
580 let should_pretty = !raw && super::utils::is_stdout_tty();
581 #[cfg(not(feature = "cli"))]
582 let should_pretty = !raw;
583
584 if should_pretty {
585 // Try to parse and pretty print the result
586 match sonic_rs::from_str::<sonic_rs::Value>(&output_json) {
587 Ok(parsed) => {
588 let pretty_json = sonic_rs::to_string_pretty(&parsed)?;
589 #[cfg(feature = "cli")]
590 {
591 println!("{}", super::utils::colorize_json(&pretty_json));
592 }
593 #[cfg(not(feature = "cli"))]
594 {
595 println!("{}", pretty_json);
596 }
597 }
598 Err(_) => {
599 // If it's not valid JSON (e.g., a string result), just output as-is
600 println!("{}", output_json);
601 }
602 }
603 } else {
604 // Raw JSON output (compact, no colors)
605 println!("{}", output_json);
606 }
607
608 Ok(())
609}
610
611/// Compare two DID documents and show differences
612fn compare_did_documents(
613 local: &plcbundle::resolver::DIDDocument,
614 _remote: &plcbundle::resolver::DIDDocument,
615 remote_json_raw: &str,
616 _did: &str,
617 remote_url: &str,
618 fetch_duration: std::time::Duration,
619) -> Result<()> {
620 use sha2::{Digest, Sha256};
621
622 eprintln!("📊 Document Comparison");
623 eprintln!("═══════════════════════");
624
625 // Construct the full URL that was fetched
626 let full_url = format!("{}/{}", remote_url.trim_end_matches('/'), _did);
627 eprintln!(" Remote URL: {}", full_url);
628 eprintln!(" Fetch time: {:?}", fetch_duration);
629 eprintln!();
630
631 // Serialize local document (respects skip_serializing_if attributes)
632 let local_json = serde_json::to_string(local)?;
633
634 // Normalize both JSON strings by parsing and re-serializing with consistent formatting
635 // This ensures we compare equivalent JSON structures, handling:
636 // - Key ordering differences
637 // - Whitespace differences
638 // - Empty array representation (preserved from raw remote JSON)
639 let local_normalized = normalize_json_for_comparison(&local_json)?;
640 let remote_normalized = normalize_json_for_comparison(remote_json_raw)?;
641
642 // Calculate SHA256 hashes of normalized JSON
643 let mut local_hasher = Sha256::new();
644 local_hasher.update(local_normalized.as_bytes());
645 let local_hash = local_hasher.finalize();
646 let local_hash_hex = format!("{:x}", local_hash);
647
648 let mut remote_hasher = Sha256::new();
649 remote_hasher.update(remote_normalized.as_bytes());
650 let remote_hash = remote_hasher.finalize();
651 let remote_hash_hex = format!("{:x}", remote_hash);
652
653 eprintln!(" Local hash: {}", local_hash_hex);
654 eprintln!(" Remote hash: {}", remote_hash_hex);
655 eprintln!();
656
657 // Compare hashes
658 if local_hash == remote_hash {
659 eprintln!("✅ Documents are identical (SHA256 hashes match)");
660 eprintln!();
661 return Ok(());
662 }
663
664 eprintln!("⚠️ Documents differ (SHA256 hashes do not match)");
665 eprintln!();
666
667 #[cfg(feature = "similar")]
668 {
669 use similar::{ChangeTag, TextDiff};
670
671 // Convert normalized JSON to pretty format for diff display
672 let local_json_pretty = json_to_pretty(&local_normalized)?;
673 let remote_json_pretty = json_to_pretty(&remote_normalized)?;
674
675 // Use similar to compute and display colored diff
676 let diff = TextDiff::from_lines(&local_json_pretty, &remote_json_pretty);
677
678 eprintln!("Diff (Local → Remote):");
679 eprintln!("──────────────────────");
680
681 for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
682 if idx > 0 {
683 eprintln!("...");
684 }
685 for op in group {
686 for change in diff.iter_changes(op) {
687 use super::utils::colors;
688 let (sign, style) = match change.tag() {
689 ChangeTag::Delete => ("-", colors::RED),
690 ChangeTag::Insert => ("+", colors::GREEN),
691 ChangeTag::Equal => (" ", colors::DIM),
692 };
693 // Color the whole line
694 eprint!("{}{}{}{}", style, sign, change.value(), colors::RESET);
695 }
696 }
697 }
698
699 eprintln!();
700 use super::utils::colors;
701 eprintln!(
702 "Legend: {}Local{} {}Remote{}",
703 colors::RED,
704 colors::RESET,
705 colors::GREEN,
706 colors::RESET
707 );
708 eprintln!();
709 }
710
711 #[cfg(not(feature = "similar"))]
712 {
713 eprintln!("💡 Tip: Enable 'similar' feature for colored diff output");
714 eprintln!();
715 }
716
717 Ok(())
718}
719
720/// Normalize JSON string for comparison by parsing and re-serializing
721/// This ensures consistent representation for hash comparison
722fn normalize_json_for_comparison(json: &str) -> Result<String> {
723 // Parse JSON using sonic_rs (faster than serde_json)
724 let value: sonic_rs::Value = sonic_rs::from_str(json)
725 .map_err(|e| anyhow::anyhow!("Failed to parse JSON for normalization: {}", e))?;
726
727 // Re-serialize with consistent formatting (compact, no whitespace)
728 // This normalizes key ordering and whitespace differences
729 let normalized = sonic_rs::to_string(&value)
730 .map_err(|e| anyhow::anyhow!("Failed to serialize normalized JSON: {}", e))?;
731
732 Ok(normalized)
733}
734
735/// Convert JSON string to pretty-printed format for display
736fn json_to_pretty(json: &str) -> Result<String> {
737 let value: sonic_rs::Value = sonic_rs::from_str(json)
738 .map_err(|e| anyhow::anyhow!("Failed to parse JSON for pretty printing: {}", e))?;
739
740 let pretty = sonic_rs::to_string_pretty(&value)
741 .map_err(|e| anyhow::anyhow!("Failed to serialize pretty JSON: {}", e))?;
742
743 Ok(pretty)
744}
745
746fn calculate_shard_for_display(identifier: &str) -> u8 {
747 use fnv::FnvHasher;
748 use std::hash::Hasher;
749
750 let mut hasher = FnvHasher::default();
751 hasher.write(identifier.as_bytes());
752 let hash = hasher.finish() as u32;
753 (hash % 256) as u8
754}
755
756// DID HANDLE - Resolve handle to DID
757
758pub fn cmd_did_handle(
759 dir: PathBuf,
760 handle: String,
761 handle_resolver_url: Option<String>,
762) -> Result<()> {
763 use plcbundle::constants;
764
765 // Use default resolver if none provided
766 let resolver_url =
767 handle_resolver_url.or_else(|| Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string()));
768
769 // Initialize manager with handle resolver and preload mempool (for resolve command)
770 let options = plcbundle::ManagerOptions {
771 handle_resolver_url: resolver_url,
772 preload_mempool: true,
773 verbose: false,
774 };
775 let manager = BundleManager::new(dir, options)?;
776
777 // Resolve handle to DID
778 let (did, resolve_time) = manager.resolve_handle_or_did(&handle)?;
779
780 if resolve_time > 0 {
781 println!("{}", did);
782 } else {
783 // If it was already a DID, just print it
784 println!("{}", did);
785 }
786
787 Ok(())
788}
789
790// DID LOOKUP - Find all operations for a DID
791
792#[allow(clippy::too_many_arguments)]
793pub fn cmd_did_lookup(
794 dir: PathBuf,
795 input: String,
796 verbose: bool,
797 json: bool,
798 format: String,
799 no_header: bool,
800 separator: String,
801 reverse: bool,
802) -> Result<()> {
803 use plcbundle::constants;
804 use std::time::Instant;
805
806 // Use default resolver if none provided (same pattern as cmd_did_resolve)
807 let resolver_url = Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string());
808
809 // Initialize manager with handle resolver and preload mempool (for resolve command)
810 let options = plcbundle::ManagerOptions {
811 handle_resolver_url: resolver_url,
812 preload_mempool: true,
813 verbose: false,
814 };
815 let manager = BundleManager::new(dir, options)?;
816
817 // Resolve handle to DID if needed
818 let (did, handle_resolve_time) = manager.resolve_handle_or_did(&input)?;
819
820 if handle_resolve_time > 0 {
821 log::info!(
822 "Resolved handle: {} → {} (in {}ms)",
823 input,
824 did,
825 handle_resolve_time
826 );
827 } else {
828 log::info!("Looking up DID: {}", did);
829 }
830
831 // Get DID index for shard calculation (only for PLC DIDs)
832 if let Some(identifier) = did.strip_prefix("did:plc:") {
833 let shard_num = calculate_shard_for_display(identifier);
834 log::debug!(
835 "DID {} -> identifier '{}' -> shard {:02x}",
836 did,
837 identifier,
838 shard_num
839 );
840 }
841
842 let total_start = Instant::now();
843
844 // Lookup operations with locations and stats (for verbose mode)
845 let result = manager.get_did_operations(&did, true, verbose)?;
846 let ops_with_loc = result.operations_with_locations.unwrap_or_default();
847 let shard_stats = result.stats.unwrap_or_default();
848 let shard_num = result.shard_num.unwrap_or(0);
849 let lookup_timings = result.lookup_timings.unwrap_or_default();
850 let load_time = result.load_time.unwrap_or_default();
851
852 let index_time = lookup_timings.extract_identifier
853 + lookup_timings.calculate_shard
854 + lookup_timings.load_shard
855 + lookup_timings.search;
856 let lookup_elapsed = index_time + load_time;
857
858 // Show shard stats if available
859 if verbose && shard_stats.total_entries > 0 {
860 log::debug!(
861 "Shard {:02x} loaded, size: {} bytes",
862 shard_num,
863 shard_stats.shard_size
864 );
865
866 let reduction = if shard_stats.total_entries > 0 {
867 ((shard_stats.total_entries - shard_stats.prefix_narrowed_to) as f64
868 / shard_stats.total_entries as f64)
869 * 100.0
870 } else {
871 0.0
872 };
873
874 log::debug!(
875 "Prefix index narrowed search: {} entries → {} entries ({:.1}% reduction)",
876 shard_stats.total_entries,
877 shard_stats.prefix_narrowed_to,
878 reduction
879 );
880 log::debug!(
881 "Binary search found {} locations after {} attempts",
882 shard_stats.locations_found,
883 shard_stats.binary_search_attempts
884 );
885 }
886
887 let total_elapsed = total_start.elapsed();
888
889 // Show verbose timing breakdown
890 if verbose {
891 // Convert handle resolution time to Duration
892 let handle_resolve_duration = std::time::Duration::from_millis(handle_resolve_time);
893
894 if handle_resolve_time > 0 {
895 log::info!("Handle resolution: {:?}", handle_resolve_duration);
896 }
897
898 // Show detailed index lookup timings
899 log::info!("Index Lookup Breakdown:");
900 log::info!(" Extract ID: {:?}", lookup_timings.extract_identifier);
901 log::info!(" Calc shard: {:?}", lookup_timings.calculate_shard);
902 log::info!(
903 " Load shard: {:?} ({})",
904 lookup_timings.load_shard,
905 if lookup_timings.cache_hit {
906 "cache hit"
907 } else {
908 "cache miss"
909 }
910 );
911
912 // Search breakdown
913 log::info!(" Search:");
914 if let Some(ref base_time) = lookup_timings.base_search_time {
915 log::info!(" Base shard: {:?}", base_time);
916 }
917 if !lookup_timings.delta_segment_times.is_empty() {
918 let total_delta_time: std::time::Duration = lookup_timings
919 .delta_segment_times
920 .iter()
921 .map(|(_, time)| *time)
922 .sum();
923 log::info!(
924 " Delta segs: {:?} ({} segment{})",
925 total_delta_time,
926 lookup_timings.delta_segment_times.len(),
927 if lookup_timings.delta_segment_times.len() == 1 {
928 ""
929 } else {
930 "s"
931 }
932 );
933
934 // Show individual delta segments if there are multiple
935 if lookup_timings.delta_segment_times.len() > 1 {
936 for (seg_name, seg_time) in &lookup_timings.delta_segment_times {
937 log::info!(" - {}: {:?}", seg_name, seg_time);
938 }
939 }
940 }
941 if lookup_timings.merge_time.as_nanos() > 0 {
942 log::info!(" Merge/sort: {:?}", lookup_timings.merge_time);
943 }
944 log::info!(" Search total: {:?}", lookup_timings.search);
945 log::info!(" Index total: {:?}", index_time);
946 log::info!(
947 " Load operations: {:?} ({} operations)",
948 load_time,
949 ops_with_loc.len()
950 );
951 // Calculate true total including handle resolution
952 let true_total = handle_resolve_duration + total_elapsed;
953 log::info!(
954 "Total: {:?} (handle: {:?} + lookup: {:?})",
955 true_total,
956 handle_resolve_duration,
957 total_elapsed
958 );
959 log::info!("");
960 }
961
962 // Separate bundled and mempool operations (mempool ops have bundle=0)
963 let mut bundled_ops = Vec::new();
964 let mut mempool_ops = Vec::new();
965 for owl in ops_with_loc.iter() {
966 if owl.bundle != 0 {
967 bundled_ops.push(owl.clone());
968 } else {
969 mempool_ops.push(&owl.operation);
970 }
971 }
972
973 if bundled_ops.is_empty() && mempool_ops.is_empty() {
974 if json {
975 println!("{{\"found\": false, \"operations\": []}}");
976 } else {
977 println!("DID not found (searched in {:?})", total_elapsed);
978 }
979 return Ok(());
980 }
981
982 if json {
983 return output_lookup_json(
984 &did,
985 &bundled_ops,
986 &mempool_ops,
987 total_elapsed,
988 lookup_elapsed,
989 reverse,
990 );
991 }
992
993 display_lookup_results(
994 &did,
995 &bundled_ops,
996 &mempool_ops,
997 total_elapsed,
998 lookup_elapsed,
999 verbose,
1000 &format,
1001 no_header,
1002 &separator,
1003 reverse,
1004 )
1005}
1006
1007fn output_lookup_json(
1008 did: &str,
1009 bundled_ops: &[plcbundle::OperationWithLocation],
1010 mempool_ops: &[&plcbundle::Operation],
1011 total_elapsed: std::time::Duration,
1012 lookup_elapsed: std::time::Duration,
1013 reverse: bool,
1014) -> Result<()> {
1015 use serde_json::json;
1016
1017 let mut bundled = Vec::new();
1018 for owl in bundled_ops {
1019 bundled.push(json!({
1020 "bundle": owl.bundle,
1021 "position": owl.position,
1022 "cid": owl.operation.cid,
1023 "nullified": owl.nullified,
1024 "created_at": owl.operation.created_at,
1025 }));
1026 }
1027
1028 let mut mempool = Vec::new();
1029 for op in mempool_ops {
1030 mempool.push(json!({
1031 "cid": op.cid,
1032 "nullified": op.nullified,
1033 "created_at": op.created_at,
1034 }));
1035 }
1036
1037 if reverse {
1038 bundled.reverse();
1039 mempool.reverse();
1040 }
1041
1042 let output = json!({
1043 "found": true,
1044 "did": did,
1045 "timing": {
1046 "total_ms": total_elapsed.as_millis(),
1047 "lookup_ms": lookup_elapsed.as_millis(),
1048 },
1049 "bundled": bundled,
1050 "mempool": mempool,
1051 });
1052
1053 println!("{}", sonic_rs::to_string_pretty(&output)?);
1054 Ok(())
1055}
1056
1057#[allow(clippy::too_many_arguments)]
1058fn display_lookup_results(
1059 did: &str,
1060 bundled_ops: &[plcbundle::OperationWithLocation],
1061 mempool_ops: &[&plcbundle::Operation],
1062 _total_elapsed: std::time::Duration,
1063 _lookup_elapsed: std::time::Duration,
1064 verbose: bool,
1065 format: &str,
1066 no_header: bool,
1067 separator: &str,
1068 reverse: bool,
1069) -> Result<()> {
1070 let nullified_count = bundled_ops.iter().filter(|owl| owl.nullified).count();
1071 let total_ops = bundled_ops.len() + mempool_ops.len();
1072 let active_ops = bundled_ops.len() - nullified_count + mempool_ops.len();
1073
1074 // Parse format string
1075 let fields = parse_format_string(format);
1076
1077 // Print summary header (unless no_header is set, but we always show the DID summary)
1078 if !no_header {
1079 println!("DID: {} ({} ops, {} active)", did, total_ops, active_ops);
1080 }
1081
1082 if fields.is_empty() {
1083 return Ok(());
1084 }
1085
1086 // Calculate column widths for alignment
1087 let mut column_widths = vec![0; fields.len()];
1088
1089 // Check header widths
1090 for (i, field) in fields.iter().enumerate() {
1091 let header = get_lookup_field_header(field);
1092 column_widths[i] = column_widths[i].max(header.len());
1093 }
1094
1095 // Check data widths
1096 for owl in bundled_ops.iter() {
1097 for (i, field) in fields.iter().enumerate() {
1098 let value = get_lookup_field_value(owl, None, field);
1099 column_widths[i] = column_widths[i].max(value.len());
1100 }
1101 }
1102
1103 for op in mempool_ops.iter() {
1104 for (i, field) in fields.iter().enumerate() {
1105 let value = get_lookup_field_value_mempool(op, field);
1106 column_widths[i] = column_widths[i].max(value.len());
1107 }
1108 }
1109
1110 // Print column header (unless disabled)
1111 if !no_header {
1112 let headers: Vec<String> = fields
1113 .iter()
1114 .enumerate()
1115 .map(|(i, f)| {
1116 let header = get_lookup_field_header(f);
1117 if separator == "\t" {
1118 // For tabs, pad with spaces for alignment
1119 format!("{:<width$}", header, width = column_widths[i])
1120 } else {
1121 header
1122 }
1123 })
1124 .collect();
1125 println!("{}", headers.join(separator));
1126 }
1127
1128 // Show operations - one per line
1129 let bundled_iter: Box<dyn Iterator<Item = &plcbundle::OperationWithLocation>> = if reverse {
1130 Box::new(bundled_ops.iter().rev())
1131 } else {
1132 Box::new(bundled_ops.iter())
1133 };
1134
1135 for owl in bundled_iter {
1136 let values: Vec<String> = fields
1137 .iter()
1138 .enumerate()
1139 .map(|(i, f)| {
1140 let value = get_lookup_field_value(owl, None, f);
1141 if separator == "\t" {
1142 // For tabs, pad with spaces for alignment
1143 format!("{:<width$}", value, width = column_widths[i])
1144 } else {
1145 value
1146 }
1147 })
1148 .collect();
1149 println!("{}", values.join(separator));
1150
1151 if verbose && !owl.nullified {
1152 let op_val = &owl.operation.operation;
1153 if let Some(op_type) = op_val.get("type").and_then(|v| v.as_str()) {
1154 eprintln!(" type: {}", op_type);
1155 }
1156 if let Some(handle) = op_val.get("handle").and_then(|v| v.as_str()) {
1157 eprintln!(" handle: {}", handle);
1158 } else if let Some(aka) = op_val.get("alsoKnownAs").and_then(|v| v.as_array())
1159 && let Some(aka_str) = aka.first().and_then(|v| v.as_str())
1160 {
1161 let handle = aka_str.strip_prefix("at://").unwrap_or(aka_str);
1162 eprintln!(" handle: {}", handle);
1163 }
1164 }
1165 }
1166
1167 // Show mempool operations
1168 let mempool_iter: Box<dyn Iterator<Item = &&plcbundle::Operation>> = if reverse {
1169 Box::new(mempool_ops.iter().rev())
1170 } else {
1171 Box::new(mempool_ops.iter())
1172 };
1173
1174 for op in mempool_iter {
1175 let values: Vec<String> = fields
1176 .iter()
1177 .enumerate()
1178 .map(|(i, f)| {
1179 let value = get_lookup_field_value_mempool(op, f);
1180 if separator == "\t" {
1181 // For tabs, pad with spaces for alignment
1182 format!("{:<width$}", value, width = column_widths[i])
1183 } else {
1184 value
1185 }
1186 })
1187 .collect();
1188 println!("{}", values.join(separator));
1189 }
1190
1191 Ok(())
1192}
1193
1194fn parse_format_string(format: &str) -> Vec<String> {
1195 format
1196 .split(',')
1197 .map(|s| s.trim().to_string())
1198 .filter(|s| !s.is_empty())
1199 .collect()
1200}
1201
1202fn get_lookup_field_header(field: &str) -> String {
1203 match field {
1204 "bundle" => "bundle",
1205 "position" | "pos" => "position",
1206 "global" | "global_pos" => "global",
1207 "status" => "status",
1208 "cid" => "cid",
1209 "created_at" | "created" | "date" | "time" => "created_at",
1210 "nullified" => "nullified",
1211 "date_short" => "date",
1212 "timestamp" | "unix" => "timestamp",
1213 _ => field,
1214 }
1215 .to_string()
1216}
1217
1218fn get_lookup_field_value(
1219 owl: &plcbundle::OperationWithLocation,
1220 _mempool: Option<&plcbundle::Operation>,
1221 field: &str,
1222) -> String {
1223 match field {
1224 "bundle" => format!("{}", owl.bundle),
1225 "position" | "pos" => format!("{:04}", owl.position),
1226 "global" | "global_pos" => {
1227 let global_pos =
1228 plcbundle::constants::bundle_position_to_global(owl.bundle, owl.position);
1229 format!("{}", global_pos)
1230 }
1231 "status" => {
1232 if owl.nullified {
1233 "✗".to_string()
1234 } else {
1235 "✓".to_string()
1236 }
1237 }
1238 "cid" => owl.operation.cid.clone().unwrap_or_default(),
1239 "created_at" | "created" | "date" | "time" => owl.operation.created_at.clone(),
1240 "nullified" => {
1241 if owl.nullified {
1242 "true".to_string()
1243 } else {
1244 "false".to_string()
1245 }
1246 }
1247 "date_short" => {
1248 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&owl.operation.created_at) {
1249 dt.format("%Y-%m-%d").to_string()
1250 } else {
1251 owl.operation.created_at.clone()
1252 }
1253 }
1254 "timestamp" | "unix" => {
1255 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&owl.operation.created_at) {
1256 format!("{}", dt.timestamp())
1257 } else {
1258 "0".to_string()
1259 }
1260 }
1261 _ => String::new(),
1262 }
1263}
1264
1265fn get_lookup_field_value_mempool(op: &plcbundle::Operation, field: &str) -> String {
1266 match field {
1267 "bundle" => "mempool".to_string(),
1268 "position" | "pos" => "".to_string(),
1269 "global" | "global_pos" => "".to_string(),
1270 "status" => "✓".to_string(),
1271 "cid" => op.cid.clone().unwrap_or_default(),
1272 "created_at" | "created" | "date" | "time" => op.created_at.clone(),
1273 "nullified" => "false".to_string(),
1274 "date_short" => {
1275 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&op.created_at) {
1276 dt.format("%Y-%m-%d").to_string()
1277 } else {
1278 op.created_at.clone()
1279 }
1280 }
1281 "timestamp" | "unix" => {
1282 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&op.created_at) {
1283 format!("{}", dt.timestamp())
1284 } else {
1285 "0".to_string()
1286 }
1287 }
1288 _ => String::new(),
1289 }
1290}
1291
1292// DID BATCH - Process multiple DIDs (TODO)
1293
1294pub fn cmd_did_batch(
1295 _dir: PathBuf,
1296 _action: String,
1297 _threads: usize,
1298 _output: Option<PathBuf>,
1299 _from_stdin: bool,
1300) -> Result<()> {
1301 log::error!("`did batch` not yet implemented");
1302 Ok(())
1303}
1304
1305// DID VALIDATE - Validate DID audit log from plc.directory
1306//
1307// This implementation is based on the atproto-plc library examples:
1308// https://docs.rs/atproto-plc/0.2.0/atproto_plc/index.html
1309// Library author: ngerakines
1310// License: MIT OR Apache-2.0
1311
1312use atproto_plc::{Operation, OperationChainValidator, PlcState, VerifyingKey};
1313use serde::Deserialize;
1314use std::collections::HashMap;
1315
1316/// Audit log response from plc.directory
1317#[derive(Debug, Deserialize, Clone)]
1318struct AuditLogEntry {
1319 /// The DID this operation is for
1320 #[allow(dead_code)]
1321 did: String,
1322
1323 /// The operation itself
1324 operation: Operation,
1325
1326 /// CID of this operation
1327 cid: String,
1328
1329 /// Timestamp when this operation was created
1330 #[serde(rename = "createdAt")]
1331 created_at: String,
1332
1333 /// Nullified flag (if this operation was invalidated)
1334 #[serde(default)]
1335 nullified: bool,
1336}
1337
1338/// Represents a fork point in the operation chain
1339#[derive(Debug, Clone)]
1340struct ForkPoint {
1341 /// The prev CID that all operations in this fork reference
1342 prev_cid: String,
1343
1344 /// Competing operations at this fork
1345 operations: Vec<ForkOperation>,
1346
1347 /// The winning operation (after fork resolution)
1348 #[allow(dead_code)]
1349 winner_cid: String,
1350}
1351
1352/// An operation that's part of a fork
1353#[derive(Debug, Clone)]
1354struct ForkOperation {
1355 cid: String,
1356 #[allow(dead_code)]
1357 operation: Operation,
1358 timestamp: chrono::DateTime<chrono::Utc>,
1359 signing_key_index: Option<usize>,
1360 signing_key: Option<String>,
1361 is_winner: bool,
1362 rejection_reason: Option<String>,
1363}
1364
1365pub fn cmd_did_validate(
1366 dir: PathBuf,
1367 did_input: String,
1368 verbose: bool,
1369 quiet: bool,
1370 fork_viz: bool,
1371) -> Result<()> {
1372 use plcbundle::constants;
1373
1374 // Initialize manager
1375 let resolver_url = Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string());
1376 let options = plcbundle::ManagerOptions {
1377 handle_resolver_url: resolver_url,
1378 preload_mempool: false,
1379 verbose: false,
1380 };
1381 let manager = BundleManager::new(dir, options)?;
1382
1383 // Resolve handle to DID if needed (same pattern as other did commands)
1384 let (did_str, handle_resolve_time) = manager.resolve_handle_or_did(&did_input)?;
1385
1386 if handle_resolve_time > 0 {
1387 log::info!(
1388 "Resolved handle: {} → {} (in {}ms)",
1389 did_input,
1390 did_str,
1391 handle_resolve_time
1392 );
1393 } else {
1394 log::info!("Validating DID: {}", did_str);
1395 }
1396
1397 // Validate DID format for atproto_plc library (must be did:plc)
1398 if !did_str.starts_with("did:plc:") {
1399 eprintln!("❌ Error: Validation only supports did:plc identifiers");
1400 eprintln!(" Got: {}", did_str);
1401 return Err(anyhow::anyhow!("Only did:plc identifiers can be validated"));
1402 }
1403
1404 if !quiet {
1405 println!("🔍 Fetching audit log for: {}", did_str);
1406 println!(" Source: local bundles");
1407 println!();
1408 }
1409
1410 // Fetch operations from local bundles and mempool
1411 let result = manager.get_did_operations(&did_str, true, false)?;
1412 let ops_with_loc = result.operations_with_locations.unwrap_or_default();
1413
1414 if ops_with_loc.is_empty() {
1415 eprintln!("❌ Error: No operations found for DID: {}", did_str);
1416 return Err(anyhow::anyhow!("No operations found"));
1417 }
1418
1419 // Convert to audit log format
1420 let audit_log = convert_to_audit_log(ops_with_loc)?;
1421
1422 if audit_log.is_empty() {
1423 eprintln!("❌ Error: No operations found in audit log");
1424 return Err(anyhow::anyhow!("No operations found"));
1425 }
1426
1427 if !quiet {
1428 println!("📊 Audit Log Summary:");
1429 println!(" Total operations: {}", audit_log.len());
1430 println!(" Genesis operation: {}", audit_log[0].cid);
1431 println!(" Latest operation: {}", audit_log.last().unwrap().cid);
1432 println!();
1433 }
1434
1435 // If fork visualization is requested, show that instead
1436 if fork_viz {
1437 return visualize_forks(&audit_log, &did_str, verbose);
1438 }
1439
1440 // Display operations if verbose
1441 if verbose {
1442 println!("📋 Operations:");
1443 for (i, entry) in audit_log.iter().enumerate() {
1444 let status = if entry.nullified {
1445 "❌ NULLIFIED"
1446 } else {
1447 "✅"
1448 };
1449 println!(" [{}] {} {} - {}", i, status, entry.cid, entry.created_at);
1450 if entry.operation.is_genesis() {
1451 println!(" Type: Genesis (creates the DID)");
1452 } else {
1453 println!(" Type: Update");
1454 }
1455 if let Some(prev) = entry.operation.prev() {
1456 println!(" Previous: {}", prev);
1457 }
1458 }
1459 println!();
1460 }
1461
1462 // Detect forks and build canonical chain
1463 if !quiet {
1464 println!("🔐 Analyzing operation chain...");
1465 println!();
1466 }
1467
1468 // Detect fork points and nullified operations
1469 let has_forks = detect_forks(&audit_log);
1470 let has_nullified = audit_log.iter().any(|e| e.nullified);
1471
1472 if has_forks || has_nullified {
1473 if !quiet {
1474 if has_forks {
1475 println!("⚠️ Fork detected - multiple operations reference the same prev CID");
1476 }
1477 if has_nullified {
1478 println!("⚠️ Nullified operations detected - will validate canonical chain only");
1479 }
1480 println!();
1481 }
1482
1483 // Use fork resolution to build canonical chain
1484 if verbose {
1485 println!("Step 1: Fork Resolution & Canonical Chain Building");
1486 println!("===================================================");
1487 }
1488
1489 // Build operations and timestamps for fork resolution
1490 let operations: Vec<_> = audit_log.iter().map(|e| e.operation.clone()).collect();
1491 let timestamps: Vec<_> = audit_log
1492 .iter()
1493 .map(|e| {
1494 e.created_at
1495 .parse::<chrono::DateTime<chrono::Utc>>()
1496 .unwrap_or_else(|_| chrono::Utc::now())
1497 })
1498 .collect();
1499
1500 // Resolve forks and get canonical chain
1501 match OperationChainValidator::validate_chain_with_forks(&operations, ×tamps) {
1502 Ok(final_state) => {
1503 if verbose {
1504 println!(" ✅ Fork resolution complete");
1505 println!(" ✅ Canonical chain validated successfully");
1506 println!();
1507
1508 // Show which operations are in the canonical chain
1509 println!("Canonical Chain Operations:");
1510 println!("===========================");
1511 let canonical_indices = build_canonical_chain_indices(&audit_log);
1512 for idx in &canonical_indices {
1513 let entry = &audit_log[*idx];
1514 println!(" [{}] ✅ {} - {}", idx, entry.cid, entry.created_at);
1515 }
1516 println!();
1517
1518 if has_nullified {
1519 println!("Nullified/Rejected Operations:");
1520 println!("==============================");
1521 for (i, entry) in audit_log.iter().enumerate() {
1522 if entry.nullified && !canonical_indices.contains(&i) {
1523 println!(
1524 " [{}] ❌ {} - {} (nullified)",
1525 i, entry.cid, entry.created_at
1526 );
1527 if let Some(prev) = entry.operation.prev() {
1528 println!(" Referenced: {}", prev);
1529 }
1530 }
1531 }
1532 println!();
1533 }
1534 }
1535
1536 // Display final state
1537 display_final_state(&final_state, quiet);
1538 return Ok(());
1539 }
1540 Err(e) => {
1541 eprintln!();
1542 eprintln!("❌ Validation failed: {}", e);
1543 return Err(anyhow::anyhow!("Validation failed: {}", e));
1544 }
1545 }
1546 }
1547
1548 // Simple linear chain validation (no forks or nullified operations)
1549 if verbose {
1550 println!("Step 1: Linear Chain Validation");
1551 println!("================================");
1552 }
1553
1554 for i in 1..audit_log.len() {
1555 let prev_cid = audit_log[i - 1].cid.clone();
1556 let expected_prev = audit_log[i].operation.prev();
1557
1558 if verbose {
1559 println!(" [{}] Checking prev reference...", i);
1560 println!(" Expected: {}", prev_cid);
1561 }
1562
1563 if let Some(actual_prev) = expected_prev {
1564 if verbose {
1565 println!(" Actual: {}", actual_prev);
1566 }
1567
1568 if actual_prev != prev_cid {
1569 eprintln!();
1570 eprintln!(
1571 "❌ Validation failed: Chain linkage broken at operation {}",
1572 i
1573 );
1574 eprintln!(" Expected prev: {}", prev_cid);
1575 eprintln!(" Actual prev: {}", actual_prev);
1576 return Err(anyhow::anyhow!("Chain linkage broken"));
1577 }
1578
1579 if verbose {
1580 println!(" ✅ Match - chain link valid");
1581 }
1582 } else if i > 0 {
1583 eprintln!();
1584 eprintln!(
1585 "❌ Validation failed: Non-genesis operation {} missing prev field",
1586 i
1587 );
1588 return Err(anyhow::anyhow!("Missing prev field"));
1589 }
1590 }
1591
1592 if verbose {
1593 println!();
1594 println!("✅ Chain linkage validation complete");
1595 println!();
1596 }
1597
1598 // Step 2: Validate cryptographic signatures
1599 if verbose {
1600 println!("Step 2: Cryptographic Signature Validation");
1601 println!("==========================================");
1602 }
1603
1604 let mut current_rotation_keys: Vec<String> = Vec::new();
1605
1606 for (i, entry) in audit_log.iter().enumerate() {
1607 if entry.nullified {
1608 if verbose {
1609 println!(" [{}] ⊘ Skipped (nullified)", i);
1610 }
1611 continue;
1612 }
1613
1614 // For genesis operation, we can't validate signature without rotation keys
1615 // But we can extract them and validate subsequent operations
1616 if i == 0 {
1617 if verbose {
1618 println!(" [{}] Genesis operation - extracting rotation keys", i);
1619 }
1620 if let Some(rotation_keys) = entry.operation.rotation_keys() {
1621 current_rotation_keys = rotation_keys.to_vec();
1622 if verbose {
1623 println!(" Rotation keys: {}", rotation_keys.len());
1624 for (j, key) in rotation_keys.iter().enumerate() {
1625 println!(" [{}] {}", j, key);
1626 }
1627 println!(
1628 " ⚠️ Genesis signature cannot be verified (bootstrapping trust)"
1629 );
1630 }
1631 }
1632 continue;
1633 }
1634
1635 if verbose {
1636 println!(" [{}] Validating signature...", i);
1637 println!(" CID: {}", entry.cid);
1638 println!(" Signature: {}", entry.operation.signature());
1639 }
1640
1641 // Validate signature using current rotation keys
1642 if !current_rotation_keys.is_empty() {
1643 if verbose {
1644 println!(
1645 " Available rotation keys: {}",
1646 current_rotation_keys.len()
1647 );
1648 for (j, key) in current_rotation_keys.iter().enumerate() {
1649 println!(" [{}] {}", j, key);
1650 }
1651 }
1652
1653 let verifying_keys: Vec<VerifyingKey> = current_rotation_keys
1654 .iter()
1655 .filter_map(|k| VerifyingKey::from_did_key(k).ok())
1656 .collect();
1657
1658 if verbose {
1659 println!(
1660 " Parsed verifying keys: {}/{}",
1661 verifying_keys.len(),
1662 current_rotation_keys.len()
1663 );
1664 }
1665
1666 // Try to verify with each key and track which one worked
1667 let mut verified = false;
1668 let mut verification_key_index = None;
1669
1670 for (j, key) in verifying_keys.iter().enumerate() {
1671 if entry.operation.verify(&[*key]).is_ok() {
1672 verified = true;
1673 verification_key_index = Some(j);
1674 break;
1675 }
1676 }
1677
1678 if !verified {
1679 // Final attempt with all keys (for comprehensive error)
1680 if let Err(e) = entry.operation.verify(&verifying_keys) {
1681 eprintln!();
1682 eprintln!("❌ Validation failed: Invalid signature at operation {}", i);
1683 eprintln!(" Error: {}", e);
1684 eprintln!(" CID: {}", entry.cid);
1685 eprintln!(
1686 " Tried {} rotation keys, none verified the signature",
1687 verifying_keys.len()
1688 );
1689 return Err(anyhow::anyhow!("Invalid signature"));
1690 }
1691 }
1692
1693 if verbose {
1694 if let Some(key_idx) = verification_key_index {
1695 println!(
1696 " ✅ Signature verified with rotation key [{}]",
1697 key_idx
1698 );
1699 println!(" {}", current_rotation_keys[key_idx]);
1700 } else {
1701 println!(" ✅ Signature verified");
1702 }
1703 }
1704 }
1705
1706 // Update rotation keys if this operation changes them
1707 if let Some(new_rotation_keys) = entry.operation.rotation_keys()
1708 && new_rotation_keys != current_rotation_keys
1709 {
1710 if verbose {
1711 println!(" 🔄 Rotation keys updated by this operation");
1712 println!(" Old keys: {}", current_rotation_keys.len());
1713 println!(" New keys: {}", new_rotation_keys.len());
1714 for (j, key) in new_rotation_keys.iter().enumerate() {
1715 println!(" [{}] {}", j, key);
1716 }
1717 }
1718 current_rotation_keys = new_rotation_keys.to_vec();
1719 }
1720 }
1721
1722 if verbose {
1723 println!();
1724 println!("✅ Cryptographic signature validation complete");
1725 println!();
1726 }
1727
1728 // Build final state
1729 let final_entry = audit_log.last().unwrap();
1730 if let Some(_rotation_keys) = final_entry.operation.rotation_keys() {
1731 let final_state = match &final_entry.operation {
1732 Operation::PlcOperation {
1733 rotation_keys,
1734 verification_methods,
1735 also_known_as,
1736 services,
1737 ..
1738 } => PlcState {
1739 rotation_keys: rotation_keys.clone(),
1740 verification_methods: verification_methods.clone(),
1741 also_known_as: also_known_as.clone(),
1742 services: services.clone(),
1743 },
1744 _ => PlcState::new(),
1745 };
1746
1747 display_final_state(&final_state, quiet);
1748 } else {
1749 eprintln!("❌ Error: Could not extract final state");
1750 return Err(anyhow::anyhow!("Could not extract final state"));
1751 }
1752
1753 Ok(())
1754}
1755
1756/// Detect if there are fork points in the audit log
1757fn detect_forks(audit_log: &[AuditLogEntry]) -> bool {
1758 let mut prev_counts: HashMap<String, usize> = HashMap::new();
1759 for entry in audit_log {
1760 if let Some(prev) = entry.operation.prev() {
1761 *prev_counts.entry(prev.to_string()).or_insert(0) += 1;
1762 }
1763 }
1764 // If any prev CID is referenced by more than one operation, there's a fork
1765 prev_counts.values().any(|&count| count > 1)
1766}
1767
1768/// Build a list of indices that form the canonical chain
1769fn build_canonical_chain_indices(audit_log: &[AuditLogEntry]) -> Vec<usize> {
1770 // Build a map of prev CID to operations
1771 let mut prev_to_indices: HashMap<String, Vec<usize>> = HashMap::new();
1772 for (i, entry) in audit_log.iter().enumerate() {
1773 if let Some(prev) = entry.operation.prev() {
1774 prev_to_indices.entry(prev.to_string()).or_default().push(i);
1775 }
1776 }
1777
1778 // Start from genesis and follow the canonical chain
1779 let mut canonical = Vec::new();
1780
1781 // Find genesis (first operation)
1782 let genesis = match audit_log.first() {
1783 Some(g) => g,
1784 None => return canonical,
1785 };
1786
1787 canonical.push(0);
1788 let mut current_cid = genesis.cid.clone();
1789
1790 // Follow the chain, preferring non-nullified operations
1791 while let Some(indices) = prev_to_indices.get(¤t_cid) {
1792 // Find the first non-nullified operation
1793 if let Some(&next_idx) = indices.iter().find(|&&idx| !audit_log[idx].nullified) {
1794 canonical.push(next_idx);
1795 current_cid = audit_log[next_idx].cid.clone();
1796 } else {
1797 // All operations at this point are nullified - try to find any operation
1798 if let Some(&next_idx) = indices.first() {
1799 canonical.push(next_idx);
1800 current_cid = audit_log[next_idx].cid.clone();
1801 } else {
1802 break;
1803 }
1804 }
1805 }
1806
1807 canonical
1808}
1809
1810/// Display the final state after validation
1811fn display_final_state(final_state: &PlcState, quiet: bool) {
1812 if quiet {
1813 println!("✅ VALID");
1814 } else {
1815 println!("✅ Validation successful!");
1816 println!();
1817
1818 println!("📄 Final DID State:");
1819 println!(" Rotation keys: {}", final_state.rotation_keys.len());
1820 for (i, key) in final_state.rotation_keys.iter().enumerate() {
1821 println!(" [{}] {}", i, key);
1822 }
1823 println!();
1824
1825 println!(
1826 " Verification methods: {}",
1827 final_state.verification_methods.len()
1828 );
1829 for (name, key) in &final_state.verification_methods {
1830 println!(" {}: {}", name, key);
1831 }
1832 println!();
1833
1834 if !final_state.also_known_as.is_empty() {
1835 println!(" Also known as: {}", final_state.also_known_as.len());
1836 for uri in &final_state.also_known_as {
1837 println!(" - {}", uri);
1838 }
1839 println!();
1840 }
1841
1842 if !final_state.services.is_empty() {
1843 println!(" Services: {}", final_state.services.len());
1844 for (name, service) in &final_state.services {
1845 println!(
1846 " {}: {} ({})",
1847 name, service.endpoint, service.service_type
1848 );
1849 }
1850 }
1851 }
1852}
1853
1854/// Convert local bundle operations to audit log format
1855fn convert_to_audit_log(
1856 ops_with_loc: Vec<plcbundle::OperationWithLocation>,
1857) -> Result<Vec<AuditLogEntry>> {
1858 let mut audit_log = Vec::new();
1859
1860 for owl in ops_with_loc {
1861 // Extract the operation JSON and convert to atproto_plc::Operation
1862 // Note: atproto_plc uses serde, so we use serde_json here (not parsing from bundle)
1863 let operation_json = serde_json::to_value(&owl.operation.operation)?;
1864 let operation: Operation = serde_json::from_value(operation_json)
1865 .map_err(|e| anyhow::anyhow!("Failed to parse operation: {}", e))?;
1866
1867 // Get CID from bundle operation (should always be present)
1868 let cid = owl.operation.cid.clone().unwrap_or_else(|| {
1869 // Fallback: this shouldn't happen in real data, but provide a placeholder
1870 format!("bundle_{}_pos_{}", owl.bundle, owl.position)
1871 });
1872
1873 audit_log.push(AuditLogEntry {
1874 did: owl.operation.did.clone(),
1875 operation,
1876 cid,
1877 created_at: owl.operation.created_at.clone(),
1878 nullified: owl.nullified || owl.operation.nullified,
1879 });
1880 }
1881
1882 Ok(audit_log)
1883}
1884
1885/// Visualize forks in the audit log
1886fn visualize_forks(audit_log: &[AuditLogEntry], did_str: &str, verbose: bool) -> Result<()> {
1887 println!("🔍 Analyzing forks in: {}", did_str);
1888 println!(" Source: local bundles");
1889 println!();
1890
1891 println!("📊 Audit log contains {} operations", audit_log.len());
1892
1893 // Detect forks
1894 let forks = detect_forks_detailed(audit_log, verbose);
1895
1896 if forks.is_empty() {
1897 println!("\n✅ No forks detected - this is a linear operation chain");
1898 println!(" All operations form a single canonical path from genesis to tip.");
1899
1900 if verbose {
1901 println!("\n📋 Linear chain visualization:");
1902 visualize_linear_chain(audit_log);
1903 }
1904
1905 return Ok(());
1906 }
1907
1908 println!("⚠️ Detected {} fork point(s)", forks.len());
1909 println!();
1910
1911 visualize_tree(audit_log, &forks, verbose);
1912
1913 Ok(())
1914}
1915
1916/// Detect fork points in the audit log with detailed information
1917fn detect_forks_detailed(audit_log: &[AuditLogEntry], verbose: bool) -> Vec<ForkPoint> {
1918 let mut prev_to_operations: HashMap<String, Vec<AuditLogEntry>> = HashMap::new();
1919
1920 // Group operations by their prev CID
1921 for entry in audit_log {
1922 if let Some(prev) = entry.operation.prev() {
1923 prev_to_operations
1924 .entry(prev.to_string())
1925 .or_default()
1926 .push(entry.clone());
1927 }
1928 }
1929
1930 // Build operation map for state reconstruction
1931 let operation_map: HashMap<String, AuditLogEntry> = audit_log
1932 .iter()
1933 .map(|e| (e.cid.clone(), e.clone()))
1934 .collect();
1935
1936 let mut forks = Vec::new();
1937
1938 // Find fork points (where multiple operations reference the same prev)
1939 for (prev_cid, operations) in prev_to_operations {
1940 if operations.len() > 1 {
1941 if verbose {
1942 println!("🔀 Fork detected at {}", truncate_cid(&prev_cid));
1943 println!(" {} competing operations", operations.len());
1944 }
1945
1946 // Get the state at the prev operation to determine rotation keys
1947 let state = if let Some(prev_entry) = operation_map.get(&prev_cid) {
1948 get_state_from_operation(&prev_entry.operation)
1949 } else {
1950 // This shouldn't happen in a valid chain
1951 continue;
1952 };
1953
1954 // Analyze each operation in the fork
1955 let mut fork_ops = Vec::new();
1956 for entry in &operations {
1957 let timestamp = parse_timestamp(&entry.created_at);
1958
1959 // Determine which rotation key signed this operation
1960 let (signing_key_index, signing_key) = if !state.rotation_keys.is_empty() {
1961 find_signing_key(&entry.operation, &state.rotation_keys)
1962 } else {
1963 (None, None)
1964 };
1965
1966 fork_ops.push(ForkOperation {
1967 cid: entry.cid.clone(),
1968 operation: entry.operation.clone(),
1969 timestamp,
1970 signing_key_index,
1971 signing_key,
1972 is_winner: false,
1973 rejection_reason: None,
1974 });
1975 }
1976
1977 // Resolve the fork to determine winner
1978 let winner_cid = resolve_fork(&mut fork_ops);
1979
1980 forks.push(ForkPoint {
1981 prev_cid,
1982 operations: fork_ops,
1983 winner_cid,
1984 });
1985 }
1986 }
1987
1988 // Sort forks chronologically
1989 forks.sort_by_key(|f| {
1990 f.operations
1991 .iter()
1992 .map(|op| op.timestamp)
1993 .min()
1994 .unwrap_or_else(chrono::Utc::now)
1995 });
1996
1997 forks
1998}
1999
2000/// Resolve a fork point and mark the winner
2001fn resolve_fork(fork_ops: &mut [ForkOperation]) -> String {
2002 // Sort by timestamp (chronological order)
2003 fork_ops.sort_by_key(|op| op.timestamp);
2004
2005 // First-received is the default winner
2006 let mut winner_idx = 0;
2007 fork_ops[0].is_winner = true;
2008
2009 // Check if any later operation can invalidate based on priority
2010 for i in 1..fork_ops.len() {
2011 let competing_key_idx = fork_ops[i].signing_key_index;
2012 let winner_key_idx = fork_ops[winner_idx].signing_key_index;
2013
2014 match (competing_key_idx, winner_key_idx) {
2015 (Some(competing_idx), Some(winner_idx_val)) => {
2016 if competing_idx < winner_idx_val {
2017 // Higher priority (lower index)
2018 let time_diff = fork_ops[i].timestamp - fork_ops[winner_idx].timestamp;
2019
2020 if time_diff <= chrono::Duration::hours(72) {
2021 // Within recovery window - this operation wins
2022 fork_ops[winner_idx].is_winner = false;
2023 fork_ops[winner_idx].rejection_reason = Some(format!(
2024 "Invalidated by higher-priority key[{}] within recovery window",
2025 competing_idx
2026 ));
2027
2028 fork_ops[i].is_winner = true;
2029 winner_idx = i;
2030 } else {
2031 // Outside recovery window
2032 fork_ops[i].rejection_reason = Some(format!(
2033 "Higher-priority key[{}] but outside 72-hour recovery window ({:.1}h late)",
2034 competing_idx,
2035 time_diff.num_hours() as f64
2036 ));
2037 }
2038 } else {
2039 // Lower priority
2040 fork_ops[i].rejection_reason = Some(format!(
2041 "Lower-priority key[{}] (current winner has key[{}])",
2042 competing_idx, winner_idx_val
2043 ));
2044 }
2045 }
2046 _ => {
2047 fork_ops[i].rejection_reason = Some("Could not determine signing key".to_string());
2048 }
2049 }
2050 }
2051
2052 fork_ops[winner_idx].cid.clone()
2053}
2054
2055/// Find which rotation key signed an operation
2056fn find_signing_key(
2057 operation: &Operation,
2058 rotation_keys: &[String],
2059) -> (Option<usize>, Option<String>) {
2060 for (index, key_did) in rotation_keys.iter().enumerate() {
2061 if let Ok(verifying_key) = VerifyingKey::from_did_key(key_did)
2062 && operation.verify(&[verifying_key]).is_ok()
2063 {
2064 return (Some(index), Some(key_did.clone()));
2065 }
2066 }
2067 (None, None)
2068}
2069
2070/// Get state from an operation
2071fn get_state_from_operation(operation: &Operation) -> PlcState {
2072 match operation {
2073 Operation::PlcOperation {
2074 rotation_keys,
2075 verification_methods,
2076 also_known_as,
2077 services,
2078 ..
2079 } => PlcState {
2080 rotation_keys: rotation_keys.clone(),
2081 verification_methods: verification_methods.clone(),
2082 also_known_as: also_known_as.clone(),
2083 services: services.clone(),
2084 },
2085 _ => PlcState::new(),
2086 }
2087}
2088
2089/// Parse ISO 8601 timestamp
2090fn parse_timestamp(timestamp: &str) -> chrono::DateTime<chrono::Utc> {
2091 timestamp
2092 .parse::<chrono::DateTime<chrono::Utc>>()
2093 .unwrap_or_else(|_| chrono::Utc::now())
2094}
2095
2096/// Visualize forks as a tree
2097fn visualize_tree(audit_log: &[AuditLogEntry], forks: &[ForkPoint], verbose: bool) {
2098 println!("📊 Fork Visualization (Tree Format)");
2099 println!("═══════════════════════════════════════════════════════════════");
2100 println!();
2101
2102 // Build a map of which operations are part of forks
2103 let mut fork_map: HashMap<String, &ForkPoint> = HashMap::new();
2104 for fork in forks {
2105 for op in &fork.operations {
2106 fork_map.insert(op.cid.clone(), fork);
2107 }
2108 }
2109
2110 // Track which prev CIDs have been processed
2111 let mut processed_forks: std::collections::HashSet<String> = std::collections::HashSet::new();
2112
2113 for entry in audit_log.iter() {
2114 let is_genesis = entry.operation.is_genesis();
2115 let prev = entry.operation.prev();
2116
2117 // Check if this operation is part of a fork
2118 if let Some(_prev_cid) = prev
2119 && let Some(fork) = fork_map.get(&entry.cid)
2120 {
2121 // This is a fork point
2122 if !processed_forks.contains(&fork.prev_cid) {
2123 processed_forks.insert(fork.prev_cid.clone());
2124
2125 println!(
2126 "Fork at operation referencing {}",
2127 truncate_cid(&fork.prev_cid)
2128 );
2129
2130 for (j, fork_op) in fork.operations.iter().enumerate() {
2131 let symbol = if fork_op.is_winner { "✓" } else { "✗" };
2132 let color = if fork_op.is_winner { "🟢" } else { "🔴" };
2133 let prefix = if j == fork.operations.len() - 1 {
2134 "└─"
2135 } else {
2136 "├─"
2137 };
2138
2139 println!(
2140 " {} {} {} CID: {}",
2141 prefix,
2142 color,
2143 symbol,
2144 truncate_cid(&fork_op.cid)
2145 );
2146
2147 if let Some(key_idx) = fork_op.signing_key_index {
2148 println!(" │ Signed by: rotation_key[{}]", key_idx);
2149 if verbose && let Some(key) = &fork_op.signing_key {
2150 println!(" │ Key: {}", truncate_cid(key));
2151 }
2152 }
2153
2154 println!(
2155 " │ Timestamp: {}",
2156 fork_op.timestamp.format("%Y-%m-%d %H:%M:%S UTC")
2157 );
2158
2159 if !fork_op.is_winner {
2160 if let Some(reason) = &fork_op.rejection_reason {
2161 println!(" │ Reason: {}", reason);
2162 }
2163 } else {
2164 println!(" │ Status: CANONICAL (winner)");
2165 }
2166
2167 if j < fork.operations.len() - 1 {
2168 println!(" │");
2169 }
2170 }
2171 println!();
2172 }
2173 continue;
2174 }
2175
2176 // Regular operation (not part of a fork)
2177 if is_genesis {
2178 println!("🌱 Genesis");
2179 println!(" CID: {}", truncate_cid(&entry.cid));
2180 println!(" Timestamp: {}", entry.created_at);
2181 if verbose && let Operation::PlcOperation { rotation_keys, .. } = &entry.operation {
2182 println!(" Rotation keys: {}", rotation_keys.len());
2183 }
2184 println!();
2185 }
2186 }
2187
2188 // Summary
2189 println!("═══════════════════════════════════════════════════════════════");
2190 println!("📈 Summary:");
2191 println!(" Total operations: {}", audit_log.len());
2192 println!(" Fork points: {}", forks.len());
2193
2194 let total_competing_ops: usize = forks.iter().map(|f| f.operations.len()).sum();
2195 let rejected_ops = total_competing_ops - forks.len();
2196 println!(" Rejected operations: {}", rejected_ops);
2197
2198 if !forks.is_empty() {
2199 println!("\n🔐 Fork Resolution Details:");
2200 for (i, fork) in forks.iter().enumerate() {
2201 let winner = fork.operations.iter().find(|op| op.is_winner).unwrap();
2202 println!(
2203 " Fork {}: Winner is {} (signed by key[{}])",
2204 i + 1,
2205 truncate_cid(&winner.cid),
2206 winner.signing_key_index.unwrap_or(999)
2207 );
2208 }
2209 }
2210}
2211
2212/// Visualize linear chain (no forks)
2213fn visualize_linear_chain(audit_log: &[AuditLogEntry]) {
2214 for (i, entry) in audit_log.iter().enumerate() {
2215 let symbol = if i == 0 { "🌱" } else { " ↓" };
2216 println!("{} Operation {}: {}", symbol, i, truncate_cid(&entry.cid));
2217 println!(" Timestamp: {}", entry.created_at);
2218 if let Some(prev) = entry.operation.prev() {
2219 println!(" Previous: {}", truncate_cid(prev));
2220 }
2221 }
2222}
2223
2224/// Truncate a CID for display
2225fn truncate_cid(cid: &str) -> String {
2226 if cid.len() > 20 {
2227 format!("{}...{}", &cid[..8], &cid[cid.len() - 8..])
2228 } else {
2229 cid.to_string()
2230 }
2231}