use crate::core::{Command, CommandContext, CommandResult, ComponentType};
use actr_config::ConfigParser;
use actr_version::{Fingerprint, ProtoFile};
use anyhow::{Context, Result};
use async_trait::async_trait;
use clap::Args;
use std::fs;
use std::path::Path;
use tracing::{error, info};
#[derive(Debug, Clone)]
enum VerificationStatus {
Passed {
matched_fingerprint: String,
},
Failed {
mismatches: Vec<(String, String, String)>,
}, NoLockFile,
NotRequested,
}
#[derive(Args, Debug)]
#[command(
about = "Compute project and service fingerprints",
long_about = "Compute and display semantic fingerprints for proto files and services"
)]
pub struct FingerprintCommand {
#[arg(short, long, default_value = "Actr.toml")]
pub config: String,
#[arg(long, default_value = "text")]
pub format: String,
#[arg(long)]
pub proto: Option<String>,
#[arg(long)]
pub service_level: bool,
#[arg(long)]
pub verify: bool,
}
#[async_trait]
impl Command for FingerprintCommand {
async fn execute(&self, _context: &CommandContext) -> Result<CommandResult> {
if let Some(proto_path) = &self.proto {
info!(
"🔍 Computing proto semantic fingerprint for: {}",
proto_path
);
execute_proto_fingerprint(self, proto_path).await?;
} else {
info!("🔍 Computing service semantic fingerprint...");
execute_service_fingerprint(self).await?;
}
Ok(CommandResult::Success(
"Fingerprint calculation completed".to_string(),
))
}
fn required_components(&self) -> Vec<ComponentType> {
vec![] }
fn name(&self) -> &str {
"fingerprint"
}
fn description(&self) -> &str {
"Compute semantic fingerprints for proto files and services"
}
}
async fn execute_proto_fingerprint(args: &FingerprintCommand, proto_path: &str) -> Result<()> {
let path = Path::new(proto_path);
if !path.exists() {
return Err(anyhow::anyhow!("Proto file not found: {}", proto_path));
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read proto file: {}", proto_path))?;
let fingerprint = Fingerprint::calculate_proto_semantic_fingerprint(&content)
.context("Failed to calculate proto fingerprint")?;
match args.format.as_str() {
"text" => show_proto_text_output(&fingerprint, proto_path),
"json" => show_proto_json_output(&fingerprint, proto_path)?,
"yaml" => show_proto_yaml_output(&fingerprint, proto_path)?,
_ => {
error!("Unsupported output format: {}", args.format);
return Err(anyhow::anyhow!("Unsupported format: {}", args.format));
}
}
Ok(())
}
async fn execute_service_fingerprint(args: &FingerprintCommand) -> Result<()> {
let config_path = Path::new(&args.config);
let config = ConfigParser::from_file(config_path)
.with_context(|| format!("Failed to load config from {}", args.config))?;
let mut proto_files: Vec<ProtoFile> = config
.exports
.iter()
.map(|pf| ProtoFile {
name: pf.file_name().unwrap_or("unknown.proto").to_string(),
content: pf.content.clone(),
path: Some(pf.path.to_string_lossy().to_string()),
})
.collect();
if args.verify {
let config_dir = config_path.parent().unwrap_or(Path::new("."));
let remote_dir = config_dir.join("protos").join("remote");
if remote_dir.exists() {
collect_proto_files_from_directory(&remote_dir, &mut proto_files)?;
}
}
if proto_files.is_empty() {
if args.verify {
let verification_status = verify_fingerprint_against_lock("", &[], config_path)?;
match args.format.as_str() {
"text" => {
show_verification_status_only(&verification_status);
}
"json" => {
let verification_info = match verification_status {
VerificationStatus::Passed { .. } => {
serde_json::json!({"status": "passed"})
}
VerificationStatus::Failed { mismatches } => serde_json::json!({
"status": "failed",
"mismatches": mismatches.iter().map(|(file_path, expected, actual)| {
serde_json::json!({
"file_path": file_path,
"expected": expected,
"actual": actual
})
}).collect::<Vec<_>>()
}),
VerificationStatus::NoLockFile => {
serde_json::json!({"status": "no_lock_file"})
}
_ => serde_json::json!({"status": "not_requested"}),
};
println!(
"{}",
serde_json::to_string_pretty(&verification_info).unwrap()
);
}
"yaml" => {
let verification_info = match verification_status {
VerificationStatus::Passed { .. } => {
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("passed".to_string()),
);
map
}
VerificationStatus::Failed { mismatches } => {
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("failed".to_string()),
);
map.insert(
serde_yaml::Value::String("mismatches".to_string()),
serde_yaml::Value::Sequence(
mismatches
.iter()
.map(|(file_path, expected, actual)| {
let mut mismatch_map = serde_yaml::Mapping::new();
mismatch_map.insert(
serde_yaml::Value::String("file_path".to_string()),
serde_yaml::Value::String(file_path.clone()),
);
mismatch_map.insert(
serde_yaml::Value::String("expected".to_string()),
serde_yaml::Value::String(expected.clone()),
);
mismatch_map.insert(
serde_yaml::Value::String("actual".to_string()),
serde_yaml::Value::String(actual.clone()),
);
serde_yaml::Value::Mapping(mismatch_map)
})
.collect(),
),
);
map
}
VerificationStatus::NoLockFile => {
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("no_lock_file".to_string()),
);
map
}
_ => {
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("not_requested".to_string()),
);
map
}
};
println!(
"{}",
serde_yaml::to_string(&serde_yaml::Value::Mapping(verification_info))
.unwrap()
);
}
_ => {
show_verification_status_only(&verification_status);
}
}
} else {
match args.format.as_str() {
"text" => {
println!("ℹ️ No proto files found in exports");
println!(
" Add proto files to the 'exports' array in {} to calculate fingerprints",
args.config
);
}
"json" => {
let output = serde_json::json!({
"status": "no_exports",
"message": "No proto files found in exports",
"config_file": args.config
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
"yaml" => {
let output = serde_yaml::Value::Mapping({
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("no_exports".to_string()),
);
map.insert(
serde_yaml::Value::String("message".to_string()),
serde_yaml::Value::String(
"No proto files found in exports".to_string(),
),
);
map.insert(
serde_yaml::Value::String("config_file".to_string()),
serde_yaml::Value::String(args.config.clone()),
);
map
});
println!("{}", serde_yaml::to_string(&output).unwrap());
}
_ => {
println!("ℹ️ No proto files found in exports");
}
}
}
return Ok(());
}
let fingerprint = Fingerprint::calculate_service_semantic_fingerprint(&proto_files)
.context("Failed to calculate service fingerprint")?;
let verification_status = if args.verify {
verify_fingerprint_against_lock(&fingerprint, &proto_files, config_path)?
} else {
VerificationStatus::NotRequested
};
match args.format.as_str() {
"text" => show_text_output(&fingerprint, &proto_files, &verification_status),
"json" => show_json_output(&fingerprint, &proto_files, &verification_status)?,
"yaml" => show_yaml_output(&fingerprint, &proto_files, &verification_status)?,
_ => {
error!("Unsupported output format: {}", args.format);
return Err(anyhow::anyhow!("Unsupported format: {}", args.format));
}
}
Ok(())
}
fn show_proto_text_output(fingerprint: &str, proto_path: &str) {
println!("📋 Proto Semantic Fingerprint:");
println!(" File: {}", proto_path);
println!(" {fingerprint}");
}
fn show_proto_json_output(fingerprint: &str, proto_path: &str) -> Result<()> {
let output = ProtoJsonOutput {
proto_file: proto_path.to_string(),
fingerprint: fingerprint.to_string(),
};
let json = serde_json::to_string_pretty(&output).context("Failed to serialize output")?;
println!("{json}");
Ok(())
}
fn show_proto_yaml_output(fingerprint: &str, proto_path: &str) -> Result<()> {
let output = ProtoJsonOutput {
proto_file: proto_path.to_string(),
fingerprint: fingerprint.to_string(),
};
let yaml = serde_yaml::to_string(&output).context("Failed to serialize output")?;
println!("{yaml}");
Ok(())
}
fn collect_proto_files_from_directory(dir: &Path, proto_files: &mut Vec<ProtoFile>) -> Result<()> {
fn visit_dir(dir: &Path, proto_files: &mut Vec<ProtoFile>, base_dir: &Path) -> Result<()> {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
visit_dir(&path, proto_files, base_dir)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("proto") {
let content = fs::read_to_string(&path).with_context(|| {
format!("Failed to read proto file: {}", path.display())
})?;
let relative_path = path.strip_prefix(base_dir).unwrap_or(&path);
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown.proto")
.to_string();
proto_files.push(ProtoFile {
name,
content,
path: Some(relative_path.to_string_lossy().to_string()),
});
}
}
}
Ok(())
}
visit_dir(dir, proto_files, dir)
}
fn show_verification_status_only(verification_status: &VerificationStatus) {
match verification_status {
VerificationStatus::Passed { .. } => {
println!("✅ Fingerprint verification: PASSED");
println!(" All lock file fingerprints verified against actual files");
}
VerificationStatus::Failed { mismatches } => {
println!("❌ Fingerprint verification: FAILED");
println!(" File-level mismatches:");
for (file_path, expected, actual) in mismatches {
println!(" File: {}", file_path);
println!(" Expected: {}", expected);
println!(" Actual: {}", actual);
}
}
VerificationStatus::NoLockFile => {
println!("⚠️ Fingerprint verification: No lock file found");
}
VerificationStatus::NotRequested => {
}
}
}
fn show_text_output(
fingerprint: &str,
proto_files: &[ProtoFile],
verification_status: &VerificationStatus,
) {
println!("📋 Service Semantic Fingerprint:");
println!(" {fingerprint}");
println!("\n📦 Proto Files ({}):", proto_files.len());
for pf in proto_files {
println!(" - {}", pf.name);
}
match verification_status {
VerificationStatus::Passed {
matched_fingerprint: _,
} => {
println!("\n✅ Fingerprint verification: PASSED");
println!(" All lock file fingerprints verified against actual files");
}
VerificationStatus::Failed { mismatches } => {
println!("\n❌ Fingerprint verification: FAILED");
println!(" File-level mismatches:");
for (file_path, expected, actual) in mismatches {
println!(" File: {}", file_path);
println!(" Expected: {}", expected);
println!(" Actual: {}", actual);
}
}
VerificationStatus::NoLockFile => {
println!("\n⚠️ Fingerprint verification: No lock file found");
}
VerificationStatus::NotRequested => {
}
}
}
fn show_json_output(
fingerprint: &str,
proto_files: &[ProtoFile],
verification_status: &VerificationStatus,
) -> Result<()> {
let verification_info = match verification_status {
VerificationStatus::Passed {
matched_fingerprint,
} => serde_json::json!({
"status": "passed",
"matched_fingerprint": matched_fingerprint
}),
VerificationStatus::Failed { mismatches } => serde_json::json!({
"status": "failed",
"mismatches": mismatches.iter().map(|(file_path, expected, actual)| {
serde_json::json!({
"file_path": file_path,
"expected": expected,
"actual": actual
})
}).collect::<Vec<_>>()
}),
VerificationStatus::NoLockFile => serde_json::json!({
"status": "no_lock_file"
}),
VerificationStatus::NotRequested => serde_json::json!({
"status": "not_requested"
}),
};
let output = serde_json::json!({
"service_fingerprint": fingerprint,
"proto_files": proto_files.iter().map(|pf| pf.name.clone()).collect::<Vec<_>>(),
"verification": verification_info
});
let json = serde_json::to_string_pretty(&output).context("Failed to serialize output")?;
println!("{json}");
Ok(())
}
fn show_yaml_output(
fingerprint: &str,
proto_files: &[ProtoFile],
verification_status: &VerificationStatus,
) -> Result<()> {
let verification_info = match verification_status {
VerificationStatus::Passed {
matched_fingerprint,
} => serde_yaml::Value::Mapping({
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("passed".to_string()),
);
map.insert(
serde_yaml::Value::String("matched_fingerprint".to_string()),
serde_yaml::Value::String(matched_fingerprint.clone()),
);
map
}),
VerificationStatus::Failed { mismatches } => serde_yaml::Value::Mapping({
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("failed".to_string()),
);
map.insert(
serde_yaml::Value::String("mismatches".to_string()),
serde_yaml::Value::Sequence(
mismatches
.iter()
.map(|(file_path, expected, actual)| {
let mut mismatch_map = serde_yaml::Mapping::new();
mismatch_map.insert(
serde_yaml::Value::String("file_path".to_string()),
serde_yaml::Value::String(file_path.clone()),
);
mismatch_map.insert(
serde_yaml::Value::String("expected".to_string()),
serde_yaml::Value::String(expected.clone()),
);
mismatch_map.insert(
serde_yaml::Value::String("actual".to_string()),
serde_yaml::Value::String(actual.clone()),
);
serde_yaml::Value::Mapping(mismatch_map)
})
.collect(),
),
);
map
}),
VerificationStatus::NoLockFile => serde_yaml::Value::Mapping({
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("no_lock_file".to_string()),
);
map
}),
VerificationStatus::NotRequested => serde_yaml::Value::Mapping({
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("status".to_string()),
serde_yaml::Value::String("not_requested".to_string()),
);
map
}),
};
let output = serde_yaml::Value::Mapping({
let mut map = serde_yaml::Mapping::new();
map.insert(
serde_yaml::Value::String("service_fingerprint".to_string()),
serde_yaml::Value::String(fingerprint.to_string()),
);
map.insert(
serde_yaml::Value::String("proto_files".to_string()),
serde_yaml::Value::Sequence(
proto_files
.iter()
.map(|pf| serde_yaml::Value::String(pf.name.clone()))
.collect(),
),
);
map.insert(
serde_yaml::Value::String("verification".to_string()),
verification_info,
);
map
});
let yaml = serde_yaml::to_string(&output).context("Failed to serialize output")?;
println!("{yaml}");
Ok(())
}
fn verify_fingerprint_against_lock(
current_fingerprint: &str,
proto_files: &[ProtoFile],
config_path: &Path,
) -> Result<VerificationStatus> {
let lock_path = config_path.with_file_name("actr.lock.toml");
if !lock_path.exists() {
return Ok(VerificationStatus::NoLockFile);
}
let lock_content = fs::read_to_string(&lock_path)
.with_context(|| format!("Failed to read lock file: {}", lock_path.display()))?;
let lock_file: toml::Value = toml::from_str(&lock_content)
.with_context(|| format!("Failed to parse lock file: {}", lock_path.display()))?;
let mut mismatches = Vec::new();
let mut service_fingerprint_mismatch = None;
if let Some(dependencies) = lock_file.get("dependency").and_then(|d| d.as_array()) {
for dep in dependencies {
if let Some(expected_service_fp) = dep.get("fingerprint").and_then(|f| f.as_str())
&& expected_service_fp.starts_with("service_semantic:")
{
let expected_fp = expected_service_fp.to_string();
let actual_fp = current_fingerprint.to_string();
if expected_fp != actual_fp {
service_fingerprint_mismatch = Some((expected_fp, actual_fp));
}
break; }
}
}
if let Some(dependencies) = lock_file.get("dependency").and_then(|d| d.as_array()) {
for dep in dependencies {
if let Some(files) = dep.get("files").and_then(|f| f.as_array()) {
for file in files {
if let (Some(lock_path), Some(expected_fp)) = (
file.get("path").and_then(|p| p.as_str()),
file.get("fingerprint").and_then(|f| f.as_str()),
) {
if expected_fp.is_empty() {
mismatches.push((
lock_path.to_string(),
expected_fp.to_string(),
"ERROR: Empty fingerprint in lock file".to_string(),
));
continue;
}
let mut found = false;
for proto_file in proto_files {
if let Some(proto_path) = &proto_file.path
&& proto_path == lock_path
{
match Fingerprint::calculate_proto_semantic_fingerprint(
&proto_file.content,
) {
Ok(actual_fp) => {
if actual_fp != expected_fp {
mismatches.push((
lock_path.to_string(),
expected_fp.to_string(),
actual_fp,
));
}
}
Err(e) => {
mismatches.push((
lock_path.to_string(),
expected_fp.to_string(),
format!("ERROR: {}", e),
));
}
}
found = true;
break;
}
}
if !found {
mismatches.push((
lock_path.to_string(),
expected_fp.to_string(),
"ERROR: Proto file not found".to_string(),
));
}
}
}
}
}
}
if let Some((expected, actual)) = service_fingerprint_mismatch {
mismatches.push(("SERVICE_FINGERPRINT".to_string(), expected, actual));
}
if mismatches.is_empty() {
Ok(VerificationStatus::Passed {
matched_fingerprint: "all_files_verified".to_string(),
})
} else {
Ok(VerificationStatus::Failed { mismatches })
}
}
#[derive(serde::Serialize)]
struct ProtoJsonOutput {
proto_file: String,
fingerprint: String,
}