lineguard 0.1.7

A fast and reliable file linter that ensures proper line endings and clean formatting
Documentation
use anyhow::Context;
use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use std::process::Command as StdCommand;
use tempfile::TempDir;

fn init_git_repo(dir: &TempDir) -> Result<(), Box<dyn std::error::Error>> {
    let repo_path = dir.path();

    // Initialize git repo
    StdCommand::new("git")
        .args(["init"])
        .current_dir(repo_path)
        .output()
        .context("Failed to initialize git repository")?;

    // Configure git
    StdCommand::new("git")
        .args(["config", "user.name", "Test User"])
        .current_dir(repo_path)
        .output()
        .context("Failed to configure git user.name")?;

    StdCommand::new("git")
        .args(["config", "user.email", "test@example.com"])
        .current_dir(repo_path)
        .output()
        .context("Failed to configure git user.email")?;

    // Disable GPG signing for tests
    StdCommand::new("git")
        .args(["config", "commit.gpgsign", "false"])
        .current_dir(repo_path)
        .output()
        .context("Failed to disable GPG signing for tests")?;

    Ok(())
}

fn create_commit(
    dir: &TempDir,
    files: &[(&str, &str)],
    message: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    let repo_path = dir.path();

    // Create/modify files
    for (filename, content) in files {
        std::fs::write(repo_path.join(filename), content)
            .with_context(|| format!("Failed to write file: {filename}"))?;
    }

    // Stage all files
    StdCommand::new("git")
        .args(["add", "-A"])
        .current_dir(repo_path)
        .output()
        .context("Failed to stage files with git add")?;

    // Commit
    StdCommand::new("git")
        .args(["commit", "-m", message])
        .current_dir(repo_path)
        .output()
        .with_context(|| format!("Failed to create commit: {message}"))?;

    // Get commit hash (short version for consistency)
    let output = StdCommand::new("git")
        .args(["rev-list", "-n", "1", "--abbrev-commit", "HEAD"])
        .current_dir(repo_path)
        .output()
        .context("Failed to get commit hash")?;

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

#[test]
fn test_from_option_checks_only_changed_files() {
    let temp_dir = TempDir::new().unwrap();
    init_git_repo(&temp_dir).unwrap();

    // First commit: create good files
    create_commit(
        &temp_dir,
        &[
            ("file1.txt", "line 1\nline 2\n"),
            ("file2.txt", "line 1\nline 2\n"),
        ],
        "Initial commit",
    )
    .unwrap();

    let first_commit =
        create_commit(&temp_dir, &[("file3.txt", "line 1\nline 2\n")], "Add file3").unwrap();

    // Second commit: add file with issues
    create_commit(
        &temp_dir,
        &[
            ("file4.txt", "line 1  \nline 2"), // Has issues
        ],
        "Add file4 with issues",
    )
    .unwrap();

    // Run lineguard from first commit
    let mut cmd = cargo_bin_cmd!("lineguard");
    cmd.current_dir(&temp_dir);
    cmd.arg("--from").arg(&first_commit);
    cmd.arg(".");

    cmd.assert()
        .code(1) // Should exit with code 1 when issues are found
        .stdout(predicate::str::contains("file4.txt"))
        .stdout(predicate::str::contains("file1.txt").not())
        .stdout(predicate::str::contains("file2.txt").not())
        .stdout(predicate::str::contains("file3.txt").not());
}

#[test]
fn test_from_to_option_checks_range() {
    let temp_dir = TempDir::new().unwrap();
    init_git_repo(&temp_dir).unwrap();

    // Create commits
    let commit1 =
        create_commit(&temp_dir, &[("file1.txt", "line 1\nline 2\n")], "Commit 1").unwrap();

    let _commit2 = create_commit(
        &temp_dir,
        &[
            ("file2.txt", "line 1  \nline 2\n"), // Has issues
        ],
        "Commit 2",
    )
    .unwrap();

    let commit3 = create_commit(
        &temp_dir,
        &[
            ("file3.txt", "line 1\nline 2"), // Has issues
        ],
        "Commit 3",
    )
    .unwrap();

    create_commit(
        &temp_dir,
        &[
            ("file4.txt", "line 1  \n"), // Has issues
        ],
        "Commit 4",
    )
    .unwrap();

    // Check only commits 2 and 3 (not 4)
    let mut cmd = cargo_bin_cmd!("lineguard");
    cmd.current_dir(&temp_dir);
    cmd.arg("--from").arg(&commit1);
    cmd.arg("--to").arg(&commit3);
    cmd.arg(".");

    cmd.assert()
        .code(1) // Should exit with code 1 when issues are found
        .stdout(predicate::str::contains("file2.txt"))
        .stdout(predicate::str::contains("file3.txt"))
        .stdout(predicate::str::contains("file4.txt").not());
}

#[test]
fn test_from_without_git_repo_shows_error() {
    let temp_dir = TempDir::new().unwrap();

    // Create a file without git repo
    std::fs::write(temp_dir.path().join("file.txt"), "content\n").unwrap();

    let mut cmd = cargo_bin_cmd!("lineguard");
    cmd.current_dir(&temp_dir);
    cmd.arg("--from").arg("HEAD~1");
    cmd.arg(".");

    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("not a git repository"));
}

