use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use crate::utils::logger;
use crate::utils::error::{SolarboatError, SafeOperations};
#[derive(Debug, Default)]
pub struct Module {
depends_on: Vec<String>,
used_by: Vec<String>,
is_stateful: bool,
}
pub fn get_changed_modules_clean(root_dir: &str, all: bool, default_branch: &str, recent_commits: u32) -> Result<Vec<String>, String> {
let mut modules = HashMap::new();
logger::dependency_graph_progress("Discovering modules...");
discover_modules(root_dir, &mut modules)?;
logger::dependency_graph_progress("Building dependency graph...");
build_dependency_graph(&mut modules)?;
if all {
let stateful_modules: Vec<String> = modules
.iter()
.filter(|(_, module)| module.is_stateful)
.map(|(path, _)| path.clone())
.collect();
return Ok(stateful_modules);
}
let current_branch = get_current_branch(root_dir)?;
let is_on_main = current_branch == default_branch;
if is_on_main {
logger::environment_detection("branch", &format!("Currently on {} branch - using enhanced change detection", current_branch));
if let Ok(pr_number) = std::env::var("SOLARBOAT_PR_NUMBER") {
if !pr_number.is_empty() {
logger::environment_detection("pipeline", &format!("Detected CD pipeline environment (PR #{})", pr_number));
let changed_files = get_cd_pipeline_changes(root_dir, &pr_number, default_branch)?;
let affected_modules = process_changed_modules(&changed_files, &mut modules)?;
if affected_modules.is_empty() {
logger::info(&format!("No changes detected in PR #{}", pr_number));
}
return Ok(affected_modules);
}
}
logger::environment_detection("local", &format!("Running in local environment - checking last {} commits", recent_commits));
let changed_files = get_main_branch_changes_local_clean(root_dir, recent_commits)?;
let affected_modules = process_changed_modules(&changed_files, &mut modules)?;
logger::git_analysis_summary(recent_commits as usize, changed_files.len(), affected_modules.len());
if affected_modules.is_empty() {
logger::info("No changes detected on main branch. This could mean:");
logger::info(" • No recent commits with .tf changes");
logger::info(" • Changes were already applied");
logger::info(" • Use --all flag to process all modules");
}
return Ok(affected_modules);
}
let changed_files = get_git_changed_files(".", default_branch)?;
let affected_modules = process_changed_modules(&changed_files, &mut modules)?;
if root_dir != "." {
logger::info(&format!("Filtering modules with path: {}", root_dir));
let filtered_modules: Vec<String> = affected_modules
.into_iter()
.filter(|path| {
let contains_path = path.contains(&format!("/{}/", root_dir)) ||
path.ends_with(&format!("/{}", root_dir));
contains_path
})
.collect();
return Ok(filtered_modules);
}
Ok(affected_modules)
}
pub fn discover_modules(root_dir: &str, modules: &mut HashMap<String, Module>) -> Result<(), String> {
for entry in fs::read_dir(root_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
discover_modules(path.to_str().ok_or("Invalid path")?, modules)?;
let tf_files: Vec<_> = fs::read_dir(&path)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "tf"))
.collect();
if !tf_files.is_empty() {
let abs_path = fs::canonicalize(&path).map_err(|e| e.to_string())?;
let abs_path_str = abs_path.to_str().ok_or("Invalid path")?.to_string();
modules.entry(abs_path_str.clone()).or_insert(Module {
is_stateful: has_backend_config(&tf_files),
..Default::default()
});
}
}
}
Ok(())
}
pub fn build_dependency_graph(modules: &mut HashMap<String, Module>) -> Result<(), String> {
let dependencies = collect_dependencies(modules)?;
for (path, dep) in dependencies {
if let Some(module) = modules.get_mut(&path) {
module.depends_on.push(dep.clone());
}
if let Some(dep_module) = modules.get_mut(&dep) {
dep_module.used_by.push(path.clone());
}
}
logger::info(&format!("Found {} modules repo-wide", modules.len()));
Ok(())
}
pub fn collect_dependencies(modules: &HashMap<String, Module>) -> Result<Vec<(String, String)>, String> {
let mut dependencies = Vec::new();
for (path, _module) in modules {
let tf_files: Vec<_> = fs::read_dir(path)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "tf"))
.collect();
for file in tf_files {
let content = fs::read_to_string(file.path()).map_err(|e| e.to_string())?;
let deps = find_module_dependencies(&content, path);
for dep in deps {
dependencies.push((path.clone(), dep));
}
}
}
Ok(dependencies)
}
pub fn find_module_dependencies(content: &str, current_dir: &str) -> Vec<String> {
let mut deps = Vec::new();
let lines: Vec<&str> = content.lines().collect();
let mut in_module_block = false;
for line in lines {
let trimmed_line = line.trim();
if trimmed_line.starts_with("module") && trimmed_line.contains("{") {
in_module_block = true;
continue;
}
if in_module_block {
if trimmed_line.contains("source") {
let parts: Vec<&str> = trimmed_line.split('=').collect();
if parts.len() == 2 {
let source = parts[1].trim().trim_matches(|c| c == '"' || c == '\'');
let module_path = Path::new(current_dir).join(source);
if let Ok(abs_path) = fs::canonicalize(module_path) {
if let Some(abs_path_str) = abs_path.to_str() {
deps.push(abs_path_str.to_string());
}
}
}
}
if trimmed_line.contains("}") {
in_module_block = false;
}
}
}
deps
}
pub fn has_backend_config(tf_files: &[fs::DirEntry]) -> bool {
let has_module_blocks = tf_files.iter().any(|file| {
if let Ok(content) = fs::read_to_string(file.path()) {
let lines: Vec<&str> = content.lines().collect();
for line in lines {
let trimmed_line = line.trim();
if trimmed_line.starts_with("module") && trimmed_line.contains("{") {
return true;
}
}
}
false
});
if has_module_blocks {
return true;
}
for file in tf_files {
if let Ok(content) = fs::read_to_string(file.path()) {
let lines: Vec<&str> = content.lines().collect();
let mut in_terraform_block = false;
let mut brace_count = 0;
for line in lines {
let trimmed_line = line.trim();
if trimmed_line.is_empty() || trimmed_line.starts_with('#') || trimmed_line.starts_with("//") {
continue;
}
if trimmed_line.starts_with("terraform") && trimmed_line.contains("{") {
in_terraform_block = true;
brace_count += 1;
continue;
}
if in_terraform_block && trimmed_line.starts_with("backend") && trimmed_line.contains("\"") {
return true;
}
if trimmed_line.contains("{") {
brace_count += 1;
}
if trimmed_line.contains("}") {
brace_count -= 1;
if brace_count == 0 {
in_terraform_block = false;
}
}
}
}
}
if let Some(first_file) = tf_files.first() {
if let Some(dir_path) = first_file.path().parent() {
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "tfstate") {
return true;
}
}
}
}
}
false
}
fn get_current_branch(root_dir: &str) -> Result<String, String> {
if let Ok(branch) = std::env::var("GITHUB_REF_NAME") {
return Ok(branch);
}
let output = Command::new("git")
.args(&["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err("Failed to get current branch".to_string())
}
}
fn get_main_branch_changes_local_clean(root_dir: &str, recent_commits: u32) -> Result<Vec<String>, String> {
let mut total_changes = Vec::new();
let recent_changes = get_recent_commit_changes_clean(root_dir, recent_commits as usize)?;
total_changes.extend(recent_changes);
if !total_changes.is_empty() {
logger::info("Found changes in recent commits");
return Ok(total_changes);
}
let uncommitted_changes = get_uncommitted_changes(root_dir)?;
if !uncommitted_changes.is_empty() {
logger::info("Found uncommitted changes");
total_changes.extend(uncommitted_changes);
return Ok(total_changes);
}
let reference_changes = get_reference_changes(root_dir)?;
if !reference_changes.is_empty() {
logger::info("Found changes compared to reference point");
total_changes.extend(reference_changes);
return Ok(total_changes);
}
logger::info("No changes detected using any strategy");
Ok(Vec::new())
}
#[allow(dead_code)]
fn get_main_branch_changes_local(root_dir: &str, recent_commits: u32) -> Result<Vec<String>, String> {
let recent_changes = get_recent_commit_changes(root_dir, recent_commits as usize)?;
if !recent_changes.is_empty() {
logger::info("Found changes in recent commits");
return Ok(recent_changes);
}
let uncommitted_changes = get_uncommitted_changes(root_dir)?;
if !uncommitted_changes.is_empty() {
logger::info("Found uncommitted changes");
return Ok(uncommitted_changes);
}
let reference_changes = get_reference_changes(root_dir)?;
if !reference_changes.is_empty() {
logger::info("Found changes compared to reference point");
return Ok(reference_changes);
}
logger::info("No changes detected using any strategy");
Ok(Vec::new())
}
fn get_cd_pipeline_changes(root_dir: &str, pr_number: &str, default_branch: &str) -> Result<Vec<String>, String> {
logger::info(&format!("Analyzing changes for PR #{} against {}", pr_number, default_branch));
let pipeline_changes = get_pipeline_supplied_changes(root_dir, pr_number);
match pipeline_changes {
Ok(changes) if !changes.is_empty() => {
logger::info("Found changes using pipeline-supplied commits");
return Ok(changes);
}
Ok(_) => {
logger::info("Pipeline-supplied commits found but no changes detected");
return Ok(Vec::new());
}
Err(_) => {
logger::info("No pipeline-supplied commits available, using fallback strategies");
}
}
if let Ok(changes) = get_pr_changes(root_dir, pr_number, default_branch) {
if !changes.is_empty() {
logger::info("Found changes using merge base detection (fallback)");
return Ok(changes);
}
}
let recent_changes = get_recent_commit_changes(root_dir, 10)?;
if !recent_changes.is_empty() {
logger::info("Found changes in recent commits (fallback)");
return Ok(recent_changes);
}
let uncommitted_changes = get_uncommitted_changes(root_dir)?;
if !uncommitted_changes.is_empty() {
logger::info("Found uncommitted changes");
return Ok(uncommitted_changes);
}
logger::info(&format!("No changes detected for PR #{}", pr_number));
Ok(Vec::new())
}
fn get_pipeline_supplied_changes(root_dir: &str, _pr_number: &str) -> Result<Vec<String>, String> {
let base_commit = std::env::var("SOLARBOAT_BASE_COMMIT").ok();
let head_commit = std::env::var("SOLARBOAT_HEAD_COMMIT").ok();
let base_branch = std::env::var("SOLARBOAT_BASE_BRANCH").ok();
let head_branch = std::env::var("SOLARBOAT_HEAD_BRANCH").ok();
if let (Some(base), Some(head)) = (base_commit.clone(), head_commit.clone()) {
logger::info("Using pipeline-supplied commits:");
logger::info(&format!(" • Base commit: {}", base));
logger::info(&format!(" • Head commit: {}", head));
if let Some(base_branch) = base_branch.clone() {
logger::info(&format!(" • Base branch: {}", base_branch));
}
if let Some(head_branch) = head_branch.clone() {
logger::info(&format!(" • Head branch: {}", head_branch));
}
return get_changes_between_commits(root_dir, &base, &head);
}
if let Some(base) = base_commit {
logger::info(&format!("Using pipeline-supplied base commit: {}", base));
return get_changes_between_commits(root_dir, &base, "HEAD");
}
if let Some(head) = head_commit {
logger::info(&format!("Using pipeline-supplied head commit: {}", head));
return get_changes_between_commits(root_dir, "main", &head);
}
logger::info("No pipeline-supplied commits found, falling back to merge base detection");
Ok(Vec::new()) }
fn get_pr_changes(root_dir: &str, pr_number: &str, default_branch: &str) -> Result<Vec<String>, String> {
let merge_base_output = Command::new("git")
.args(&["merge-base", default_branch, "HEAD"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if merge_base_output.status.success() {
let merge_base = String::from_utf8_lossy(&merge_base_output.stdout).trim().to_string();
logger::info(&format!("Using merge base: {}", merge_base));
return get_changes_between_commits(root_dir, &merge_base, "HEAD");
}
let origin_merge_base_output = Command::new("git")
.args(&["merge-base", &format!("origin/{}", default_branch), "HEAD"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if origin_merge_base_output.status.success() {
let merge_base = String::from_utf8_lossy(&origin_merge_base_output.stdout).trim().to_string();
logger::info(&format!("Using origin merge base: {}", merge_base));
return get_changes_between_commits(root_dir, &merge_base, "HEAD");
}
logger::warn(&format!("Could not determine merge base for PR #{}", pr_number));
Ok(Vec::new())
}
fn get_recent_commit_changes_clean(root_dir: &str, commit_count: usize) -> Result<Vec<String>, String> {
let mut changed_files = Vec::new();
logger::info(&format!("Getting changes from last {} commits", commit_count));
let log_output = Command::new("git")
.args(&["log", "--oneline", "-n", &commit_count.to_string()])
.current_dir(root_dir)
.output()
.map_err(|e| format!("Failed to execute git log: {}", e))?;
if log_output.status.success() {
let output_str = String::from_utf8_lossy(&log_output.stdout);
let commits: Vec<&str> = output_str
.lines()
.filter_map(|line| line.split_whitespace().next())
.collect();
if commits.len() >= 2 {
let from_commit = commits.last().unwrap();
let to_commit = commits.first().unwrap();
changed_files = get_changes_between_commits_clean(root_dir, from_commit, to_commit)
.map_err(|e| format!("Failed to get changes between commits: {}", e))?;
}
}
logger::git_changes_progress(&format!("last {} commits", commit_count), changed_files.len(), &changed_files);
Ok(changed_files)
}
fn get_recent_commit_changes(root_dir: &str, commit_count: usize) -> Result<Vec<String>, String> {
let mut changed_files = Vec::new();
let log_output = Command::new("git")
.args(&["log", "--oneline", "-n", &commit_count.to_string()])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if !log_output.status.success() {
return Ok(Vec::new());
}
let log_output_str = String::from_utf8_lossy(&log_output.stdout);
let commits: Vec<&str> = log_output_str
.lines()
.filter_map(|line| line.split_whitespace().next())
.collect();
for commit in commits {
let changes = get_changes_between_commits(root_dir, &format!("{}~1", commit), commit)?;
changed_files.extend(changes);
}
changed_files.sort();
changed_files.dedup();
Ok(changed_files)
}
fn get_uncommitted_changes(root_dir: &str) -> Result<Vec<String>, String> {
let mut changed_files = Vec::new();
let staged_output = Command::new("git")
.args(&["diff", "--cached", "--name-only"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if staged_output.status.success() {
changed_files.extend(
String::from_utf8_lossy(&staged_output.stdout)
.lines()
.filter(|line| line.ends_with(".tf"))
.map(|line| Path::new(root_dir).join(line).to_string_lossy().to_string())
);
}
let unstaged_output = Command::new("git")
.args(&["diff", "--name-only"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if unstaged_output.status.success() {
changed_files.extend(
String::from_utf8_lossy(&unstaged_output.stdout)
.lines()
.filter(|line| line.ends_with(".tf"))
.map(|line| Path::new(root_dir).join(line).to_string_lossy().to_string())
);
}
changed_files.sort();
changed_files.dedup();
Ok(changed_files)
}
fn get_reference_changes(root_dir: &str) -> Result<Vec<String>, String> {
let tag_output = Command::new("git")
.args(&["describe", "--tags", "--abbrev=0"])
.current_dir(root_dir)
.output();
if let Ok(output) = tag_output {
if output.status.success() {
let tag = String::from_utf8_lossy(&output.stdout).trim().to_string();
logger::info(&format!("Comparing with last tag: {}", tag));
return get_changes_between_commits(root_dir, &tag, "HEAD");
}
}
let date_output = Command::new("git")
.args(&["rev-list", "-n", "1", "--before=1 day ago", "HEAD"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if date_output.status.success() {
let commit = String::from_utf8_lossy(&date_output.stdout).trim().to_string();
if !commit.is_empty() {
logger::info(&format!("Comparing with commit from 1 day ago: {}", commit));
return get_changes_between_commits(root_dir, &commit, "HEAD");
}
}
Ok(Vec::new())
}
fn get_changes_between_commits_clean(root_dir: &str, from_commit: &str, to_commit: &str) -> Result<Vec<String>, SolarboatError> {
let mut changed_files = Vec::new();
logger::info(&format!("Getting changes between {} and {}", from_commit, to_commit));
let diff_output = Command::new("git")
.args(&["diff", "--name-only", from_commit, to_commit])
.current_dir(root_dir)
.output()
.map_err(|e| SolarboatError::Process {
command: "git diff".to_string(),
args: vec!["diff".to_string(), "--name-only".to_string(), from_commit.to_string(), to_commit.to_string()],
cause: e.to_string(),
exit_code: None,
})?;
if diff_output.status.success() {
changed_files.extend(
String::from_utf8_lossy(&diff_output.stdout)
.lines()
.filter(|line| line.ends_with(".tf"))
.filter_map(|line| {
let file_path = Path::new(root_dir).join(line);
if file_path.exists() {
SafeOperations::canonicalize(&file_path)
.and_then(|canonical_path| SafeOperations::os_str_to_string(canonical_path.as_os_str()))
.ok()
} else {
let current_dir = SafeOperations::current_dir().ok()?;
let path = current_dir.join(root_dir).join(line);
SafeOperations::os_str_to_string(path.as_os_str()).ok()
}
})
);
}
changed_files.sort();
changed_files.dedup();
if !changed_files.is_empty() {
logger::info(&format!("Found {} changed .tf files", changed_files.len()));
logger::changed_files_summary(&changed_files);
} else {
logger::info("No .tf files changed between the commits");
}
Ok(changed_files)
}
fn get_changes_between_commits(root_dir: &str, from_commit: &str, to_commit: &str) -> Result<Vec<String>, String> {
let mut changed_files = Vec::new();
logger::info(&format!("Getting changes between {} and {}", from_commit, to_commit));
let diff_output = Command::new("git")
.args(&["diff", "--name-only", from_commit, to_commit])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if diff_output.status.success() {
changed_files.extend(
String::from_utf8_lossy(&diff_output.stdout)
.lines()
.filter(|line| line.ends_with(".tf"))
.map(|line| {
let file_path = Path::new(root_dir).join(line);
if file_path.exists() {
fs::canonicalize(file_path)
.map_err(|e| e.to_string())
.unwrap()
.to_str()
.unwrap()
.to_string()
} else {
let current_dir = std::env::current_dir().map_err(|e| e.to_string()).unwrap();
current_dir.join(root_dir).join(line)
.to_str()
.unwrap()
.to_string()
}
})
);
}
changed_files.sort();
changed_files.dedup();
if !changed_files.is_empty() {
logger::info(&format!("Found {} changed .tf files", changed_files.len()));
logger::changed_files_summary(&changed_files);
} else {
logger::info("No .tf files changed between the commits");
}
Ok(changed_files)
}
pub fn get_git_changed_files(root_dir: &str, default_branch: &str) -> Result<Vec<String>, String> {
let merge_base_output = Command::new("git")
.args(&["merge-base", &format!("origin/{}", default_branch), "HEAD"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
let merge_base = if merge_base_output.status.success() {
String::from_utf8_lossy(&merge_base_output.stdout).trim().to_string()
} else {
let local_merge_base = Command::new("git")
.args(&["merge-base", default_branch, "HEAD"])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if !local_merge_base.status.success() {
return Ok(Vec::new());
}
String::from_utf8_lossy(&local_merge_base.stdout).trim().to_string()
};
let mut changed_files = Vec::new();
let status_output = Command::new("git")
.arg("status")
.arg("--porcelain")
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if status_output.status.success() {
changed_files.extend(
String::from_utf8_lossy(&status_output.stdout)
.lines()
.filter(|line| line.ends_with(".tf"))
.map(|line| {
let file = line[3..].trim();
let file_path = Path::new(root_dir).join(file);
if file_path.exists() {
fs::canonicalize(file_path)
.map_err(|e| e.to_string())
.unwrap()
.to_str()
.unwrap()
.to_string()
} else {
let current_dir = std::env::current_dir().map_err(|e| e.to_string()).unwrap();
current_dir.join(root_dir).join(file)
.to_str()
.unwrap()
.to_string()
}
})
);
}
let diff_output = Command::new("git")
.args(&["diff", "--name-only", &merge_base])
.current_dir(root_dir)
.output()
.map_err(|e| e.to_string())?;
if diff_output.status.success() {
changed_files.extend(
String::from_utf8_lossy(&diff_output.stdout)
.lines()
.filter(|line| line.ends_with(".tf"))
.map(|line| {
let file_path = Path::new(root_dir).join(line);
if file_path.exists() {
fs::canonicalize(file_path)
.map_err(|e| e.to_string())
.unwrap()
.to_str()
.unwrap()
.to_string()
} else {
let current_dir = std::env::current_dir().map_err(|e| e.to_string()).unwrap();
current_dir.join(root_dir).join(line)
.to_str()
.unwrap()
.to_string()
}
})
);
}
changed_files.sort();
changed_files.dedup();
Ok(changed_files)
}
pub fn process_changed_modules(changed_files: &[String], modules: &mut HashMap<String, Module>) -> Result<Vec<String>, String> {
let mut affected_modules = Vec::new();
let mut processed = HashMap::new();
let module_paths: Vec<String> = modules.keys().cloned().collect();
for file in changed_files {
let file_path = Path::new(file);
for module_path in &module_paths {
let module_path = Path::new(module_path);
if file_path.starts_with(module_path) {
mark_module_changed(module_path.to_str().unwrap(), modules, &mut affected_modules, &mut processed);
break;
}
}
}
Ok(affected_modules)
}
pub fn mark_module_changed(module_path: &str, all_modules: &mut HashMap<String, Module>, affected_modules: &mut Vec<String>, processed: &mut HashMap<String, bool>) {
if *processed.get(module_path).unwrap_or(&false) {
return;
}
processed.insert(module_path.to_string(), true);
if let Some(module) = all_modules.get(module_path) {
if module.is_stateful {
if !affected_modules.contains(&module_path.to_string()) {
affected_modules.push(module_path.to_string());
}
} else {
if !module.used_by.is_empty() {
logger::info(&format!("Stateless module with changes: {}", module_path.split('/').last().unwrap_or(module_path)));
for user_module_path in &module.used_by {
if let Some(user_module) = all_modules.get(user_module_path) {
if user_module.is_stateful {
if !affected_modules.contains(user_module_path) {
logger::info(&format!("Adding stateful module that uses changed stateless module: {}",
user_module_path.split('/').last().unwrap_or(user_module_path)));
affected_modules.push(user_module_path.clone());
}
}
}
}
}
}
}
}