use std::collections::BTreeMap;
use std::path::Path;
use serde::{Serialize, Deserialize};
use anyhow::{Context, Result};
use crate::core::detection::scanner::Scanner;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileStatistics {
pub total_files: usize,
pub scanned_files: usize,
pub skipped_files: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectFingerprint {
pub timestamp: u64,
pub detected_frameworks: Vec<String>,
pub dependencies: BTreeMap<String, String>,
pub workspaces: Vec<String>,
pub file_statistics: FileStatistics,
}
const FINGERPRINT_PATH: &str = ".morph-cli/project.json";
impl ProjectFingerprint {
pub fn generate(project_root: &Path) -> Result<Self> {
let mut scanner = Scanner::new(project_root.to_path_buf());
let result = scanner.scan();
let detected_frameworks = result.detection.frameworks.iter()
.map(|f| {
if let Some(v) = &f.version {
format!("{} ({})", f.name, v)
} else {
f.name.clone()
}
})
.collect();
let mut dependencies = BTreeMap::new();
let pkg_path = project_root.join("package.json");
if let Some(pkg) = crate::core::detection::package_json::PackageJson::load(&pkg_path) {
for (k, v) in pkg.dependencies {
dependencies.insert(k, v);
}
for (k, v) in pkg.dev_dependencies {
dependencies.insert(k, v);
}
}
let workspaces = result.workspace.packages.iter()
.map(|p| p.name.clone())
.collect();
let file_statistics = FileStatistics {
total_files: result.total_files,
scanned_files: result.scanned_files.len(),
skipped_files: result.skipped_files.len(),
};
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Ok(Self {
timestamp,
detected_frameworks,
dependencies,
workspaces,
file_statistics,
})
}
pub fn save(&self, project_root: &Path) -> Result<()> {
let path = project_root.join(FINGERPRINT_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)
.context("Failed to serialize project fingerprint")?;
std::fs::write(&path, json)
.with_context(|| format!("Failed to write project fingerprint: {}", path.display()))?;
Ok(())
}
pub fn load(project_root: &Path) -> Result<Option<Self>> {
let path = project_root.join(FINGERPRINT_PATH);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read project fingerprint: {}", path.display()))?;
let fingerprint = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse project fingerprint: {}", path.display()))?;
Ok(Some(fingerprint))
}
pub fn compatibility_check(&self, current: &Self) -> Result<(), String> {
let self_fws: std::collections::BTreeSet<_> = self.detected_frameworks.iter().collect();
let current_fws: std::collections::BTreeSet<_> = current.detected_frameworks.iter().collect();
if self_fws != current_fws {
return Err(format!(
"Detected frameworks changed: current={:?}, recorded={:?}",
current.detected_frameworks, self.detected_frameworks
));
}
let self_ws: std::collections::BTreeSet<_> = self.workspaces.iter().collect();
let current_ws: std::collections::BTreeSet<_> = current.workspaces.iter().collect();
if self_ws != current_ws {
return Err(format!(
"Workspace packages structure changed: current={:?}, recorded={:?}",
current.workspaces, self.workspaces
));
}
let diff = (self.file_statistics.total_files as isize - current.file_statistics.total_files as isize).abs();
if self.file_statistics.total_files > 0 {
let percentage = (diff as f64 / self.file_statistics.total_files as f64) * 100.0;
if percentage > 50.0 && diff > 20 {
return Err(format!(
"File counts changed significantly: current={}, recorded={} ({:.1}% change)",
current.file_statistics.total_files, self.file_statistics.total_files, percentage
));
}
}
Ok(())
}
}