use std::any::Any;
use std::path::Path;
use async_trait::async_trait;
use clap::{Arg, Command, arg};
use cuenv_codeowners::Rule;
use cuenv_codeowners::provider::{ProjectOwners, SyncStatus};
use cuenv_core::DryRun;
use cuenv_core::Result;
use cuenv_core::manifest::{Base, DirectoryRules, Ignore, IgnoreValue};
use cuenv_editorconfig::{EditorConfigFile, EditorConfigSection as BuilderSection};
use cuenv_ignore::{IgnoreFile, IgnoreFiles};
use ignore::WalkBuilder;
use crate::commands::CommandExecutor;
use crate::commands::sync::provider::{SyncMode, SyncOptions, SyncResult};
use crate::provider::{Provider, SyncCapability};
use crate::providers::detect_code_owners_provider;
const CUENV_HEADER: &str = "Generated by cuenv - do not edit\nSource: .rules.cue";
pub struct RulesProvider;
impl RulesProvider {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for RulesProvider {
fn default() -> Self {
Self::new()
}
}
impl Provider for RulesProvider {
fn name(&self) -> &'static str {
"rules"
}
fn description(&self) -> &'static str {
"Sync configuration from .rules.cue files (ignore, editorconfig, codeowners)"
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[async_trait]
impl SyncCapability for RulesProvider {
fn build_sync_command(&self) -> Command {
Command::new(self.name())
.about(self.description())
.arg(arg!(-p --path <PATH> "Path to directory containing CUE files").default_value("."))
.arg(
Arg::new("package")
.long("package")
.help("Name of the CUE package to evaluate")
.default_value("cuenv"),
)
.arg(arg!(--"dry-run" "Show what would be generated without writing files"))
.arg(arg!(--check "Check if files are in sync without making changes"))
.arg(arg!(-A --all "Sync all projects in the workspace"))
}
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 has_config(&self, _manifest: &Base) -> bool {
true
}
}
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::*;
#[test]
fn test_rules_provider_name() {
let provider = RulesProvider::new();
assert_eq!(provider.name(), "rules");
}
#[test]
fn test_rules_provider_description() {
let provider = RulesProvider::new();
assert!(!provider.description().is_empty());
assert!(provider.description().contains(".rules.cue"));
}
#[test]
fn test_rules_provider_as_any() {
let provider = RulesProvider::new();
let any = provider.as_any();
assert!(any.is::<RulesProvider>());
}
#[test]
fn test_rules_provider_as_any_mut() {
let mut provider = RulesProvider::new();
let any = provider.as_any_mut();
assert!(any.is::<RulesProvider>());
}
#[test]
fn test_rules_provider_command() {
let provider = RulesProvider::new();
let cmd = provider.build_sync_command();
assert_eq!(cmd.get_name(), "rules");
}
#[test]
fn test_rules_provider_command_has_args() {
let provider = RulesProvider::new();
let cmd = provider.build_sync_command();
let args: Vec<_> = cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
assert!(args.contains(&"path"));
assert!(args.contains(&"package"));
assert!(args.contains(&"dry-run"));
assert!(args.contains(&"check"));
assert!(args.contains(&"all"));
}
#[test]
fn test_rules_provider_default() {
let provider = RulesProvider;
assert_eq!(provider.name(), "rules");
}
#[test]
fn test_rules_provider_has_config() {
let provider = RulesProvider::new();
let base = Base::default();
assert!(provider.has_config(&base));
}
#[test]
fn test_sync_codeowners_empty() {
let projects: Vec<ProjectOwners> = vec![];
let output = sync_codeowners(Path::new("."), &projects, false.into(), false).expect("sync");
assert!(output.is_empty());
}
#[test]
fn test_sync_codeowners_dry_run_reports_action() {
let projects = vec![ProjectOwners::new(
"services/api",
"services/api",
vec![Rule::new("*.rs", ["@rust-team"])],
)];
let output = sync_codeowners(Path::new("."), &projects, true.into(), false).expect("sync");
assert!(output.contains("CODEOWNERS"));
assert!(output.contains("Would"));
}
#[test]
fn test_cuenv_header_constant() {
assert!(CUENV_HEADER.contains("Generated by cuenv"));
assert!(CUENV_HEADER.contains(".rules.cue"));
}
}