#[test]
fn test_invalid_git_reference_shows_error() {
    let temp_dir = TempDir::new().unwrap();
    init_git_repo(&temp_dir).unwrap();

    create_commit(&temp_dir, &[("file.txt", "content\n")], "Initial commit").unwrap();

    let mut cmd = cargo_bin_cmd!("lineguard");
    cmd.current_dir(&temp_dir);
    cmd.arg("--from").arg("invalid-hash");
    cmd.arg(".");

    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("Invalid git reference"));
}

#[test]
fn test_from_option_with_fix_mode() {
    let temp_dir = TempDir::new().unwrap();
    init_git_repo(&temp_dir).unwrap();

    // First commit
    let first_commit = create_commit(
        &temp_dir,
        &[("unchanged.txt", "line 1\nline 2\n")],
        "First commit",
    )
    .unwrap();

    // Second commit with issues
    create_commit(
        &temp_dir,
        &[("changed.txt", "line 1  \nline 2")],
        "Add file with issues",
    )
    .unwrap();

    // Fix only changed files
    let mut cmd = cargo_bin_cmd!("lineguard");
    cmd.current_dir(&temp_dir);
    cmd.arg("--from").arg(&first_commit);
    cmd.arg("--fix");
    cmd.arg(".");

    cmd.assert().success();

    // Verify only changed file was fixed
    let content = std::fs::read_to_string(temp_dir.path().join("changed.txt")).unwrap();
    assert_eq!(content, "line 1\nline 2\n");
}

#[test]
fn test_from_option_with_json_output() {
    let temp_dir = TempDir::new().unwrap();
    init_git_repo(&temp_dir).unwrap();

    let first_commit =
        create_commit(&temp_dir, &[("file1.txt", "content\n")], "First commit").unwrap();

    create_commit(&temp_dir, &[("file2.txt", "content  \n")], "Second commit").unwrap();

    let mut cmd = cargo_bin_cmd!("lineguard");
    cmd.current_dir(&temp_dir);
    cmd.arg("--from").arg(&first_commit);
    cmd.arg("--format").arg("json");
    cmd.arg(".");

    cmd.assert()
        .code(1) // Should exit with code 1 when issues are found
        .stdout(predicate::str::contains("\"files_checked\": 1"))
        .stdout(predicate::str::contains("file2.txt"));
}

#[test]
fn test_verbose_mode_shows_git_range_info() {
    let temp_dir = TempDir::new().unwrap();
    init_git_repo(&temp_dir).unwrap();

    // Create commits
    let commit1 = create_commit(
        &temp_dir,
        &[
            ("file1.txt", "line 1\nline 2\n"),
            ("file2.txt", "line 1\nline 2\n"),
        ],
        "Initial commit",
    )
    .unwrap();

    let _commit2 = create_commit(
        &temp_dir,
        &[
            ("file3.txt", "line 1\nline 2\n"),
            ("file4.txt", "line 1  \nline 2"), // Has issues
        ],
        "Add more files",
    )
    .unwrap();

    let commit3 = create_commit(
        &temp_dir,
        &[
            ("file5.txt", "line 1\nline 2\n"),
            ("file6.txt", "line 1\nline 2"), // Has issues
        ],
        "Add even more files",
    )
    .unwrap();

    // Run with verbose mode
    let mut cmd = cargo_bin_cmd!("lineguard");
    cmd.current_dir(&temp_dir);
    cmd.arg("--verbose");
    cmd.arg("--from").arg(&commit1);
    cmd.arg("--to").arg(&commit3);
    cmd.arg(".");

    cmd.assert()
        .code(1) // Should exit with code 1 when issues are found
        .stdout(predicate::str::contains("Git range:"))
        .stdout(predicate::str::contains(&commit1[0..7]))  // Short hash
        .stdout(predicate::str::contains(&commit3[0..7]))  // Short hash
        .stdout(predicate::str::contains("Changed files: 4"))
        .stdout(predicate::str::contains("file3.txt"))
        .stdout(predicate::str::contains("file4.txt"))
        .stdout(predicate::str::contains("file5.txt"))
        .stdout(predicate::str::contains("file6.txt"));
}