1use 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#[derive(Debug, Clone)]
17enum VerificationStatus {
18 Passed {
19 matched_fingerprint: String,
20 },
21 Failed {
22 mismatches: Vec<(String, String, String)>,
23 }, NoLockFile,
25 NotRequested,
26}
27
28#[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 #[arg(short, long, default_value = "Actr.toml")]
37 pub config: String,
38
39 #[arg(long, default_value = "text")]
41 pub format: String,
42
43 #[arg(long)]
45 pub proto: Option<String>,
46
47 #[arg(long)]
49 pub service_level: bool,
50
51 #[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 info!(
62 "š Computing proto semantic fingerprint for: {}",
63 proto_path
64 );
65 execute_proto_fingerprint(self, proto_path).await?;
66 } else {
67 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![] }
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
90async 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 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
117async fn execute_service_fingerprint(args: &FingerprintCommand) -> Result<()> {
119 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 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 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 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 let fingerprint = Fingerprint::calculate_service_semantic_fingerprint(&proto_files)
295 .context("Failed to calculate service fingerprint")?;
296
297 let verification_status = if args.verify {
299 verify_fingerprint_against_lock(&fingerprint, &proto_files, config_path)?
300 } else {
301 VerificationStatus::NotRequested
302 };
303
304 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
318fn show_proto_text_output(fingerprint: &str, proto_path: &str) {
320 println!("š Proto Semantic Fingerprint:");
321 println!(" File: {}", proto_path);
322 println!(" {fingerprint}");
323}
324
325fn 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
338fn 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
351fn 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 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
388fn 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 }
410 }
411}
412
413fn 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 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 }
449 }
450}
451
452fn 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
495fn 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
594fn 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 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 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; }
629 }
630 }
631
632 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 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 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 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 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 let Some((expected, actual)) = service_fingerprint_mismatch {
699 mismatches.push(("SERVICE_FINGERPRINT".to_string(), expected, actual));
700 }
701
702 if mismatches.is_empty() {
703 Ok(VerificationStatus::Passed {
705 matched_fingerprint: "all_files_verified".to_string(),
706 })
707 } else {
708 Ok(VerificationStatus::Failed { mismatches })
710 }
711}
712
713#[derive(serde::Serialize)]
715struct ProtoJsonOutput {
716 proto_file: String,
717 fingerprint: String,
718}