use crate::coordination::ci_decision::CiDecisionEngine;
use crate::coordination::{extract_owner_repo, WorkflowTracker};
use crate::error::Result;
use crate::file_ops::FileOps;
use crate::git::{GitRepository, ShaResolver, SubmoduleProcessor};
use crate::http::{GitHubApiClient, WorkflowApiClient};
use crate::interner::StringInterner;
use crate::patterns::loader::{PatternGroup, PatternLoader};
use crate::patterns::matcher::PatternMatcher;
use crate::types::{
Diagnostic, DiagnosticCategory, DiagnosticSeverity, GroupResult, InputConfig, ProcessedResult,
WorkflowCheckResult,
};
use rayon::prelude::*;
use std::path::Path;
pub struct FileProcessor<'a> {
git_ops: &'a GitRepository,
interner: &'a StringInterner,
config: &'a InputConfig<'a>,
}
impl<'a> FileProcessor<'a> {
pub fn new(
git_ops: &'a GitRepository,
interner: &'a StringInterner,
config: &'a InputConfig<'a>,
) -> Self {
Self {
git_ops,
interner,
config,
}
}
pub async fn process(&self) -> Result<ProcessedResult> {
let mut result = ProcessedResult::default();
if self.config.use_rest_api {
return self.process_via_rest_api().await;
}
let repo_path = self.git_ops.path();
let sha_resolver = ShaResolver::new(repo_path);
let (base_sha, head_sha) = sha_resolver.resolve_event_aware(self.config)?;
result.base_sha = Some(self.interner.intern(&base_sha));
result.head_sha = Some(self.interner.intern(&head_sha));
if self.config.skip_same_sha && base_sha == head_sha {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
category: DiagnosticCategory::SkippedSameSha,
message: format!("Skipped: base and head SHA are identical ({})", base_sha),
});
return Ok(result);
}
let diff = match self
.git_ops
.diff(
&base_sha,
&head_sha,
self.interner,
&self.config.diff_filter,
)
.await
{
Ok(diff) => diff,
Err(e) => {
if self.config.fail_on_initial_diff_error {
return Err(e);
}
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::InitialDiff,
message: format!("Initial diff failed (soft): {}", e),
});
return Ok(result);
}
};
result.all_files = diff.files;
result.additions = diff.additions;
result.deletions = diff.deletions;
if self.config.include_submodules {
match self.process_submodules(&base_sha, &head_sha).await {
Ok(submodule_diff) => {
result.all_files.extend(submodule_diff.files);
}
Err(e) => {
if self.config.fail_on_submodule_diff_error {
return Err(e);
}
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::SubmoduleDiff,
message: format!("Submodule diff failed (soft): {}", e),
});
}
}
}
let mut yaml_groups = self.load_yaml_groups(&mut result);
if self.config.track_workflow_failures {
match self
.check_workflows_enhanced(&mut result.all_files, &yaml_groups)
.await
{
Ok(workflow_result) => {
let ci_engine = CiDecisionEngine::new();
let ci_decision = ci_engine.compute(
&result.all_files,
&workflow_result.failures,
&workflow_result.successes,
);
if workflow_result.waited {
eprintln!(
"Waited {}ms for {} active workflows",
workflow_result.wait_time_ms,
workflow_result.blocking_runs.len()
);
}
if !workflow_result.failures.is_empty() {
eprintln!(
"Found {} recent failures on branch",
workflow_result.failures.len()
);
}
result.ci_decision = Some(ci_decision);
result.workflow_result = Some(workflow_result);
}
Err(e) => {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::WorkflowApi,
message: format!("Workflow check failed (soft): {}", e),
});
}
}
}
let matcher = self.build_pattern_matcher()?;
if self.config.detect_vanished {
self.detect_vanished_files(
&base_sha,
&head_sha,
matcher.as_ref(),
&yaml_groups,
&mut result,
);
}
let groups_from_template = self.config.files_group_by.is_some()
&& self.config.files_yaml.is_none()
&& self.config.files_yaml_from_source_file.is_none();
if groups_from_template && (self.config.detect_vanished || self.config.deleted_to_destroy) {
let mut candidates: Vec<&str> = result
.vanished_files
.iter()
.filter_map(|v| self.interner.resolve(v.path))
.collect();
if self.config.deleted_to_destroy {
candidates.extend(result.all_files.iter().filter_map(|f| {
(f.change_type == crate::types::ChangeType::Deleted)
.then(|| self.interner.resolve(f.path))
.flatten()
}));
}
if !candidates.is_empty() {
if let Some(ref template_str) = self.config.files_group_by {
match PatternLoader::parse_group_by_template(template_str) {
Ok(template) => {
let key_mode = self
.config
.files_group_by_key
.as_deref()
.map(crate::types::GroupByKey::parse)
.unwrap_or_default();
if let Err(e) = PatternLoader::synthesize_groups_for_paths(
&template,
key_mode,
candidates.into_iter(),
self.config.negation_patterns_first,
&mut yaml_groups,
) {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::VanishedDetection,
message: format!("group synthesis failed (soft): {}", e),
});
}
}
Err(e) => {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::VanishedDetection,
message: format!("group template parse failed (soft): {}", e),
});
}
}
}
}
}
if let Some(matcher) = &matcher {
let (matched, unmatched) =
matcher.partition_files_parallel(&result.all_files, self.interner);
result.filtered_indices = matched;
result.unmatched_indices = unmatched;
result.pattern_applied = true;
} else {
let n = result.all_files.len() as u32;
result.filtered_indices = (0..n).collect();
result.unmatched_indices = Vec::new();
result.pattern_applied = false;
}
if self.config.files_ancestor_lookup_depth > 0 {
if let Some(ref matcher) = matcher {
self.recover_unmatched_by_ancestor(
&result.all_files,
&mut result.filtered_indices,
&mut result.unmatched_indices,
matcher,
&mut result.diagnostics,
);
}
}
if !yaml_groups.is_empty() {
result.group_results = yaml_groups
.iter()
.map(|group| {
let matched: Vec<u32> = result
.filtered_indices
.iter()
.copied()
.filter(|&i| {
let file = &result.all_files[i as usize];
self.interner
.resolve(file.path)
.map(|path| group.matcher.matches_sync(path))
.unwrap_or(false)
})
.collect();
let vanished: Vec<u32> = (0..result.vanished_files.len() as u32)
.filter(|&i| {
self.interner
.resolve(result.vanished_files[i as usize].path)
.map(|path| group.matcher.matches_sync(path))
.unwrap_or(false)
})
.collect();
GroupResult {
key: self.interner.intern(&group.name),
matched_indices: matched,
vanished_indices: vanished,
}
})
.collect();
}
if !self.config.exclude_symlinks {
self.detect_symlinks_parallel(&mut result.all_files);
}
if self.config.recover_deleted_files {
if let Some(ref output_dir) = self.config.output_dir {
let recovery = crate::git::recovery::FileRecovery::new(repo_path);
let deleted_paths: Vec<crate::types::InternedString> = result
.all_files
.iter()
.filter(|f| f.change_type == crate::types::ChangeType::Deleted)
.map(|f| f.path)
.collect();
if !deleted_paths.is_empty() {
let results = recovery.recover_all_parallel(
&base_sha,
&deleted_paths,
self.interner,
Path::new(output_dir.as_ref()),
);
for res in results {
if let Err(e) = res {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::SymlinkDetection,
message: format!("File recovery failed: {}", e),
});
}
}
}
}
}
if self.config.write_output_files {
if let Some(ref output_dir) = self.config.output_dir {
let dir = Path::new(output_dir.as_ref());
std::fs::create_dir_all(dir)?;
let blocked_groups = result.workflow_result.as_ref().map(|wr| &wr.blocked_groups);
let outputs = crate::output::ComputedOutputs::compute_with_concurrency(
&result,
self.config.output_renamed_as_deleted_added,
blocked_groups,
Some(self.interner),
);
let filtered_paths: Vec<&str> = result
.filtered_indices
.iter()
.filter_map(|&i| self.interner.resolve(result.all_files[i as usize].path))
.collect();
if let Err(e) = crate::output::writer::OutputWriter::write_text(
dir,
"all_changed_files",
&filtered_paths,
&self.config.files_separator,
) {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::PatternLoad,
message: format!("Failed to write output file: {}", e),
});
}
if self.config.json {
if let Err(e) = crate::output::writer::OutputWriter::write_json(
dir,
"all_changed_files",
&filtered_paths,
) {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::PatternLoad,
message: format!("Failed to write JSON output: {}", e),
});
}
}
let type_categories = [
("added_files", &outputs.filtered_added),
("modified_files", &outputs.filtered_modified),
("deleted_files", &outputs.filtered_deleted),
("renamed_files", &outputs.filtered_renamed),
("copied_files", &outputs.filtered_copied),
];
for (name, indices) in &type_categories {
let paths: Vec<&str> = indices
.iter()
.filter_map(|&i| self.interner.resolve(result.all_files[i as usize].path))
.collect();
let _ = crate::output::writer::OutputWriter::write_text(
dir,
name,
&paths,
&self.config.files_separator,
);
}
}
}
Ok(result)
}
fn build_pattern_matcher(&self) -> Result<Option<PatternMatcher>> {
let mut source_buf = String::new();
let mut source_patterns: Vec<&str> = Vec::new();
if let Some(ref source_file) = self.config.files_from_source_file {
match PatternLoader::load_from_file(source_file, &mut source_buf) {
Ok(patterns) => {
source_patterns = patterns;
}
Err(e) => {
return Err(e);
}
}
}
let inline_refs: Vec<&str> = self
.config
.files
.as_ref()
.map(|v| v.iter().map(|c| c.as_ref()).collect())
.unwrap_or_default();
if inline_refs.is_empty() && source_patterns.is_empty() {
return Ok(None);
}
let patterns: Vec<&str> = inline_refs.into_iter().chain(source_patterns).collect();
let mut gitignore_buf = String::new();
let mut gitignore_patterns: Vec<&str> = Vec::new();
if self.config.match_gitignore_files {
let gitignore_path = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join(".gitignore");
if gitignore_path.exists() {
if let Ok(loaded) = PatternLoader::load_from_file(
gitignore_path.to_str().unwrap_or(""),
&mut gitignore_buf,
) {
gitignore_patterns = loaded;
}
}
}
let mut ignore_patterns: Vec<&str> = self
.config
.files_ignore
.as_ref()
.map(|ignore| ignore.iter().map(|c| c.as_ref()).collect())
.unwrap_or_default();
ignore_patterns.extend(gitignore_patterns);
let matcher = PatternMatcher::new(
&patterns,
&ignore_patterns,
self.config.negation_patterns_first,
)?;
Ok(Some(matcher))
}
fn recover_unmatched_by_ancestor(
&self,
all_files: &[crate::types::ChangedFile],
filtered: &mut Vec<u32>,
unmatched: &mut Vec<u32>,
matcher: &PatternMatcher,
diagnostics: &mut Vec<Diagnostic>,
) {
let depth = self.config.files_ancestor_lookup_depth.min(3);
if depth != self.config.files_ancestor_lookup_depth {
diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
category: DiagnosticCategory::AncestorRecovery,
message: format!(
"files_ancestor_lookup_depth clamped from {} to 3 (maximum)",
self.config.files_ancestor_lookup_depth
),
});
}
let workdir = self.git_ops.workdir();
let mut recovered = Vec::new();
unmatched.retain(|&idx| {
let path = match self.interner.resolve(all_files[idx as usize].path) {
Some(p) => p,
None => return true, };
if self.has_matching_ancestor_file(path, workdir, matcher, depth, diagnostics) {
recovered.push(idx);
false } else {
true }
});
if !recovered.is_empty() {
diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
category: DiagnosticCategory::AncestorRecovery,
message: format!(
"Recovered {} file(s) via ancestor directory lookup (depth={})",
recovered.len(),
depth
),
});
filtered.extend(recovered);
}
}
fn has_matching_ancestor_file(
&self,
file_path: &str,
workdir: &Path,
matcher: &PatternMatcher,
max_depth: u32,
diagnostics: &mut Vec<Diagnostic>,
) -> bool {
let rel = Path::new(file_path);
let mut current_dir = match rel.parent() {
Some(d) => d.to_path_buf(),
None => return false,
};
for _ in 0..max_depth {
let abs_dir = workdir.join(¤t_dir);
match std::fs::read_dir(&abs_dir) {
Ok(entries) => {
for entry in entries.flatten() {
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
let entry_rel = current_dir.join(entry.file_name());
if let Some(s) = entry_rel.to_str() {
if matcher.matches_sync(s) {
return true;
}
}
}
}
}
Err(e) => {
diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::AncestorRecovery,
message: format!("Failed to scan directory '{}': {}", abs_dir.display(), e),
});
}
}
match current_dir.parent() {
Some(p) if !p.as_os_str().is_empty() => current_dir = p.to_path_buf(),
_ => break,
}
}
false
}
fn load_yaml_content(&self) -> Result<Option<String>> {
if let Some(ref yaml) = self.config.files_yaml {
return Ok(Some(yaml.to_string()));
}
if let Some(ref yaml_file) = self.config.files_yaml_from_source_file {
let content = std::fs::read_to_string(yaml_file.as_ref()).map_err(|e| {
crate::error::Error::Pattern(format!(
"Failed to read YAML pattern file '{}': {}",
yaml_file, e
))
})?;
return Ok(Some(content));
}
Ok(None)
}
fn detect_vanished_files(
&self,
base_sha: &str,
head_sha: &str,
matcher: Option<&PatternMatcher>,
yaml_groups: &[PatternGroup],
result: &mut ProcessedResult,
) {
use crate::git::vanished::{pathspec_prefixes, VanishedDetector};
let mut pattern_refs: Vec<&str> = Vec::new();
if let Some(files) = &self.config.files {
pattern_refs.extend(files.iter().map(|c| c.as_ref()));
}
let pathspecs: Vec<String> = if !pattern_refs.is_empty() {
pathspec_prefixes(&pattern_refs)
} else if let Some(group_by) = self.config.files_group_by.as_deref() {
pathspec_prefixes(&[group_by])
} else {
Vec::new()
};
let detector = VanishedDetector::new(self.git_ops.path(), self.interner);
let scan = if let Some(matcher) = matcher {
detector.detect_sync(
base_sha,
head_sha,
|p| matcher.matches_sync(p),
&pathspecs,
self.config.vanished_max_commits,
)
} else if let Some(template) = self.config.files_group_by.as_deref().filter(|_| {
self.config.files_yaml.is_none() && self.config.files_yaml_from_source_file.is_none()
}) {
let single = template.replace("{group}", "*");
let multi = template.replace("{group}", "**");
match PatternMatcher::new(
&[single.as_str(), multi.as_str()],
&[],
self.config.negation_patterns_first,
) {
Ok(template_matcher) => detector.detect_sync(
base_sha,
head_sha,
|p| template_matcher.matches_sync(p),
&pathspecs,
self.config.vanished_max_commits,
),
Err(e) => {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::VanishedDetection,
message: format!("template predicate build failed (soft): {}", e),
});
return;
}
}
} else if !yaml_groups.is_empty() {
detector.detect_sync(
base_sha,
head_sha,
|p| yaml_groups.iter().any(|g| g.matcher.matches_sync(p)),
&pathspecs,
self.config.vanished_max_commits,
)
} else {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
category: DiagnosticCategory::VanishedDetection,
message: "detect_vanished enabled but no files/files_yaml/files_group_by \
patterns configured; skipping"
.into(),
});
return;
};
match scan {
Ok(scan) => {
if scan.truncated {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
category: DiagnosticCategory::VanishedDetection,
message: format!(
"vanished detection truncated after {} commits \
(vanished_max_commits={}); results may be incomplete",
scan.commits_walked, self.config.vanished_max_commits
),
});
}
for anomaly in scan.anomalies {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
category: DiagnosticCategory::VanishedDetection,
message: anomaly,
});
}
result.vanished_files = scan.vanished;
}
Err(e) => {
let msg = e.to_string();
let category = if msg.contains("shallow") || msg.contains("not found") {
DiagnosticCategory::ShallowClone
} else {
DiagnosticCategory::VanishedDetection
};
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category,
message: format!("vanished detection failed (soft): {}", msg),
});
}
}
}
fn load_yaml_groups(&self, result: &mut ProcessedResult) -> Vec<PatternGroup> {
match self.load_yaml_content() {
Ok(Some(yaml_content)) => {
if self.config.files_group_by.is_some() {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::Warning,
category: DiagnosticCategory::PatternLoad,
message: "Both files_yaml and files_group_by set; using files_yaml".into(),
});
}
match PatternLoader::load_yaml_groups(
&yaml_content,
self.config.negation_patterns_first,
) {
Ok(groups) => return groups,
Err(e) => {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::PatternLoad,
message: format!("YAML pattern load failed: {}", e),
});
return Vec::new();
}
}
}
Ok(None) => {} Err(e) => {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::PatternLoad,
message: format!("Failed to load YAML content: {}", e),
});
return Vec::new();
}
}
if let Some(ref template_str) = self.config.files_group_by {
match PatternLoader::parse_group_by_template(template_str) {
Ok(template) => {
let key_mode = self
.config
.files_group_by_key
.as_deref()
.map(crate::types::GroupByKey::parse)
.unwrap_or_default();
let repo_root = self.git_ops.workdir();
match PatternLoader::discover_groups_from_template(
&template,
repo_root,
self.config.negation_patterns_first,
key_mode,
) {
Ok(groups) => return groups,
Err(e) => {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::PatternLoad,
message: format!("files_group_by discovery failed: {}", e),
});
}
}
}
Err(e) => {
result.diagnostics.push(Diagnostic {
severity: DiagnosticSeverity::SoftError,
category: DiagnosticCategory::PatternLoad,
message: format!("files_group_by template error: {}", e),
});
}
}
}
Vec::new()
}
async fn check_workflows_enhanced(
&self,
current_files: &mut Vec<crate::types::ChangedFile>,
yaml_groups: &[PatternGroup],
) -> Result<WorkflowCheckResult> {
let current_branch =
std::env::var("GITHUB_REF").unwrap_or_else(|_| "refs/heads/main".to_string());
let branch = current_branch
.strip_prefix("refs/heads/")
.unwrap_or(¤t_branch);
let api_client = WorkflowApiClient::from_env()?;
let tracker = WorkflowTracker::new(api_client, self.config, self.interner, yaml_groups);
let result = tracker.check_workflows(branch, current_files).await?;
tracker.merge_failed_files(current_files, &result.failures);
Ok(result)
}
async fn process_via_rest_api(&self) -> Result<ProcessedResult> {
if self.config.files_ancestor_lookup_depth > 0 {
eprintln!("Warning: files_ancestor_lookup_depth not available in REST API mode");
}
let client = GitHubApiClient::from_env()?;
let diff = match GitHubApiClient::extract_pr_info_from_env() {
Ok((owner, repo, pr_number)) => {
client
.fetch_changed_files(&owner, &repo, pr_number, self.interner)
.await?
}
Err(_) => {
let sha_resolver = ShaResolver::new(self.git_ops.path());
let (base_sha, head_sha) = sha_resolver.resolve_event_aware(self.config)?;
let (owner, repo) = extract_owner_repo()?;
client
.compare_refs(&owner, &repo, &base_sha, &head_sha, self.interner)
.await?
}
};
let mut result = ProcessedResult::from_unfiltered(diff);
let matcher = self.build_pattern_matcher()?;
if let Some(matcher) = &matcher {
let (matched, unmatched) =
matcher.partition_files_parallel(&result.all_files, self.interner);
result.filtered_indices = matched;
result.unmatched_indices = unmatched;
result.pattern_applied = true;
}
Ok(result)
}
async fn process_submodules(
&self,
base_sha: &str,
head_sha: &str,
) -> Result<crate::types::DiffResult> {
let max_depth = self.config.dir_names_max_depth.map(|d| d as u8);
let processor = SubmoduleProcessor::new(self.git_ops.path(), max_depth);
processor.process_submodule_changes(
base_sha,
head_sha,
self.interner,
0, )
}
fn detect_symlinks_parallel(&self, files: &mut [crate::types::ChangedFile]) {
let file_ops = FileOps::new();
files.par_iter_mut().for_each(|file| {
if let Some(path_str) = self.interner.resolve(file.path) {
let path = Path::new(path_str);
if let Ok(is_link) = file_ops.is_symlink_sync(path) {
file.is_symlink = is_link;
}
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::GitRepository;
use crate::interner::StringInterner;
use std::fs;
use tempfile::TempDir;
fn create_test_repo() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let repo_path = dir.path().to_path_buf();
std::process::Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.unwrap();
fs::write(repo_path.join("file1.txt"), "content1").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(&repo_path)
.output()
.unwrap();
(dir, repo_path)
}
#[tokio::test]
async fn test_processor_basic_flow() {
let (_dir, repo_path) = create_test_repo();
let base_output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let base_sha = String::from_utf8_lossy(&base_output.stdout)
.trim()
.to_string();
fs::write(repo_path.join("file2.txt"), "content2").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "Add file2"])
.current_dir(&repo_path)
.output()
.unwrap();
let head_output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_path)
.output()
.unwrap();
let head_sha = String::from_utf8_lossy(&head_output.stdout)
.trim()
.to_string();
std::env::set_current_dir(&repo_path).unwrap();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let config = InputConfig {
base_sha: Some(std::borrow::Cow::Owned(base_sha)),
sha: Some(std::borrow::Cow::Owned(head_sha)),
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
let result = processor.process().await.unwrap();
assert_eq!(result.all_files.len(), 1);
assert_eq!(
interner.resolve(result.all_files[0].path),
Some("file2.txt")
);
assert!(!result.pattern_applied);
assert_eq!(result.filtered_indices, vec![0]);
assert!(result.unmatched_indices.is_empty());
}
fn setup_ancestor_test() -> (TempDir, std::path::PathBuf) {
let (dir, repo_path) = create_test_repo();
fs::create_dir_all(repo_path.join("stacks/prod")).unwrap();
fs::write(repo_path.join("stacks/prod/config.yaml"), "key: value").unwrap();
fs::create_dir_all(repo_path.join("stacks/prod/migrations")).unwrap();
fs::write(
repo_path.join("stacks/prod/migrations/001.sql"),
"CREATE TABLE t;",
)
.unwrap();
fs::create_dir_all(repo_path.join("stacks/dev")).unwrap();
fs::write(repo_path.join("stacks/dev/config.yaml"), "env: dev").unwrap();
(dir, repo_path)
}
#[test]
fn test_ancestor_recovery_same_dir() {
let (_dir, repo_path) = setup_ancestor_test();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let matcher = PatternMatcher::new(&["stacks/prod/**/*.yaml"], &[], true).unwrap();
let sql_path = interner.intern("stacks/prod/config.sql");
let yaml_path = interner.intern("stacks/prod/config.yaml");
fs::write(repo_path.join("stacks/prod/config.sql"), "SELECT 1").unwrap();
let all_files = vec![
crate::types::ChangedFile {
path: yaml_path,
change_type: crate::types::ChangeType::Modified,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
},
crate::types::ChangedFile {
path: sql_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
},
];
let mut filtered = vec![0u32]; let mut unmatched = vec![1u32]; let mut diagnostics = Vec::new();
let config = InputConfig {
files_ancestor_lookup_depth: 1,
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
processor.recover_unmatched_by_ancestor(
&all_files,
&mut filtered,
&mut unmatched,
&matcher,
&mut diagnostics,
);
assert!(unmatched.is_empty());
assert_eq!(filtered, vec![0, 1]);
assert!(diagnostics
.iter()
.any(|d| d.message.contains("Recovered 1")));
}
#[test]
fn test_ancestor_recovery_parent_dir() {
let (_dir, repo_path) = setup_ancestor_test();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let matcher = PatternMatcher::new(&["stacks/prod/**/*.yaml"], &[], true).unwrap();
let sql_path = interner.intern("stacks/prod/migrations/001.sql");
let all_files = vec![crate::types::ChangedFile {
path: sql_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
}];
let mut filtered = Vec::new();
let mut unmatched = vec![0u32];
let mut diagnostics = Vec::new();
let config = InputConfig {
files_ancestor_lookup_depth: 2,
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
processor.recover_unmatched_by_ancestor(
&all_files,
&mut filtered,
&mut unmatched,
&matcher,
&mut diagnostics,
);
assert!(unmatched.is_empty());
assert_eq!(filtered, vec![0]);
assert!(diagnostics
.iter()
.any(|d| d.message.contains("Recovered 1")));
}
#[test]
fn test_ancestor_recovery_depth_zero() {
let (_dir, repo_path) = setup_ancestor_test();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let matcher = PatternMatcher::new(&["stacks/prod/**/*.yaml"], &[], true).unwrap();
let sql_path = interner.intern("stacks/prod/migrations/001.sql");
let all_files = vec![crate::types::ChangedFile {
path: sql_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
}];
let mut filtered = Vec::new();
let mut unmatched = vec![0u32];
let mut diagnostics = Vec::new();
let config = InputConfig {
files_ancestor_lookup_depth: 0,
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
processor.recover_unmatched_by_ancestor(
&all_files,
&mut filtered,
&mut unmatched,
&matcher,
&mut diagnostics,
);
assert_eq!(unmatched, vec![0]);
assert!(filtered.is_empty());
}
#[test]
fn test_ancestor_recovery_no_match() {
let (_dir, repo_path) = setup_ancestor_test();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let matcher = PatternMatcher::new(&["nonexistent/**/*.yaml"], &[], true).unwrap();
let sql_path = interner.intern("stacks/prod/migrations/001.sql");
let all_files = vec![crate::types::ChangedFile {
path: sql_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
}];
let mut filtered = Vec::new();
let mut unmatched = vec![0u32];
let mut diagnostics = Vec::new();
let config = InputConfig {
files_ancestor_lookup_depth: 3,
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
processor.recover_unmatched_by_ancestor(
&all_files,
&mut filtered,
&mut unmatched,
&matcher,
&mut diagnostics,
);
assert_eq!(unmatched, vec![0]);
assert!(filtered.is_empty());
}
#[test]
fn test_ancestor_recovery_depth_limit() {
let (_dir, repo_path) = setup_ancestor_test();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let matcher = PatternMatcher::new(&["stacks/prod/**/*.yaml"], &[], true).unwrap();
let sql_path = interner.intern("stacks/prod/migrations/001.sql");
let all_files = vec![crate::types::ChangedFile {
path: sql_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
}];
let mut filtered = Vec::new();
let mut unmatched = vec![0u32];
let mut diagnostics = Vec::new();
let config = InputConfig {
files_ancestor_lookup_depth: 1,
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
processor.recover_unmatched_by_ancestor(
&all_files,
&mut filtered,
&mut unmatched,
&matcher,
&mut diagnostics,
);
assert_eq!(unmatched, vec![0]);
assert!(filtered.is_empty());
}
#[test]
fn test_ancestor_recovery_multiple() {
let (_dir, repo_path) = setup_ancestor_test();
fs::create_dir_all(repo_path.join("orphan")).unwrap();
fs::write(repo_path.join("orphan/data.bin"), "binary").unwrap();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let matcher = PatternMatcher::new(&["stacks/**/*.yaml"], &[], true).unwrap();
let sql_path = interner.intern("stacks/prod/migrations/001.sql");
let orphan_path = interner.intern("orphan/data.bin");
let all_files = vec![
crate::types::ChangedFile {
path: sql_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
},
crate::types::ChangedFile {
path: orphan_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
},
];
let mut filtered = Vec::new();
let mut unmatched = vec![0u32, 1u32];
let mut diagnostics = Vec::new();
let config = InputConfig {
files_ancestor_lookup_depth: 2,
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
processor.recover_unmatched_by_ancestor(
&all_files,
&mut filtered,
&mut unmatched,
&matcher,
&mut diagnostics,
);
assert_eq!(unmatched, vec![1]);
assert_eq!(filtered, vec![0]);
}
#[test]
fn test_ancestor_recovery_clamped() {
let (_dir, repo_path) = setup_ancestor_test();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let matcher = PatternMatcher::new(&["stacks/**/*.yaml"], &[], true).unwrap();
let sql_path = interner.intern("stacks/prod/migrations/001.sql");
let all_files = vec![crate::types::ChangedFile {
path: sql_path,
change_type: crate::types::ChangeType::Added,
previous_path: None,
is_symlink: false,
submodule_depth: 0,
origin: crate::types::FileOrigin::default(),
}];
let mut filtered = Vec::new();
let mut unmatched = vec![0u32];
let mut diagnostics = Vec::new();
let config = InputConfig {
files_ancestor_lookup_depth: 10,
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
processor.recover_unmatched_by_ancestor(
&all_files,
&mut filtered,
&mut unmatched,
&matcher,
&mut diagnostics,
);
assert!(diagnostics
.iter()
.any(|d| d.message.contains("clamped from 10 to 3")));
}
#[test]
fn test_build_pattern_matcher_with_inline_patterns() {
use std::borrow::Cow;
let (_dir, repo_path) = create_test_repo();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let config = InputConfig {
files: Some(vec![
Cow::Borrowed("src/**/*.rs"),
Cow::Borrowed("tests/**"),
]),
..Default::default()
};
let processor = FileProcessor::new(&repo, &interner, &config);
let matcher = processor.build_pattern_matcher().unwrap();
assert!(
matcher.is_some(),
"Expected Some(PatternMatcher) when inline patterns are set"
);
let matcher = matcher.unwrap();
assert!(matcher.matches_sync("src/lib.rs"));
assert!(matcher.matches_sync("src/utils/helpers.rs"));
assert!(matcher.matches_sync("tests/integration.rs"));
assert!(!matcher.matches_sync("docs/README.md"));
assert!(!matcher.matches_sync("Cargo.toml"));
}
#[test]
fn test_build_pattern_matcher_no_patterns() {
let (_dir, repo_path) = create_test_repo();
let repo = GitRepository::discover(&repo_path).unwrap();
let interner = StringInterner::new();
let config = InputConfig::default();
let processor = FileProcessor::new(&repo, &interner, &config);
let matcher = processor.build_pattern_matcher().unwrap();
assert!(
matcher.is_none(),
"Expected None when no patterns are configured"
);
}
}