difflore-cli 0.1.0

Your AI coding agent, taught by your team's PR reviews — a local-first, open-source MCP server that turns past review comments into rules your agent follows automatically.
Documentation
use std::path::PathBuf;

use difflore_core::models::DiffContentRecord;

use crate::commands::util::{ensure_project, project_path};
use crate::runtime::CommandContext;

use super::pr::{PreparePrOptions, PreparedPrFix, prepare_pr_fix};
use super::scope::{DiffScope, collect_diff, parse_diff_scope};

pub(super) struct FixContext {
    pub(super) db: difflore_core::SqlitePool,
    pub(super) path: PathBuf,
    pub(super) project_id: String,
    pub(super) diff_records: Vec<DiffContentRecord>,
    pub(super) diff_scope: DiffScope,
    pub(super) repo_full_name: Option<String>,
    pub(super) repo_full_name_aliases: Vec<String>,
    pub(super) target_file: Option<String>,
    pub(super) review_id: Option<String>,
    pub(super) pr_fix: Option<PreparedPrFix>,
}

#[allow(clippy::too_many_arguments)]
pub(super) async fn prepare_fix_context(
    cmd_ctx: &CommandContext,
    diff_scope_arg: Option<&str>,
    pr: Option<&str>,
    pr_repo: Option<&str>,
    pr_base: Option<&str>,
    pr_work_branch: Option<&str>,
    pr_no_checkout: bool,
    pr_allow_dirty: bool,
    pr_yes: bool,
    pr_preview: bool,
    path_arg: Option<&PathBuf>,
) -> anyhow::Result<FixContext> {
    let db = cmd_ctx.db.clone();
    let cwd = PathBuf::from(project_path());

    let pr_fix = if let Some(raw_pr) = pr {
        Some(
            prepare_pr_fix(
                &cwd,
                PreparePrOptions {
                    raw_pr,
                    repo_hint: pr_repo,
                    base_override: pr_base,
                    work_branch: pr_work_branch,
                    no_checkout: pr_no_checkout,
                    allow_dirty: pr_allow_dirty,
                    yes: pr_yes,
                    preview: pr_preview,
                },
            )
            .await?,
        )
    } else {
        None
    };

    let path = pr_fix
        .as_ref()
        .map(|pr| pr.repo_root.clone())
        .or_else(|| path_arg.filter(|p| p.is_dir()).cloned())
        .unwrap_or_else(|| cwd.clone());
    let target_file = path_arg
        .filter(|p| !p.is_dir())
        .map(|p| normalize_target_path(&path, p));
    let path_str = path.to_string_lossy().to_string();
    let project = ensure_project(&db, &path_str).await;

    let (mut diff_records, diff_scope, repo_full_name_aliases, repo_full_name, review_id) =
        if let Some(pr) = pr_fix.as_ref() {
            (
                pr.diff_records.clone(),
                DiffScope::PullRequest {
                    label: pr.scope_label.clone(),
                },
                pr.repo_full_name_aliases.clone(),
                Some(pr.repo_full_name.clone()),
                Some(pr.review_id.clone()),
            )
        } else {
            let requested_scope = parse_diff_scope(diff_scope_arg)?;
            let (diff_records, diff_scope) = collect_diff(&path, requested_scope).await?;
            let repo_full_name_aliases =
                difflore_core::git::detect_github_repo_full_names(&path_str);
            let repo_full_name = repo_full_name_aliases.first().cloned();
            (
                diff_records,
                diff_scope,
                repo_full_name_aliases,
                repo_full_name,
                None,
            )
        };
    if let Some(target) = target_file.as_deref() {
        diff_records.retain(|record| record.file_path == target);
    }

    Ok(FixContext {
        db,
        path,
        project_id: project.id,
        diff_records,
        diff_scope,
        repo_full_name,
        repo_full_name_aliases,
        target_file,
        review_id,
        pr_fix,
    })
}

fn normalize_target_path(repo_root: &std::path::Path, path: &std::path::Path) -> String {
    let relative = if path.is_absolute() {
        path.strip_prefix(repo_root).unwrap_or(path)
    } else {
        path
    };
    relative.to_string_lossy().replace('\\', "/")
}

// Prefer a representative source path so retrieval has a language signal
// instead of only diff headers and import noise.
pub(super) fn primary_file_for_retrieval(diff_records: &[DiffContentRecord]) -> Option<String> {
    let first_changed = diff_records.iter().find_map(non_empty_file_path);
    diff_records
        .iter()
        .find_map(|record| {
            let file = non_empty_file_path(record)?;
            if is_source_or_test_file(&file) {
                Some(file)
            } else {
                None
            }
        })
        .or(first_changed)
}

fn non_empty_file_path(record: &DiffContentRecord) -> Option<String> {
    let file = record.file_path.trim();
    if file.is_empty() {
        None
    } else {
        Some(file.to_owned())
    }
}

fn is_source_or_test_file(file: &str) -> bool {
    let normalized = file.replace('\\', "/").to_ascii_lowercase();
    let Some(ext) = std::path::Path::new(&normalized)
        .extension()
        .and_then(|ext| ext.to_str())
    else {
        return false;
    };
    matches!(
        ext,
        "c" | "cc"
            | "cpp"
            | "cxx"
            | "h"
            | "hpp"
            | "cs"
            | "go"
            | "java"
            | "js"
            | "jsx"
            | "mjs"
            | "cjs"
            | "ts"
            | "tsx"
            | "mts"
            | "cts"
            | "py"
            | "rb"
            | "rs"
            | "swift"
            | "kt"
            | "kts"
            | "php"
            | "vue"
            | "svelte"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn diff_record(file_path: &str) -> DiffContentRecord {
        DiffContentRecord {
            file_path: file_path.to_owned(),
            hunks: Vec::new(),
        }
    }

    #[test]
    fn primary_file_prefers_source_over_changeset() {
        let records = vec![
            diff_record(".changeset/whole-views-wear.md"),
            diff_record("packages/form-core/src/FieldApi.ts"),
            diff_record("packages/form-core/tests/DynamicValidation.spec.ts"),
        ];

        assert_eq!(
            primary_file_for_retrieval(&records).as_deref(),
            Some("packages/form-core/src/FieldApi.ts")
        );
    }

    #[test]
    fn primary_file_falls_back_to_docs_when_only_docs_changed() {
        let records = vec![diff_record(".changeset/whole-views-wear.md")];

        assert_eq!(
            primary_file_for_retrieval(&records).as_deref(),
            Some(".changeset/whole-views-wear.md")
        );
    }
}