slippy-cli 0.1.0

AI Linter for Rust projects
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

use futures_util::future::join_all;
use slippy_linter::{
    FileFilter, LintConfig, RawSlippyConfiguration, SlippyConfiguration, finalize,
};

fn config() -> SlippyConfiguration {
    dotenvy::dotenv().ok();
    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
    let _ = tracing_subscriber::fmt()
        .with_env_filter(env_filter)
        .with_writer(std::io::stderr)
        .with_target(false)
        .without_time()
        .try_init();
    let raw: RawSlippyConfiguration = toml::from_str(include_str!("../slippy.toml")).unwrap();
    finalize(raw, &Default::default()).unwrap()
}

#[tokio::test]
async fn lint_option_result_misuse() {
    test_compare_stderr_for_lint("option_result_misuse").await;
}

#[tokio::test]
async fn lint_string_used_as_enum() {
    test_compare_stderr_for_lint("string_used_as_enum").await;
}

#[tokio::test]
async fn lint_unnecessary_contains_in_loop() {
    test_compare_stderr_for_lint("unnecessary_contains_in_loop").await;
}

#[tokio::test]
async fn lint_representable_illegal_state() {
    test_errors_exist_for_lint("representable_illegal_state").await;
}

#[tokio::test]
async fn internal_examples_match_stderr() {
    run_examples_in_subdir("internal").await;
}

async fn test_compare_stderr_for_lint(lint_name: &str) {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let lint_dir = root.join("examples").join("lints").join(lint_name);
    let rel_dir = Path::new("examples").join("lints").join(lint_name);
    let config = LintConfig::new(config(), true);

    let mut entries: Vec<_> = std::fs::read_dir(&lint_dir)
        .unwrap()
        .map(|e| e.unwrap().path())
        .collect();
    entries.sort();

    let mut tasks = Vec::new();
    for entry in &entries {
        if entry.extension() == Some(OsStr::new("rs")) {
            let stderr_path = entry.with_extension("stderr");
            if !stderr_path.exists() {
                panic!("missing stderr file for {}", entry.display());
            }
            let name = entry.file_name().unwrap();
            tasks.push((rel_dir.join(name), stderr_path.clone()));
        }
    }

    if std::env::var("BATCH").is_ok() {
        run_batch_tasks(&tasks, &config, 10, 9).await;
    } else {
        run_tasks_assert_all(&tasks, &config).await;
    }
}

async fn test_errors_exist_for_lint(lint_name: &str) {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let lint_dir = root.join("examples").join("lints").join(lint_name);
    let rel_dir = Path::new("examples").join("lints").join(lint_name);
    let config = LintConfig::new(config(), true);

    let tasks = collect_errors_tasks_in_dir(&lint_dir, &rel_dir);

    if std::env::var("BATCH").is_ok() {
        check_errors_exist_batch(&tasks, &config, 10, 9).await;
    } else {
        check_errors_exist(&tasks, &config).await;
    }
}

fn collect_errors_tasks_in_dir(abs_dir: &Path, rel_dir: &Path) -> Vec<(PathBuf, PathBuf)> {
    let mut entries: Vec<_> = std::fs::read_dir(abs_dir)
        .unwrap()
        .map(|e| e.unwrap().path())
        .collect();
    entries.sort();

    let mut tasks = Vec::new();
    for entry in &entries {
        if entry.extension() == Some(OsStr::new("rs")) {
            let errors_path = entry.with_extension("errors");
            if !errors_path.exists() {
                panic!("missing .errors file for {}", entry.display());
            }
            let name = entry.file_name().unwrap();
            tasks.push((rel_dir.join(name), errors_path.clone()));
        }
    }
    tasks
}

async fn check_errors_exist(tasks: &[(PathBuf, PathBuf)], config: &LintConfig<'_>) {
    let futures = tasks
        .iter()
        .map(|(relative, errors_path)| check_errors(relative, errors_path, config));

    let failures: Vec<String> = join_all(futures).await.into_iter().flatten().collect();

    assert!(
        failures.is_empty(),
        "{} examples checked, {} failures:\n\n{}",
        tasks.len(),
        failures.len(),
        failures.join("\n"),
    );
}

async fn check_errors_exist_batch(
    tasks: &[(PathBuf, PathBuf)],
    config: &LintConfig<'_>,
    runs: usize,
    min_pass: usize,
) {
    let mut pass_counts = vec![0usize; tasks.len()];
    let mut all_errors: Vec<Vec<String>> = vec![Vec::new(); tasks.len()];
    for _ in 0..runs {
        let futures = tasks
            .iter()
            .map(|(relative, errors_path)| check_errors(relative, errors_path, config));
        let results: Vec<Option<String>> = join_all(futures).await;
        for (i, result) in results.into_iter().enumerate() {
            match result {
                None => pass_counts[i] += 1,
                Some(err) => all_errors[i].push(err),
            }
        }
    }

    let failures: Vec<String> = tasks
        .iter()
        .zip(pass_counts.iter())
        .zip(all_errors.iter())
        .filter(|((_, count), _)| **count < min_pass)
        .map(|(((relative, _), &count), errors)| {
            format!(
                "BATCH FAILURE: {} ({}/{} runs passed, need at least {}):\n{}",
                relative.display(),
                count,
                runs,
                min_pass,
                errors.join("\n"),
            )
        })
        .collect();

    assert!(
        failures.is_empty(),
        "Batch failures:\n{}",
        failures.join("\n")
    );
}

