sniff-cli 0.1.0

An exhaustive LLM-backed slop finder for codebases
Documentation
use super::{env, io, llm, stats};
use crate::report_types::RunReport;
use indicatif::ProgressStyle;
use std::io::{Error as IoError, ErrorKind};

fn build_progress_style() -> Result<ProgressStyle, String> {
    ProgressStyle::default_bar()
        .template("[{bar:18.cyan/dim}] {pos}/{len} {percent}%")
        .map_err(|err| err.to_string())
        .map(|style| style.progress_chars("=>-"))
}

fn exit_code_for_run(has_issues: bool, ai_failed_reviews: usize) -> i32 {
    if has_issues || ai_failed_reviews > 0 {
        1
    } else {
        0
    }
}

fn report_path_for_target(target_path: &std::path::Path) -> std::path::PathBuf {
    let current_dir = std::env::current_dir().ok();
    let resolved_target = strip_windows_verbatim_prefix(
        std::fs::canonicalize(target_path).unwrap_or_else(|_| target_path.to_path_buf()),
    );

    if let Some(current_dir) = current_dir
        && resolved_target.starts_with(&current_dir)
    {
        return current_dir.join("sniff-report.md");
    }

    let mut candidate = if resolved_target.is_dir() {
        resolved_target
    } else {
        resolved_target
            .parent()
            .map(std::path::Path::to_path_buf)
            .unwrap_or_else(|| std::path::PathBuf::from("."))
    };

    loop {
        if [".git", "Cargo.toml", "pyproject.toml", "package.json"]
            .iter()
            .any(|marker| candidate.join(marker).exists())
        {
            return candidate.join("sniff-report.md");
        }

        let Some(parent) = candidate.parent() else {
            return candidate.join("sniff-report.md");
        };
        if parent == candidate {
            return candidate.join("sniff-report.md");
        }
        candidate = parent.to_path_buf();
    }
}

fn strip_windows_verbatim_prefix(path: std::path::PathBuf) -> std::path::PathBuf {
    let text = path.to_string_lossy();
    if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
        return std::path::PathBuf::from(format!(r"\\{}", rest));
    }
    if let Some(rest) = text.strip_prefix(r"\\?\") {
        return std::path::PathBuf::from(rest);
    }
    path
}

fn clear_previous_report(report_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
    match std::fs::metadata(report_path) {
        Ok(metadata) if metadata.is_file() => {
            std::fs::remove_file(report_path).map_err(|err| {
                IoError::other(format!(
                    "failed to remove stale report {}: {err}",
                    report_path.display()
                ))
            })?;
        }
        Ok(_) => {
            return Err(IoError::other(format!(
                "cannot write report: {} exists and is not a file",
                report_path.display()
            ))
            .into());
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => {
            return Err(IoError::other(format!(
                "failed to inspect report {}: {err}",
                report_path.display()
            ))
            .into());
        }
    }

    Ok(())
}

async fn scan_target(
    path: &str,
    config: &crate::config::ResolvedConfig,
) -> Result<Vec<crate::types::FileRecord>, String> {
    io::scan_files(path, config).await
}

async fn build_run_report(
    path: &str,
    only_files: bool,
    config: &crate::config::ResolvedConfig,
    file_records: &mut [crate::types::FileRecord],
    bar_style: &ProgressStyle,
) -> Result<(RunReport, bool), Box<dyn std::error::Error>> {
    let review =
        llm::prepare_review_artifacts(path, only_files, config, file_records, bar_style).await?;

    let stats = stats::generate_stats(stats::StatsInput {
        file_records,
        static_flags: &review.static_flags,
        verdicts: &review.verdicts,
        in_tok: review.in_tok,
        out_tok: review.out_tok,
        ai_expected_reviews: review.ai_expected_reviews,
    });

    if stats.ai_failed_reviews > 0 {
        return Err(IoError::other(format!(
            "AI review incomplete: {} of {} expected reviews did not complete for {path}. Sniff will not write the report.",
            stats.ai_failed_reviews, stats.ai_expected_reviews
        ))
        .into());
    }

    Ok(stats::build_run_report_from_parts(
        file_records,
        review.static_flags,
        review.verdicts,
        stats,
    ))
}

pub async fn run(
    path: &str,
    only_files: bool,
    skip_dotenv: bool,
) -> Result<i32, Box<dyn std::error::Error>> {
    let target_path = std::path::Path::new(path);
    let report_path = report_path_for_target(target_path);
    env::load_working_dir_env(skip_dotenv).map_err(IoError::other)?;
    env::load_target_env(target_path, skip_dotenv).map_err(IoError::other)?;
    let config = crate::config_loader::resolve_config(target_path)
        .map_err(|err| IoError::new(ErrorKind::InvalidInput, err))?;
    crate::roles::clear_file_role_cache();
    clear_previous_report(&report_path)?;

    eprintln!("Scanning source files...");
    let mut file_records = scan_target(path, &config)
        .await
        .map_err(|err| IoError::other(format!("file scan failed: {err}")))?;

    if file_records.is_empty() {
        return Err(IoError::new(
            ErrorKind::InvalidInput,
            "No supported source files found. Sniff currently handles Rust, Python, JavaScript/TypeScript, Go, and Kotlin.",
        )
        .into());
    }
    eprintln!("Found {} supported files.", file_records.len());
    if !config.llm.endpoint.trim().is_empty() {
        eprintln!("Using LLM endpoint: {}", config.llm.endpoint.trim());
    }
    eprintln!("Preparing report...");
    let bar_style = build_progress_style()
        .map_err(|err| IoError::other(format!("failed to build progress style: {}", err)))?;
    let (run_report, has_issues) =
        build_run_report(path, only_files, &config, &mut file_records, &bar_style).await?;

    let report_path_text = report_path.to_string_lossy().to_string();
    crate::reporter::render_report(&run_report, &config, Some(&report_path_text))
        .map_err(IoError::other)?;

    Ok(exit_code_for_run(
        has_issues,
        run_report.stats.ai_failed_reviews,
    ))
}

#[cfg(test)]
mod tests {
    use super::report_path_for_target;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn external_nested_target_writes_report_at_repository_root() {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let root = std::env::temp_dir().join(format!("sniff-report-root-{nonce}"));
        let nested = root.join("src");
        fs::create_dir_all(&nested).unwrap();
        fs::create_dir_all(root.join(".git")).unwrap();

        let report_path = report_path_for_target(&nested);

        assert_eq!(report_path, root.join("sniff-report.md"));
        fs::remove_dir_all(root).unwrap();
    }
}