Skip to main content

actr_cli/commands/
fingerprint.rs

1//! Fingerprint command implementation
2//!
3//! Computes and displays semantic fingerprints for proto files
4
5use crate::core::{Command, CommandContext, CommandResult, ComponentType};
6use actr_config::ConfigParser;
7use actr_version::{Fingerprint, ProtoFile};
8use anyhow::{Context, Result};
9use async_trait::async_trait;
10use clap::Args;
11use std::fs;
12use std::path::Path;
13use tracing::{error, info};
14
15/// Verification status
16#[derive(Debug, Clone)]
17enum VerificationStatus {
18    Passed {
19        matched_fingerprint: String,
20    },
21    Failed {
22        mismatches: Vec<(String, String, String)>,
23    }, // (file_path, expected_fingerprint, actual_fingerprint)
24    NoLockFile,
25    NotRequested,
26}
27
28/// Fingerprint command - computes semantic fingerprints
29#[derive(Args, Debug)]
30#[command(
31    about = "Compute project and service fingerprints",
32    long_about = "Compute and display semantic fingerprints for proto files and services"
33)]
34pub struct FingerprintCommand {
35    /// Configuration file path
36    #[arg(short, long, default_value = "Actr.toml")]
37    pub config: String,
38
39    /// Output format (text, json, yaml)
40    #[arg(long, default_value = "text")]
41    pub format: String,
42
43    /// Calculate fingerprint for a specific proto file
44    #[arg(long)]
45    pub proto: Option<String>,
46
47    /// Calculate service-level fingerprint (default)
48    #[arg(long)]
49    pub service_level: bool,
50
51    /// Verify fingerprint against lock file
52    #[arg(long)]
53    pub verify: bool,
54}
55
56#[async_trait]
57impl Command for FingerprintCommand {
58    async fn execute(&self, _context: &CommandContext) -> Result<CommandResult> {
59        if let Some(proto_path) = &self.proto {
60            // Calculate fingerprint for a specific proto file
61            info!(
62                "šŸ” Computing proto semantic fingerprint for: {}",
63                proto_path
64            );
65            execute_proto_fingerprint(self, proto_path).await?;
66        } else {
67            // Calculate service-level fingerprint (default)
68            info!("šŸ” Computing service semantic fingerprint...");
69            execute_service_fingerprint(self).await?;
70        }
71
72        Ok(CommandResult::Success(
73            "Fingerprint calculation completed".to_string(),
74        ))
75    }
76
77    fn required_components(&self) -> Vec<ComponentType> {
78        vec![] // Fingerprint calculation doesn't require external components
79    }
80
81    fn name(&self) -> &str {
82        "fingerprint"
83    }
84
85    fn description(&self) -> &str {
86        "Compute semantic fingerprints for proto files and services"
87    }
88}
89
90/// Execute proto-level fingerprint calculation
91async fn execute_proto_fingerprint(args: &FingerprintCommand, proto_path: &str) -> Result<()> {
92    let path = Path::new(proto_path);
93    if !path.exists() {
94        return Err(anyhow::anyhow!("Proto file not found: {}", proto_path));
95    }
96
97    let content = fs::read_to_string(path)
98        .with_context(|| format!("Failed to read proto file: {}", proto_path))?;
99
100    let fingerprint = Fingerprint::calculate_proto_semantic_fingerprint(&content)
101        .context("Failed to calculate proto fingerprint")?;
102
103    // Output
104    match args.format.as_str() {
105        "text" => show_proto_text_output(&fingerprint, proto_path),
106        "json" => show_proto_json_output(&fingerprint, proto_path)?,
107        "yaml" => show_proto_yaml_output(&fingerprint, proto_path)?,
108        _ => {
109            error!("Unsupported output format: {}", args.format);
110            return Err(anyhow::anyhow!("Unsupported format: {}", args.format));
111        }
112    }
113
114    Ok(())
115}
116
117/// Execute service-level fingerprint calculation
118async fn execute_service_fingerprint(args: &FingerprintCommand) -> Result<()> {
119    // Load configuration
120    let config_path = Path::new(&args.config);
121    let config = ConfigParser::from_file(config_path)
122        .with_context(|| format!("Failed to load config from {}", args.config))?;
123
124    // Convert actr_config::ProtoFile to actr_version::ProtoFile
125    let mut proto_files: Vec<ProtoFile> = config
126        .exports
127        .iter()
128        .map(|pf| ProtoFile {
129            name: pf.file_name().unwrap_or("unknown.proto").to_string(),
130            content: pf.content.clone(),
131            path: Some(pf.path.to_string_lossy().to_string()),
132        })
133        .collect();
134
135    // If verifying, also collect proto files from protos/remote directory
136    if args.verify {
137        let config_dir = config_path.parent().unwrap_or(Path::new("."));
138        let remote_dir = config_dir.join("protos").join("remote");
139
140        if remote_dir.exists() {
141            collect_proto_files_from_directory(&remote_dir, &mut proto_files)?;
142        }
143    }
144
145    if proto_files.is_empty() {
146        // No proto files to calculate fingerprint for, but we can still verify lock file
147        if args.verify {
148            let verification_status = verify_fingerprint_against_lock("", &[], config_path)?;
149            match args.format.as_str() {
150                "text" => {
151                    show_verification_status_only(&verification_status);
152                }
153                "json" => {
154                    let verification_info = match verification_status {
155                        VerificationStatus::Passed { .. } => {
156                            serde_json::json!({"status": "passed"})
157                        }
158                        VerificationStatus::Failed { mismatches } => serde_json::json!({
159                            "status": "failed",
160                            "mismatches": mismatches.iter().map(|(file_path, expected, actual)| {
161                                serde_json::json!({
162                                    "file_path": file_path,
163                                    "expected": expected,
164                                    "actual": actual
165                                })
166                            }).collect::<Vec<_>>()
167                        }),
168                        VerificationStatus::NoLockFile => {
169                            serde_json::json!({"status": "no_lock_file"})
170                        }
171                        _ => serde_json::json!({"status": "not_requested"}),
172                    };
173                    println!(
174                        "{}",
175                        serde_json::to_string_pretty(&verification_info).unwrap()
176                    );
177                }
178                "yaml" => {
179                    let verification_info = match verification_status {
180                        VerificationStatus::Passed { .. } => {
181                            let mut map = serde_yaml::Mapping::new();
182                            map.insert(
183                                serde_yaml::Value::String("status".to_string()),
184                                serde_yaml::Value::String("passed".to_string()),
185                            );
186                            map
187                        }
188                        VerificationStatus::Failed { mismatches } => {
189                            let mut map = serde_yaml::Mapping::new();
190                            map.insert(
191                                serde_yaml::Value::String("status".to_string()),
192                                serde_yaml::Value::String("failed".to_string()),
193                            );
194                            map.insert(
195                                serde_yaml::Value::String("mismatches".to_string()),
196                                serde_yaml::Value::Sequence(
197                                    mismatches
198                                        .iter()
199                                        .map(|(file_path, expected, actual)| {
200                                            let mut mismatch_map = serde_yaml::Mapping::new();
201                                            mismatch_map.insert(
202                                                serde_yaml::Value::String("file_path".to_string()),
203                                                serde_yaml::Value::String(file_path.clone()),
204                                            );
205                                            mismatch_map.insert(
206                                                serde_yaml::Value::String("expected".to_string()),
207                                                serde_yaml::Value::String(expected.clone()),
208                                            );
209                                            mismatch_map.insert(
210                                                serde_yaml::Value::String("actual".to_string()),
211                                                serde_yaml::Value::String(actual.clone()),
212                                            );
213                                            serde_yaml::Value::Mapping(mismatch_map)
214                                        })
215                                        .collect(),
216                                ),
217                            );
218                            map
219                        }
220                        VerificationStatus::NoLockFile => {
221                            let mut map = serde_yaml::Mapping::new();
222                            map.insert(
223                                serde_yaml::Value::String("status".to_string()),
224                                serde_yaml::Value::String("no_lock_file".to_string()),
225                            );
226                            map
227                        }
228                        _ => {
229                            let mut map = serde_yaml::Mapping::new();
230                            map.insert(
231                                serde_yaml::Value::String("status".to_string()),
232                                serde_yaml::Value::String("not_requested".to_string()),
233                            );
234                            map
235                        }
236                    };
237                    println!(
238                        "{}",
239                        serde_yaml::to_string(&serde_yaml::Value::Mapping(verification_info))
240                            .unwrap()
241                    );
242                }
243                _ => {
244                    show_verification_status_only(&verification_status);
245                }
246            }
247        } else {
248            match args.format.as_str() {
249                "text" => {
250                    println!("ā„¹ļø  No proto files found in exports");
251                    println!(
252                        "   Add proto files to the 'exports' array in {} to calculate fingerprints",
253                        args.config
254                    );
255                }
256                "json" => {
257                    let output = serde_json::json!({
258                        "status": "no_exports",
259                        "message": "No proto files found in exports",
260                        "config_file": args.config
261                    });
262                    println!("{}", serde_json::to_string_pretty(&output).unwrap());
263                }
264                "yaml" => {
265                    let output = serde_yaml::Value::Mapping({
266                        let mut map = serde_yaml::Mapping::new();
267                        map.insert(
268                            serde_yaml::Value::String("status".to_string()),
269                            serde_yaml::Value::String("no_exports".to_string()),
270                        );
271                        map.insert(
272                            serde_yaml::Value::String("message".to_string()),
273                            serde_yaml::Value::String(
274                                "No proto files found in exports".to_string(),
275                            ),
276                        );
277                        map.insert(
278                            serde_yaml::Value::String("config_file".to_string()),
279                            serde_yaml::Value::String(args.config.clone()),
280                        );
281                        map
282                    });
283                    println!("{}", serde_yaml::to_string(&output).unwrap());
284                }
285                _ => {
286                    println!("ā„¹ļø  No proto files found in exports");
287                }
288            }
289        }
290        return Ok(());
291    }
292
293    // Calculate semantic fingerprint
294    let fingerprint = Fingerprint::calculate_service_semantic_fingerprint(&proto_files)
295        .context("Failed to calculate service fingerprint")?;
296
297    // Verify against lock file if requested
298    let verification_status = if args.verify {
299        verify_fingerprint_against_lock(&fingerprint, &proto_files, config_path)?
300    } else {
301        VerificationStatus::NotRequested
302    };
303
304    // Output
305    match args.format.as_str() {
306        "text" => show_text_output(&fingerprint, &proto_files, &verification_status),
307        "json" => show_json_output(&fingerprint, &proto_files, &verification_status)?,
308        "yaml" => show_yaml_output(&fingerprint, &proto_files, &verification_status)?,
309        _ => {
310            error!("Unsupported output format: {}", args.format);
311            return Err(anyhow::anyhow!("Unsupported format: {}", args.format));
312        }
313    }
314
315    Ok(())
316}
317
318/// Show proto text output format
319fn show_proto_text_output(fingerprint: &str, proto_path: &str) {
320    println!("šŸ“‹ Proto Semantic Fingerprint:");
321    println!("  File: {}", proto_path);
322    println!("  {fingerprint}");
323}
324
325/// Show proto JSON output format
326fn show_proto_json_output(fingerprint: &str, proto_path: &str) -> Result<()> {
327    let output = ProtoJsonOutput {
328        proto_file: proto_path.to_string(),
329        fingerprint: fingerprint.to_string(),
330    };
331
332    let json = serde_json::to_string_pretty(&output).context("Failed to serialize output")?;
333    println!("{json}");
334
335    Ok(())
336}
337
338/// Show proto YAML output format
339fn show_proto_yaml_output(fingerprint: &str, proto_path: &str) -> Result<()> {
340    let output = ProtoJsonOutput {
341        proto_file: proto_path.to_string(),
342        fingerprint: fingerprint.to_string(),
343    };
344
345    let yaml = serde_yaml::to_string(&output).context("Failed to serialize output")?;
346    println!("{yaml}");
347
348    Ok(())
349}
350
351/// Collect proto files from a directory recursively
352fn collect_proto_files_from_directory(dir: &Path, proto_files: &mut Vec<ProtoFile>) -> Result<()> {
353    fn visit_dir(dir: &Path, proto_files: &mut Vec<ProtoFile>, base_dir: &Path) -> Result<()> {
354        if let Ok(entries) = fs::read_dir(dir) {
355            for entry in entries {
356                let entry = entry?;
357                let path = entry.path();
358
359                if path.is_dir() {
360                    visit_dir(&path, proto_files, base_dir)?;
361                } else if path.extension().and_then(|s| s.to_str()) == Some("proto") {
362                    let content = fs::read_to_string(&path).with_context(|| {
363                        format!("Failed to read proto file: {}", path.display())
364                    })?;
365
366                    // Get relative path from the base directory
367                    let relative_path = path.strip_prefix(base_dir).unwrap_or(&path);
368                    let name = path
369                        .file_name()
370                        .and_then(|n| n.to_str())
371                        .unwrap_or("unknown.proto")
372                        .to_string();
373
374                    proto_files.push(ProtoFile {
375                        name,
376                        content,
377                        path: Some(relative_path.to_string_lossy().to_string()),
378                    });
379                }
380            }
381        }
382        Ok(())
383    }
384
385    visit_dir(dir, proto_files, dir)
386}
387
388/// Show verification status only (for cases where no proto files are available)
389fn show_verification_status_only(verification_status: &VerificationStatus) {
390    match verification_status {
391        VerificationStatus::Passed { .. } => {
392            println!("āœ… Fingerprint verification: PASSED");
393            println!("  All lock file fingerprints verified against actual files");
394        }
395        VerificationStatus::Failed { mismatches } => {
396            println!("āŒ Fingerprint verification: FAILED");
397            println!("  File-level mismatches:");
398            for (file_path, expected, actual) in mismatches {
399                println!("    File: {}", file_path);
400                println!("      Expected: {}", expected);
401                println!("      Actual:   {}", actual);
402            }
403        }
404        VerificationStatus::NoLockFile => {
405            println!("āš ļø  Fingerprint verification: No lock file found");
406        }
407        VerificationStatus::NotRequested => {
408            // No verification requested, don't show anything
409        }
410    }
411}
412
413/// Show text output format
414fn show_text_output(
415    fingerprint: &str,
416    proto_files: &[ProtoFile],
417    verification_status: &VerificationStatus,
418) {
419    println!("šŸ“‹ Service Semantic Fingerprint:");
420    println!("  {fingerprint}");
421    println!("\nšŸ“¦ Proto Files ({}):", proto_files.len());
422    for pf in proto_files {
423        println!("  - {}", pf.name);
424    }
425
426    // Show verification status
427    match verification_status {
428        VerificationStatus::Passed {
429            matched_fingerprint: _,
430        } => {
431            println!("\nāœ… Fingerprint verification: PASSED");
432            println!("  All lock file fingerprints verified against actual files");
433        }
434        VerificationStatus::Failed { mismatches } => {
435            println!("\nāŒ Fingerprint verification: FAILED");
436            println!("  File-level mismatches:");
437            for (file_path, expected, actual) in mismatches {
438                println!("    File: {}", file_path);
439                println!("      Expected: {}", expected);
440                println!("      Actual:   {}", actual);
441            }
442        }
443        VerificationStatus::NoLockFile => {
444            println!("\nāš ļø  Fingerprint verification: No lock file found");
445        }
446        VerificationStatus::NotRequested => {
447            // No verification requested, don't show anything
448        }
449    }
450}
451
452/// Show JSON output format
453fn show_json_output(
454    fingerprint: &str,
455    proto_files: &[ProtoFile],
456    verification_status: &VerificationStatus,
457) -> Result<()> {
458    let verification_info = match verification_status {
459        VerificationStatus::Passed {
460            matched_fingerprint,
461        } => serde_json::json!({
462            "status": "passed",
463            "matched_fingerprint": matched_fingerprint
464        }),
465        VerificationStatus::Failed { mismatches } => serde_json::json!({
466            "status": "failed",
467            "mismatches": mismatches.iter().map(|(file_path, expected, actual)| {
468                serde_json::json!({
469                    "file_path": file_path,
470                    "expected": expected,
471                    "actual": actual
472                })
473            }).collect::<Vec<_>>()
474        }),
475        VerificationStatus::NoLockFile => serde_json::json!({
476            "status": "no_lock_file"
477        }),
478        VerificationStatus::NotRequested => serde_json::json!({
479            "status": "not_requested"
480        }),
481    };
482
483    let output = serde_json::json!({
484        "service_fingerprint": fingerprint,
485        "proto_files": proto_files.iter().map(|pf| pf.name.clone()).collect::<Vec<_>>(),
486        "verification": verification_info
487    });
488
489    let json = serde_json::to_string_pretty(&output).context("Failed to serialize output")?;
490    println!("{json}");
491
492    Ok(())
493}
494
495/// Show YAML output format
496fn show_yaml_output(
497    fingerprint: &str,
498    proto_files: &[ProtoFile],
499    verification_status: &VerificationStatus,
500) -> Result<()> {
501    let verification_info = match verification_status {
502        VerificationStatus::Passed {
503            matched_fingerprint,
504        } => serde_yaml::Value::Mapping({
505            let mut map = serde_yaml::Mapping::new();
506            map.insert(
507                serde_yaml::Value::String("status".to_string()),
508                serde_yaml::Value::String("passed".to_string()),
509            );
510            map.insert(
511                serde_yaml::Value::String("matched_fingerprint".to_string()),
512                serde_yaml::Value::String(matched_fingerprint.clone()),
513            );
514            map
515        }),
516        VerificationStatus::Failed { mismatches } => serde_yaml::Value::Mapping({
517            let mut map = serde_yaml::Mapping::new();
518            map.insert(
519                serde_yaml::Value::String("status".to_string()),
520                serde_yaml::Value::String("failed".to_string()),
521            );
522            map.insert(
523                serde_yaml::Value::String("mismatches".to_string()),
524                serde_yaml::Value::Sequence(
525                    mismatches
526                        .iter()
527                        .map(|(file_path, expected, actual)| {
528                            let mut mismatch_map = serde_yaml::Mapping::new();
529                            mismatch_map.insert(
530                                serde_yaml::Value::String("file_path".to_string()),
531                                serde_yaml::Value::String(file_path.clone()),
532                            );
533                            mismatch_map.insert(
534                                serde_yaml::Value::String("expected".to_string()),
535                                serde_yaml::Value::String(expected.clone()),
536                            );
537                            mismatch_map.insert(
538                                serde_yaml::Value::String("actual".to_string()),
539                                serde_yaml::Value::String(actual.clone()),
540                            );
541                            serde_yaml::Value::Mapping(mismatch_map)
542                        })
543                        .collect(),
544                ),
545            );
546            map
547        }),
548        VerificationStatus::NoLockFile => serde_yaml::Value::Mapping({
549            let mut map = serde_yaml::Mapping::new();
550            map.insert(
551                serde_yaml::Value::String("status".to_string()),
552                serde_yaml::Value::String("no_lock_file".to_string()),
553            );
554            map
555        }),
556        VerificationStatus::NotRequested => serde_yaml::Value::Mapping({
557            let mut map = serde_yaml::Mapping::new();
558            map.insert(
559                serde_yaml::Value::String("status".to_string()),
560                serde_yaml::Value::String("not_requested".to_string()),
561            );
562            map
563        }),
564    };
565
566    let output = serde_yaml::Value::Mapping({
567        let mut map = serde_yaml::Mapping::new();
568        map.insert(
569            serde_yaml::Value::String("service_fingerprint".to_string()),
570            serde_yaml::Value::String(fingerprint.to_string()),
571        );
572        map.insert(
573            serde_yaml::Value::String("proto_files".to_string()),
574            serde_yaml::Value::Sequence(
575                proto_files
576                    .iter()
577                    .map(|pf| serde_yaml::Value::String(pf.name.clone()))
578                    .collect(),
579            ),
580        );
581        map.insert(
582            serde_yaml::Value::String("verification".to_string()),
583            verification_info,
584        );
585        map
586    });
587
588    let yaml = serde_yaml::to_string(&output).context("Failed to serialize output")?;
589    println!("{yaml}");
590
591    Ok(())
592}
593
594/// Verify fingerprint against lock file
595fn verify_fingerprint_against_lock(
596    current_fingerprint: &str,
597    proto_files: &[ProtoFile],
598    config_path: &Path,
599) -> Result<VerificationStatus> {
600    let lock_path = config_path.with_file_name("actr.lock.toml");
601    if !lock_path.exists() {
602        return Ok(VerificationStatus::NoLockFile);
603    }
604
605    let lock_content = fs::read_to_string(&lock_path)
606        .with_context(|| format!("Failed to read lock file: {}", lock_path.display()))?;
607
608    let lock_file: toml::Value = toml::from_str(&lock_content)
609        .with_context(|| format!("Failed to parse lock file: {}", lock_path.display()))?;
610
611    let mut mismatches = Vec::new();
612    let mut service_fingerprint_mismatch = None;
613
614    // Check service-level fingerprints first
615    if let Some(dependencies) = lock_file.get("dependency").and_then(|d| d.as_array()) {
616        for dep in dependencies {
617            if let Some(expected_service_fp) = dep.get("fingerprint").and_then(|f| f.as_str())
618                && expected_service_fp.starts_with("service_semantic:")
619            {
620                // Use the current fingerprint passed in
621                let expected_fp = expected_service_fp.to_string();
622                let actual_fp = current_fingerprint.to_string();
623
624                if expected_fp != actual_fp {
625                    service_fingerprint_mismatch = Some((expected_fp, actual_fp));
626                }
627                break; // Only check the first dependency for now
628            }
629        }
630    }
631
632    // Check each proto file from lock file against actual proto files
633    if let Some(dependencies) = lock_file.get("dependency").and_then(|d| d.as_array()) {
634        for dep in dependencies {
635            if let Some(files) = dep.get("files").and_then(|f| f.as_array()) {
636                for file in files {
637                    if let (Some(lock_path), Some(expected_fp)) = (
638                        file.get("path").and_then(|p| p.as_str()),
639                        file.get("fingerprint").and_then(|f| f.as_str()),
640                    ) {
641                        // Empty fingerprints in lock file are considered mismatches
642                        if expected_fp.is_empty() {
643                            mismatches.push((
644                                lock_path.to_string(),
645                                expected_fp.to_string(),
646                                "ERROR: Empty fingerprint in lock file".to_string(),
647                            ));
648                            continue;
649                        }
650
651                        // Find the corresponding proto file in our proto_files list
652                        let mut found = false;
653                        for proto_file in proto_files {
654                            if let Some(proto_path) = &proto_file.path
655                                && proto_path == lock_path
656                            {
657                                match Fingerprint::calculate_proto_semantic_fingerprint(
658                                    &proto_file.content,
659                                ) {
660                                    Ok(actual_fp) => {
661                                        if actual_fp != expected_fp {
662                                            mismatches.push((
663                                                lock_path.to_string(),
664                                                expected_fp.to_string(),
665                                                actual_fp,
666                                            ));
667                                        }
668                                    }
669                                    Err(e) => {
670                                        // Could not calculate fingerprint for this file
671                                        mismatches.push((
672                                            lock_path.to_string(),
673                                            expected_fp.to_string(),
674                                            format!("ERROR: {}", e),
675                                        ));
676                                    }
677                                }
678                                found = true;
679                                break;
680                            }
681                        }
682
683                        if !found {
684                            // Proto file not found in our list
685                            mismatches.push((
686                                lock_path.to_string(),
687                                expected_fp.to_string(),
688                                "ERROR: Proto file not found".to_string(),
689                            ));
690                        }
691                    }
692                }
693            }
694        }
695    }
696
697    // If there are service fingerprint mismatches, add them to the mismatches list
698    if let Some((expected, actual)) = service_fingerprint_mismatch {
699        mismatches.push(("SERVICE_FINGERPRINT".to_string(), expected, actual));
700    }
701
702    if mismatches.is_empty() {
703        // All proto files match
704        Ok(VerificationStatus::Passed {
705            matched_fingerprint: "all_files_verified".to_string(),
706        })
707    } else {
708        // Some proto files don't match
709        Ok(VerificationStatus::Failed { mismatches })
710    }
711}
712
713/// JSON output structure for proto files
714#[derive(serde::Serialize)]
715struct ProtoJsonOutput {
716    proto_file: String,
717    fingerprint: String,
718}