use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildState {
pub project: String,
pub platform: String,
pub link_type: String,
pub last_build_time: u64,
pub file_hashes: HashMap<String, String>,
pub cmake_cache_hash: Option<String>,
pub config_hash: String,
pub options_hash: String,
}
impl BuildState {
pub fn new(
project: String,
platform: String,
link_type: String,
config_hash: String,
options_hash: String,
) -> Self {
Self {
project,
platform,
link_type,
last_build_time: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs(),
file_hashes: HashMap::new(),
cmake_cache_hash: None,
config_hash,
options_hash,
}
}
pub fn state_file(build_dir: &Path) -> PathBuf {
build_dir.join(".ccgo_build_state.json")
}
pub fn load(build_dir: &Path) -> Result<Option<Self>> {
let state_file = Self::state_file(build_dir);
if !state_file.exists() {
return Ok(None);
}
let content =
std::fs::read_to_string(&state_file).context("Failed to read build state file")?;
let state: BuildState =
serde_json::from_str(&content).context("Failed to parse build state")?;
Ok(Some(state))
}
pub fn save(&self, build_dir: &Path) -> Result<()> {
let state_file = Self::state_file(build_dir);
std::fs::create_dir_all(build_dir).context("Failed to create build directory")?;
let json = serde_json::to_string_pretty(self).context("Failed to serialize build state")?;
std::fs::write(&state_file, json).context("Failed to write build state file")?;
Ok(())
}
pub fn can_incremental_build(
&self,
config_hash: &str,
options_hash: &str,
cmake_cache_path: &Path,
) -> bool {
if self.config_hash != config_hash || self.options_hash != options_hash {
return false;
}
if let Some(cached_hash) = &self.cmake_cache_hash {
if let Ok(current_hash) = Self::hash_file(cmake_cache_path) {
if cached_hash != ¤t_hash {
return false;
}
}
}
cmake_cache_path.exists()
}
pub fn scan_sources(&mut self, src_dir: &Path) -> Result<()> {
self.file_hashes.clear();
if !src_dir.exists() {
return Ok(());
}
for entry in WalkDir::new(src_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy();
if matches!(
ext_str.as_ref(),
"c" | "cc" | "cpp" | "cxx" | "h" | "hh" | "hpp" | "hxx"
) {
let relative_path = path
.strip_prefix(src_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
if let Ok(hash) = Self::hash_file(path) {
self.file_hashes.insert(relative_path, hash);
}
}
}
}
}
Ok(())
}
pub fn update_cmake_cache_hash(&mut self, cmake_cache_path: &Path) -> Result<()> {
if cmake_cache_path.exists() {
self.cmake_cache_hash = Some(Self::hash_file(cmake_cache_path)?);
}
Ok(())
}
fn hash_file(path: &Path) -> Result<String> {
let content = std::fs::read(path)
.with_context(|| format!("Failed to read file: {}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&content);
let hash = hasher.finalize();
Ok(format!("{:x}", hash))
}
pub fn hash_config(config_path: &Path) -> Result<String> {
Self::hash_file(config_path)
}
pub fn hash_options(options_str: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(options_str.as_bytes());
let hash = hasher.finalize();
format!("{:x}", hash)
}
}
pub struct IncrementalAnalyzer {
old_state: Option<BuildState>,
new_state: BuildState,
}
impl IncrementalAnalyzer {
pub fn new(
build_dir: &Path,
project: String,
platform: String,
link_type: String,
config_hash: String,
options_hash: String,
) -> Result<Self> {
let old_state = BuildState::load(build_dir)?;
let new_state = BuildState::new(project, platform, link_type, config_hash, options_hash);
Ok(Self {
old_state,
new_state,
})
}
pub fn can_incremental_build(&self, cmake_cache_path: &Path) -> bool {
if let Some(old) = &self.old_state {
old.can_incremental_build(
&self.new_state.config_hash,
&self.new_state.options_hash,
cmake_cache_path,
)
} else {
false
}
}
pub fn analyze_changes(&self, src_dir: &Path) -> Result<ChangeAnalysis> {
let mut analysis = ChangeAnalysis::default();
let old_state = match &self.old_state {
Some(state) => state,
None => {
analysis.full_rebuild_required = true;
analysis.reason = Some("No previous build state found".to_string());
return Ok(analysis);
}
};
if old_state.config_hash != self.new_state.config_hash {
analysis.full_rebuild_required = true;
analysis.reason = Some("CCGO.toml configuration changed".to_string());
return Ok(analysis);
}
if old_state.options_hash != self.new_state.options_hash {
analysis.full_rebuild_required = true;
analysis.reason = Some("Build options changed".to_string());
return Ok(analysis);
}
let mut current_hashes = HashMap::new();
if src_dir.exists() {
for entry in WalkDir::new(src_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy();
if matches!(
ext_str.as_ref(),
"c" | "cc" | "cpp" | "cxx" | "h" | "hh" | "hpp" | "hxx"
) {
let relative_path = path
.strip_prefix(src_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
if let Ok(hash) = BuildState::hash_file(path) {
current_hashes.insert(relative_path, hash);
}
}
}
}
}
}
for (path, current_hash) in ¤t_hashes {
match old_state.file_hashes.get(path) {
Some(old_hash) if old_hash != current_hash => {
analysis.modified_files.push(path.clone());
}
None => {
analysis.added_files.push(path.clone());
}
_ => {}
}
}
for path in old_state.file_hashes.keys() {
if !current_hashes.contains_key(path) {
analysis.removed_files.push(path.clone());
}
}
if !analysis.added_files.is_empty() || !analysis.removed_files.is_empty() {
analysis.cmake_reconfigure_needed = true;
}
Ok(analysis)
}
pub fn finalize(mut self, build_dir: &Path, src_dir: &Path) -> Result<BuildState> {
self.new_state.scan_sources(src_dir)?;
let cmake_cache = build_dir.join("CMakeCache.txt");
self.new_state.update_cmake_cache_hash(&cmake_cache)?;
self.new_state.last_build_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
self.new_state.save(build_dir)?;
Ok(self.new_state)
}
}
#[derive(Debug, Default)]
pub struct ChangeAnalysis {
pub full_rebuild_required: bool,
pub reason: Option<String>,
pub modified_files: Vec<String>,
pub added_files: Vec<String>,
pub removed_files: Vec<String>,
pub cmake_reconfigure_needed: bool,
}
impl ChangeAnalysis {
pub fn has_changes(&self) -> bool {
self.full_rebuild_required
|| !self.modified_files.is_empty()
|| !self.added_files.is_empty()
|| !self.removed_files.is_empty()
}
pub fn total_changes(&self) -> usize {
self.modified_files.len() + self.added_files.len() + self.removed_files.len()
}
pub fn print_summary(&self) {
if self.full_rebuild_required {
if let Some(reason) = &self.reason {
println!(" 🔄 Full rebuild required: {}", reason);
} else {
println!(" 🔄 Full rebuild required");
}
return;
}
if !self.has_changes() {
println!(" ✨ No source changes detected, using cached build");
return;
}
println!(
" 📊 Incremental build - {} files changed:",
self.total_changes()
);
if !self.modified_files.is_empty() {
println!(" Modified: {}", self.modified_files.len());
}
if !self.added_files.is_empty() {
println!(" Added: {}", self.added_files.len());
}
if !self.removed_files.is_empty() {
println!(" Removed: {}", self.removed_files.len());
}
if self.cmake_reconfigure_needed {
println!(" 🔧 CMake reconfiguration needed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_hash_options() {
let hash1 = BuildState::hash_options("--release");
let hash2 = BuildState::hash_options("--release");
let hash3 = BuildState::hash_options("--debug");
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
}
#[test]
fn test_build_state_save_load() {
let temp_dir = tempfile::tempdir().unwrap();
let build_dir = temp_dir.path();
let state = BuildState::new(
"test".to_string(),
"linux".to_string(),
"static".to_string(),
"config_hash".to_string(),
"options_hash".to_string(),
);
state.save(build_dir).unwrap();
let loaded = BuildState::load(build_dir).unwrap();
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.project, "test");
assert_eq!(loaded.platform, "linux");
}
#[test]
fn test_hash_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.cpp");
let mut file = std::fs::File::create(&file_path).unwrap();
file.write_all(b"int main() { return 0; }").unwrap();
drop(file);
let hash1 = BuildState::hash_file(&file_path).unwrap();
let hash2 = BuildState::hash_file(&file_path).unwrap();
assert_eq!(hash1, hash2);
assert!(!hash1.is_empty());
}
}