agent-rules-tool 0.1.0-rc.1

Lint and migrate agent rules per agent-rules-spec
Documentation
use agent_rules_tool::cli::{Cli, Commands, SeverityArg};
use agent_rules_tool::format::RuleFormat;
use agent_rules_tool::lint::{exceeds_threshold, lint_directory, lint_string};
use agent_rules_tool::migrate::{
    MigrateOptions, MigrateWarning, build_inputs_from_dirs, migrate_paths, migrate_string,
};
use agent_rules_tool::report::{report_to_yaml_aggregate, report_to_yaml_single};
use agent_rules_tool::spec::DEFAULT_LINT_DIR;
use agent_rules_tool::{FileLintResult, LintOptions, Severity};
use clap::Parser;
use std::env;
use std::path::PathBuf;
use std::process::ExitCode;
use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() -> ExitCode {
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .init();

    let cli = Cli::parse();
    match run(cli).await {
        Ok(code) => ExitCode::from(code),
        Err(err) => {
            error!(error = %err, "command failed");
            ExitCode::from(1)
        }
    }
}

async fn run(cli: Cli) -> Result<u8, agent_rules_tool::Error> {
    match cli.command {
        Commands::Lint {
            directory,
            input_file,
            severity,
            report,
        } => run_lint(directory, input_file, severity, report).await,
        Commands::Migrate {
            from,
            directory,
            input_file,
            to,
            output,
            force,
        } => run_migrate(from, directory, input_file, to, output, force).await,
    }
}

async fn run_lint(
    directory: Option<PathBuf>,
    input_file: Option<PathBuf>,
    severity: SeverityArg,
    report: Option<PathBuf>,
) -> Result<u8, agent_rules_tool::Error> {
    let threshold: Severity = severity.into();
    let options = LintOptions {
        severity_threshold: threshold,
        filename_hint: None,
    };

    let (results, single): (Vec<FileLintResult>, Option<FileLintResult>) =
        if let Some(file) = input_file {
            let content = tokio::fs::read_to_string(&file).await?;
            let filename_hint = file
                .file_stem()
                .and_then(|s| s.to_str())
                .map(str::to_string);
            let mut opts = options.clone();
            opts.filename_hint = filename_hint;
            let report_result = lint_string(&content, &opts)?;
            emit_violations(&file.display().to_string(), &report_result.violations);
            let file_result = FileLintResult {
                path: file.clone(),
                report: report_result,
            };
            (Vec::new(), Some(file_result))
        } else {
            let dir = directory.unwrap_or_else(|| PathBuf::from(DEFAULT_LINT_DIR));
            if !dir.exists() {
                warn!(dir = %dir.display(), "directory does not exist; nothing to lint");
                return Ok(0);
            }
            let results = lint_directory(&dir, &options)?;
            if results.is_empty() {
                info!(dir = %dir.display(), "no markdown files found");
            }
            for r in &results {
                emit_violations(&r.path.display().to_string(), &r.report.violations);
            }
            (results, None)
        };

    let failed = if let Some(ref file_result) = single {
        exceeds_threshold(&file_result.report.violations, threshold)
    } else if results.is_empty() {
        false
    } else {
        results
            .iter()
            .any(|r| exceeds_threshold(&r.report.violations, threshold))
    };

    if let Some(path) = report {
        let yaml = if let Some(ref file_result) = single {
            report_to_yaml_single(&file_result.report)?
        } else {
            report_to_yaml_aggregate(&results)?
        };
        tokio::fs::write(&path, yaml).await?;
        info!(path = %path.display(), "wrote lint report");
    }

    Ok(if failed { 1 } else { 0 })
}

async fn run_migrate(
    from: agent_rules_tool::format::RuleFormatArg,
    directory: Option<PathBuf>,
    input_file: Option<PathBuf>,
    to: agent_rules_tool::format::RuleFormatArg,
    output: Option<PathBuf>,
    force: bool,
) -> Result<u8, agent_rules_tool::Error> {
    let from_format = RuleFormat::from(from);
    let to_format = RuleFormat::from(to);
    let options = MigrateOptions {
        from: from_format,
        to: to_format,
        force,
        filename_hint: None,
    };

    if let Some(file) = input_file {
        let content = tokio::fs::read_to_string(&file).await?;
        let filename_hint = file
            .file_stem()
            .and_then(|s| s.to_str())
            .map(str::to_string);
        let mut opts = options.clone();
        opts.filename_hint = filename_hint;
        let migrated = migrate_string(&content, &opts)?;
        emit_migration_warnings(&migrated.warnings);

        if let Some(out) = output {
            let written =
                agent_rules_tool::io::write_file_atomic(&out, &migrated.content, force).await?;
            if written == agent_rules_tool::io::WriteOutcome::Skipped {
                warn!(path = %out.display(), "output exists; skipping (use --force to overwrite)");
            } else {
                info!(path = %out.display(), "wrote migrated file");
            }
        } else {
            print!("{}", migrated.content);
        }
        return Ok(0);
    }

    let project_root = env::current_dir()?;
    let output_root = output
        .clone()
        .unwrap_or_else(|| PathBuf::from(to_format.default_output_dir()));

    let inputs = build_inputs_from_dirs(&project_root, directory.as_deref(), from_format)?;
    if inputs.is_empty() {
        warn!("no rule files found to migrate");
        return Ok(0);
    }

    let summary = migrate_paths(&inputs, &output_root, &options).await?;
    emit_migration_warnings(&summary.warnings);
    info!(
        written = summary.written.len(),
        skipped = summary.skipped.len(),
        output = %output_root.display(),
        "migration complete"
    );
    Ok(0)
}

fn emit_violations(path: &str, violations: &[agent_rules_tool::Violation]) {
    for v in violations {
        match v.severity {
            Severity::Error => error!(
                path = path,
                field = %v.field,
                spec = %v.spec_ref,
                "{}",
                v.message
            ),
            Severity::Warn => warn!(
                path = path,
                field = %v.field,
                spec = %v.spec_ref,
                "{}",
                v.message
            ),
        }
    }
}

fn emit_migration_warnings(warnings: &[MigrateWarning]) {
    for w in warnings {
        if let Some(ref field) = w.field {
            warn!(field = %field, "{}", w.message);
        } else {
            warn!("{}", w.message);
        }
    }
}