use async_trait::async_trait;
use cuenv_codeowners::Rule;
use cuenv_codeowners::provider::{ProjectOwners, SyncStatus};
use cuenv_core::DryRun;
use cuenv_core::Result;
use cuenv_core::manifest::{DirectoryRules, Ignore, IgnoreValue};
use cuenv_editorconfig::{EditorConfigFile, EditorConfigSection as BuilderSection};
use cuenv_ignore::{IgnoreFile, IgnoreFiles};
use ignore::WalkBuilder;
use std::path::Path;
use crate::commands::CommandExecutor;
use crate::commands::sync::provider::{SyncMode, SyncOptions, SyncProvider, SyncResult};
use crate::providers::detect_code_owners_provider;
const CUENV_HEADER: &str = "Generated by cuenv - do not edit\nSource: .rules.cue";
pub struct RulesSyncProvider;
#[async_trait]
impl SyncProvider for RulesSyncProvider {
fn name(&self) -> &'static str {
"rules"
}
fn description(&self) -> &'static str {
"Sync configuration from .rules.cue files (ignore, editorconfig, codeowners)"
}
fn has_config(&self, _manifest: &cuenv_core::manifest::Base) -> bool {
true
}
async fn sync_path(
&self,
path: &Path,
_package: &str,
options: &SyncOptions,
executor: &CommandExecutor,
) -> Result<SyncResult> {
let rules_file = path.join(".rules.cue");
if !rules_file.exists() {
return Ok(SyncResult::success(
"No .rules.cue file found in this directory.",
));
}
let dry_run = options.mode == SyncMode::DryRun;
let check = options.mode == SyncMode::Check;
let config = evaluate_rules_file(&rules_file, executor)?;
let repo_root = find_repo_root(path).unwrap_or_else(|| path.to_path_buf());
let is_root = path == repo_root;
let mut output = sync_directory_rules(path, &config, dry_run.into(), check, is_root)?;
if let Some(project) = build_project_owners(path, &repo_root, &config) {
let codeowners_output = sync_codeowners(&repo_root, &[project], dry_run.into(), check)?;
if !codeowners_output.is_empty() {
if !output.is_empty() {
output.push('\n');
}
output.push_str(&codeowners_output);
}
}
Ok(SyncResult::success(output))
}
async fn sync_workspace(
&self,
_package: &str,
options: &SyncOptions,
executor: &CommandExecutor,
) -> Result<SyncResult> {
let cwd = std::env::current_dir().map_err(|e| {
cuenv_core::Error::configuration(format!("Failed to get current directory: {e}"))
})?;
let dry_run = options.mode == SyncMode::DryRun;
let check = options.mode == SyncMode::Check;
let repo_root = find_repo_root(&cwd).unwrap_or_else(|| cwd.clone());
let walker = WalkBuilder::new(&cwd)
.follow_links(true)
.standard_filters(true)
.build();
let mut discovered_files = Vec::new();
for entry in walker.flatten() {
let path = entry.path();
if path.file_name() == Some(".rules.cue".as_ref()) {
discovered_files.push(path.to_path_buf());
}
}
if discovered_files.is_empty() {
return Ok(SyncResult::success(
"No .rules.cue files found in the repository.",
));
}
let mut outputs = Vec::new();
let mut had_error = false;
let mut owner_projects = Vec::new();
for rules_file in &discovered_files {
let directory = match rules_file.parent() {
Some(d) => d.to_path_buf(),
None => continue,
};
let config = match evaluate_rules_file(rules_file, executor) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
path = %rules_file.display(),
error = %e,
"Failed to evaluate .rules.cue - skipping"
);
outputs.push(format!("[{}] Error: {}", directory.display(), e));
had_error = true;
continue;
}
};
let is_root = directory == repo_root;
let result = sync_directory_rules(&directory, &config, dry_run.into(), check, is_root);
match result {
Ok(output) if !output.is_empty() => {
let display = directory.strip_prefix(&cwd).unwrap_or(&directory).display();
outputs.push(format!("[{}]\n{}", display, output));
}
Ok(_) => {}
Err(e) => {
outputs.push(format!("[{}] Error: {}", directory.display(), e));
had_error = true;
}
}
if let Some(project) = build_project_owners(&directory, &repo_root, &config) {
owner_projects.push(project);
}
}
if !owner_projects.is_empty() {
let output = sync_codeowners(&repo_root, &owner_projects, dry_run.into(), check)?;
if !output.is_empty() {
outputs.push(format!("[CODEOWNERS]\n{output}"));
}
}
if outputs.is_empty() {
Ok(SyncResult::success("No changes needed."))
} else {
Ok(SyncResult {
output: outputs.join("\n\n"),
had_error,
})
}
}
}
fn evaluate_rules_file(file_path: &Path, _executor: &CommandExecutor) -> Result<DirectoryRules> {
crate::providers::rules_eval::evaluate_rules_file(file_path)
}
fn sync_directory_rules(
directory: &Path,
config: &DirectoryRules,
dry_run: DryRun,
check: bool,
is_root: bool,
) -> Result<String> {
let mut outputs = Vec::new();
let effective_dry_run = dry_run.is_dry_run() || check;
if let Some(ref ignore) = config.ignore {
let output = sync_ignore_files(directory, ignore, effective_dry_run)?;
if !output.is_empty() {
outputs.push(output);
}
}
if let Some(ref editorconfig) = config.editorconfig {
let output = sync_editorconfig(directory, editorconfig, effective_dry_run, is_root)?;
if !output.is_empty() {
outputs.push(output);
}
}
if check && !outputs.is_empty() {
let changes: Vec<&str> = outputs
.iter()
.filter(|o| o.contains("Would"))
.map(String::as_str)
.collect();
if !changes.is_empty() {
return Err(cuenv_core::Error::configuration(format!(
"Files are out of sync:\n{}",
changes.join("\n")
)));
}
}
Ok(outputs.join("\n"))
}
fn build_project_owners(
directory: &Path,
repo_root: &Path,
config: &DirectoryRules,
) -> Option<ProjectOwners> {
let owners = config.owners.as_ref()?;
if owners.rules.is_empty() {
return None;
}
let mut rule_entries: Vec<_> = owners.rules.iter().collect();
rule_entries.sort_by(|a, b| {
let order_a = a.1.order.unwrap_or(i32::MAX);
let order_b = b.1.order.unwrap_or(i32::MAX);
order_a.cmp(&order_b).then_with(|| a.0.cmp(b.0))
});
let rules: Vec<Rule> = rule_entries
.into_iter()
.map(|(_, r)| {
let mut rule = Rule::new(&r.pattern, r.owners.clone());
if let Some(ref description) = r.description {
rule = rule.description(description.clone());
}
if let Some(ref section) = r.section {
rule = rule.section(section.clone());
}
rule
})
.collect();
let relative_path = directory
.strip_prefix(repo_root)
.unwrap_or(directory)
.to_path_buf();
let project_name = relative_path
.to_str()
.filter(|s| !s.is_empty())
.unwrap_or("root")
.to_string();
Some(ProjectOwners::new(relative_path, project_name, rules))
}
fn sync_ignore_files(directory: &Path, ignore: &Ignore, dry_run: bool) -> Result<String> {
let files: Vec<IgnoreFile> = ignore
.iter()
.map(|(tool, value)| {
let patterns = match value {
IgnoreValue::Patterns(patterns) => patterns.clone(),
IgnoreValue::Extended(entry) => entry.patterns.clone(),
};
let filename = match value {
IgnoreValue::Patterns(_) => None,
IgnoreValue::Extended(entry) => entry.filename.clone(),
};
IgnoreFile::new(tool)
.patterns(patterns)
.filename_opt(filename)
.header(CUENV_HEADER)
})
.collect();
if files.is_empty() {
return Ok(String::new());
}
let result = IgnoreFiles::builder()
.directory(directory)
.require_git_repo(false) .dry_run(dry_run)
.files(files)
.generate()
.map_err(|e| {
cuenv_core::Error::configuration(format!("Failed to generate ignore files: {e}"))
})?;
let mut outputs = Vec::new();
for file in &result.files {
outputs.push(format!("{}: {}", file.filename, file.status));
}
Ok(outputs.join("\n"))
}
fn sync_editorconfig(
directory: &Path,
config: &cuenv_core::manifest::EditorConfig,
dry_run: bool,
is_root: bool,
) -> Result<String> {
if config.sections.is_empty() {
return Ok(String::new());
}
let mut builder = EditorConfigFile::builder()
.directory(directory)
.is_root(is_root)
.header(CUENV_HEADER)
.dry_run(dry_run);
for (pattern, section) in &config.sections {
let mut section_builder = BuilderSection::new();
if let Some(ref style) = section.indent_style {
section_builder = section_builder.indent_style(style);
}
if let Some(ref size) = section.indent_size {
section_builder = match size {
cuenv_core::manifest::EditorConfigValue::Int(n) => section_builder.indent_size(*n),
cuenv_core::manifest::EditorConfigValue::String(s) if s == "tab" => {
section_builder.indent_size_tab()
}
cuenv_core::manifest::EditorConfigValue::String(_) => section_builder,
};
}
if let Some(width) = section.tab_width {
section_builder = section_builder.tab_width(width);
}
if let Some(ref eol) = section.end_of_line {
section_builder = section_builder.end_of_line(eol);
}
if let Some(ref charset) = section.charset {
section_builder = section_builder.charset(charset);
}
if let Some(trim) = section.trim_trailing_whitespace {
section_builder = section_builder.trim_trailing_whitespace(trim);
}
if let Some(insert) = section.insert_final_newline {
section_builder = section_builder.insert_final_newline(insert);
}
if let Some(ref length) = section.max_line_length {
section_builder = match length {
cuenv_core::manifest::EditorConfigValue::Int(n) => {
section_builder.max_line_length(*n)
}
cuenv_core::manifest::EditorConfigValue::String(s) if s == "off" => {
section_builder.max_line_length_off()
}
cuenv_core::manifest::EditorConfigValue::String(_) => section_builder,
};
}
builder = builder.section(pattern, section_builder);
}
let result = builder.generate().map_err(|e| {
cuenv_core::Error::configuration(format!("Failed to generate .editorconfig: {e}"))
})?;
Ok(format!(".editorconfig: {}", result.status))
}
fn sync_codeowners(
repo_root: &Path,
projects: &[ProjectOwners],
dry_run: DryRun,
check: bool,
) -> Result<String> {
if projects.is_empty() {
return Ok(String::new());
}
let provider = detect_code_owners_provider(repo_root);
if check {
let result = provider
.check(repo_root, projects)
.map_err(|e| cuenv_core::Error::configuration(e.to_string()))?;
if result.in_sync {
return Ok(format!("{}: in sync", result.path.display()));
}
if result.actual.is_none() {
return Err(cuenv_core::Error::configuration(format!(
"CODEOWNERS file not found at {}",
result.path.display()
)));
}
return Err(cuenv_core::Error::configuration(format!(
"CODEOWNERS file is out of sync at {}",
result.path.display()
)));
}
let result = provider
.sync(repo_root, projects, dry_run.is_dry_run())
.map_err(|e| cuenv_core::Error::configuration(e.to_string()))?;
let status = match result.status {
SyncStatus::Created => "Created",
SyncStatus::Updated => "Updated",
SyncStatus::Unchanged => "Unchanged",
SyncStatus::WouldCreate => "Would create",
SyncStatus::WouldUpdate => "Would update",
};
Ok(format!("{} CODEOWNERS: {}", status, result.path.display()))
}
fn find_repo_root(start: &Path) -> Option<std::path::PathBuf> {
let repo = gix::discover(start).ok()?;
repo.workdir().map(|p| p.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn sync_codeowners_dry_run_reports_action() {
let temp = tempdir().expect("tempdir");
let projects = vec![ProjectOwners::new(
"services/api",
"services/api",
vec![Rule::new("*.rs", ["@backend"])],
)];
let output = sync_codeowners(temp.path(), &projects, true.into(), false).expect("sync");
assert!(output.contains("CODEOWNERS"));
assert!(
output.contains("Would"),
"expected dry-run status in output, got: {output}"
);
}
#[test]
fn sync_codeowners_check_fails_when_missing() {
let temp = tempdir().expect("tempdir");
let projects = vec![ProjectOwners::new(
"",
"root",
vec![Rule::new("*", ["@team"])],
)];
let err =
sync_codeowners(temp.path(), &projects, false.into(), true).expect_err("missing file");
let msg = err.to_string();
assert!(
msg.contains("CODEOWNERS file not found"),
"expected missing-file error, got: {msg}"
);
}
}