morph_cli/core/
fingerprint.rs1use std::collections::BTreeMap;
2use std::path::Path;
3use serde::{Serialize, Deserialize};
4use anyhow::{Context, Result};
5
6use crate::core::detection::scanner::Scanner;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct FileStatistics {
10 pub total_files: usize,
11 pub scanned_files: usize,
12 pub skipped_files: usize,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ProjectFingerprint {
17 pub timestamp: u64,
18 pub detected_frameworks: Vec<String>,
19 pub dependencies: BTreeMap<String, String>,
20 pub workspaces: Vec<String>,
21 pub file_statistics: FileStatistics,
22}
23
24const FINGERPRINT_PATH: &str = ".morph-cli/project.json";
25
26impl ProjectFingerprint {
27 pub fn generate(project_root: &Path) -> Result<Self> {
28 let mut scanner = Scanner::new(project_root.to_path_buf());
29 let result = scanner.scan();
30
31 let detected_frameworks = result.detection.frameworks.iter()
32 .map(|f| {
33 if let Some(v) = &f.version {
34 format!("{} ({})", f.name, v)
35 } else {
36 f.name.clone()
37 }
38 })
39 .collect();
40
41 let mut dependencies = BTreeMap::new();
43 let pkg_path = project_root.join("package.json");
44 if let Some(pkg) = crate::core::detection::package_json::PackageJson::load(&pkg_path) {
45 for (k, v) in pkg.dependencies {
46 dependencies.insert(k, v);
47 }
48 for (k, v) in pkg.dev_dependencies {
49 dependencies.insert(k, v);
50 }
51 }
52
53 let workspaces = result.workspace.packages.iter()
55 .map(|p| p.name.clone())
56 .collect();
57
58 let file_statistics = FileStatistics {
59 total_files: result.total_files,
60 scanned_files: result.scanned_files.len(),
61 skipped_files: result.skipped_files.len(),
62 };
63
64 let timestamp = std::time::SystemTime::now()
65 .duration_since(std::time::UNIX_EPOCH)
66 .unwrap_or_default()
67 .as_secs();
68
69 Ok(Self {
70 timestamp,
71 detected_frameworks,
72 dependencies,
73 workspaces,
74 file_statistics,
75 })
76 }
77
78 pub fn save(&self, project_root: &Path) -> Result<()> {
79 let path = project_root.join(FINGERPRINT_PATH);
80 if let Some(parent) = path.parent() {
81 std::fs::create_dir_all(parent)?;
82 }
83 let json = serde_json::to_string_pretty(self)
84 .context("Failed to serialize project fingerprint")?;
85 std::fs::write(&path, json)
86 .with_context(|| format!("Failed to write project fingerprint: {}", path.display()))?;
87 Ok(())
88 }
89
90 pub fn load(project_root: &Path) -> Result<Option<Self>> {
91 let path = project_root.join(FINGERPRINT_PATH);
92 if !path.exists() {
93 return Ok(None);
94 }
95 let content = std::fs::read_to_string(&path)
96 .with_context(|| format!("Failed to read project fingerprint: {}", path.display()))?;
97 let fingerprint = serde_json::from_str(&content)
98 .with_context(|| format!("Failed to parse project fingerprint: {}", path.display()))?;
99 Ok(Some(fingerprint))
100 }
101
102 pub fn compatibility_check(&self, current: &Self) -> Result<(), String> {
103 let self_fws: std::collections::BTreeSet<_> = self.detected_frameworks.iter().collect();
105 let current_fws: std::collections::BTreeSet<_> = current.detected_frameworks.iter().collect();
106 if self_fws != current_fws {
107 return Err(format!(
108 "Detected frameworks changed: current={:?}, recorded={:?}",
109 current.detected_frameworks, self.detected_frameworks
110 ));
111 }
112
113 let self_ws: std::collections::BTreeSet<_> = self.workspaces.iter().collect();
115 let current_ws: std::collections::BTreeSet<_> = current.workspaces.iter().collect();
116 if self_ws != current_ws {
117 return Err(format!(
118 "Workspace packages structure changed: current={:?}, recorded={:?}",
119 current.workspaces, self.workspaces
120 ));
121 }
122
123 let diff = (self.file_statistics.total_files as isize - current.file_statistics.total_files as isize).abs();
125 if self.file_statistics.total_files > 0 {
126 let percentage = (diff as f64 / self.file_statistics.total_files as f64) * 100.0;
127 if percentage > 50.0 && diff > 20 {
128 return Err(format!(
129 "File counts changed significantly: current={}, recorded={} ({:.1}% change)",
130 current.file_statistics.total_files, self.file_statistics.total_files, percentage
131 ));
132 }
133 }
134
135 Ok(())
136 }
137}