async fn check_errors(
    relative: &Path,
    errors_path: &Path,
    config: &LintConfig<'_>,
) -> Option<String> {
    let errors_content = std::fs::read_to_string(errors_path).unwrap();
    let expect_diagnostics = !errors_content.trim().is_empty();

    let filter = FileFilter::allow_all();
    let mut buf = Vec::new();
    match slippy_linter::lint_file(relative, config, &filter, &mut buf).await {
        Ok(_stats) => {
            let output = String::from_utf8(buf).unwrap();
            let has_output = !strip_ansi(&output).trim().is_empty();

            if expect_diagnostics && !has_output {
                Some(format!(
                    "EXPECTED DIAGNOSTICS but got none: {}",
                    relative.display(),
                ))
            } else if !expect_diagnostics && has_output {
                Some(format!(
                    "EXPECTED NO DIAGNOSTICS but got output: {}\n{}",
                    relative.display(),
                    strip_ansi(&output),
                ))
            } else {
                None
            }
        }
        Err(e) => Some(format!("ERROR: {}: {e}\n", relative.display())),
    }
}

async fn run_tasks_assert_all(tasks: &[(PathBuf, PathBuf)], config: &LintConfig<'_>) {
    let futures = tasks
        .iter()
        .map(|(relative, stderr_path)| check_example(relative, stderr_path, config));

    let failures: Vec<String> = join_all(futures).await.into_iter().flatten().collect();

    assert!(
        failures.is_empty(),
        "{} examples checked, {} failures:\n\n{}",
        tasks.len(),
        failures.len(),
        failures.join("\n"),
    );
}

async fn run_batch_tasks(
    tasks: &[(PathBuf, PathBuf)],
    config: &LintConfig<'_>,
    runs: usize,
    min_pass: usize,
) {
    let mut pass_counts = vec![0usize; tasks.len()];
    let mut all_errors: Vec<Vec<String>> = vec![Vec::new(); tasks.len()];
    for _ in 0..runs {
        let futures = tasks
            .iter()
            .map(|(relative, stderr_path)| check_example(relative, stderr_path, config));
        let results: Vec<Option<String>> = join_all(futures).await;
        for (i, result) in results.into_iter().enumerate() {
            match result {
                None => pass_counts[i] += 1,
                Some(err) => all_errors[i].push(err),
            }
        }
    }

    let failures: Vec<String> = tasks
        .iter()
        .zip(pass_counts.iter())
        .zip(all_errors.iter())
        .filter(|((_, count), _)| **count < min_pass)
        .map(|(((relative, _), &count), errors)| {
            format!(
                "BATCH FAILURE: {} ({}/{} runs passed, need at least {}):\n{}",
                relative.display(),
                count,
                runs,
                min_pass,
                errors.join("\n"),
            )
        })
        .collect();

    assert!(
        failures.is_empty(),
        "Batch failures:\n{}",
        failures.join("\n")
    );
}

async fn run_examples_in_subdir(subdir: &str) {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let examples_dir = root.join("examples").join(subdir);
    let config = LintConfig::new(config(), true);

    let mut entries: Vec<_> = std::fs::read_dir(&examples_dir)
        .unwrap()
        .map(|e| e.unwrap().path())
        .collect();
    entries.sort();

    let mut tasks = Vec::new();

    for entry in &entries {
        if entry.extension() == Some(OsStr::new("rs")) {
            let stderr_path = entry.with_extension("stderr");
            if !stderr_path.exists() {
                panic!("missing stderr file for {}", entry.display());
            }
            let name = entry.file_name().unwrap();
            let relative = Path::new("examples").join(subdir).join(name);
            tasks.push((relative, stderr_path));
        } else if entry.is_dir() {
            let dir_name = entry.file_name().unwrap();
            let stderr_path = examples_dir.join(dir_name).with_extension("stderr");
            if !stderr_path.exists() {
                panic!("missing stderr file for {}", entry.display());
            }

            let main_rs = entry.join("main.rs");
            let entry_file = if main_rs.exists() {
                main_rs
            } else {
                panic!("MISSING ENTRY: {}: no main.rs or lib.rs", entry.display());
            };

            let relative = Path::new("examples")
                .join(subdir)
                .join(dir_name)
                .join(entry_file.file_name().unwrap());
            tasks.push((relative, stderr_path));
        }
    }

    let futures = tasks
        .iter()
        .map(|(relative, stderr_path)| check_example(relative, stderr_path, &config));

    let failures: Vec<String> = join_all(futures).await.into_iter().flatten().collect();

    assert!(
        failures.is_empty(),
        "{} examples checked, {} failures:\n\n{}",
        entries.len(),
        failures.len(),
        failures.join("\n"),
    );
}

async fn check_example(
    relative: &Path,
    stderr_path: &Path,
    config: &LintConfig<'_>,
) -> Option<String> {
    let expected = std::fs::read_to_string(stderr_path).unwrap();
    let filter = FileFilter::allow_all();
    let mut buf = Vec::new();
    match slippy_linter::lint_file(relative, config, &filter, &mut buf).await {
        Ok(_stats) => {
            let output = String::from_utf8(buf).unwrap();
            if strip_ansi(&output).trim() != expected.trim() {
                let actual = strip_ansi(&output);
                Some(format!(
                    "MISMATCH: {}\n\n========================================= expected =========================================\n{expected}\n========================================= actual =========================================\n{actual}\n--- end ---",
                    relative.display(),
                ))
            } else {
                None
            }
        }
        Err(e) => Some(format!("ERROR: {}: {e}\n", relative.display())),
    }
}

fn strip_ansi(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars();
    while let Some(c) = chars.next() {
        if c == '\x1b' {
            if chars.next() == Some('[') {
                for c in chars.by_ref() {
                    if c.is_ascii_alphabetic() {
                        break;
                    }
                }
            }
        } else {
            out.push(c);
        }
    }
    out
}