diffctx 1.12.1

Selects the minimum code an LLM needs to review a git diff: walks the dependency graph outward from changed lines and stops when extra context stops paying for itself
Documentation
use std::path::{Path, PathBuf};

use once_cell::sync::Lazy;
use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet};

use crate::config::edge_weights::DOCKER;
use crate::types::Fragment;

use super::super::EdgeDict;
use super::super::base::{self, EdgeBuilder, FragmentIndex, add_edge, link_by_name};

static DOCKERFILE_COPY_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?mi)^(?:COPY|ADD)\s+(?:--[^\s]+\s+)*(.+)").unwrap());
static DOCKERFILE_ENV_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?mi)^ENV\s+(\w+)").unwrap());
static DOCKERFILE_ARG_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?mi)^ARG\s+(\w+)").unwrap());

static COMPOSE_BUILD_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r##"(?m)^\s+build:\s*['"]?([^'"#\n]+)"##).unwrap());
static COMPOSE_CONTEXT_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r##"(?m)^\s+context:\s*['"]?([^'"#\n]+)"##).unwrap());
static COMPOSE_VOLUME_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"(?m)^\s+-\s*['"]?([./][^:'"\n]+):"#).unwrap());

fn is_dockerfile(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == "dockerfile" || name.starts_with("dockerfile.") || name.ends_with(".dockerfile")
}

fn is_compose_file(path: &Path) -> bool {
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_lowercase())
        .unwrap_or_default();
    name == "docker-compose.yml"
        || name == "docker-compose.yaml"
        || name == "compose.yml"
        || name == "compose.yaml"
}

fn strip_dot_slash(s: &str) -> &str {
    let mut s = s;
    while let Some(rest) = s.strip_prefix("./") {
        s = rest;
    }
    s
}

fn split_copy_sources(raw: &str) -> Vec<&str> {
    let tokens: Vec<&str> = raw.split_whitespace().collect();
    if tokens.len() < 2 {
        return vec![];
    }
    tokens[..tokens.len() - 1].to_vec()
}

fn collect_dockerfile_refs(content: &str) -> FxHashSet<String> {
    let mut refs = FxHashSet::default();

    for cap in DOCKERFILE_COPY_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            for src in split_copy_sources(m.as_str()) {
                if !src.starts_with("--") && !src.starts_with('$') {
                    let cleaned =
                        strip_dot_slash(src.trim().trim_matches(|c| c == '\'' || c == '"'));
                    if !cleaned.is_empty() {
                        refs.insert(cleaned.to_string());
                    }
                }
            }
        }
    }

    refs
}

fn collect_compose_refs(content: &str) -> FxHashSet<String> {
    let mut refs = FxHashSet::default();

    for cap in COMPOSE_BUILD_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            let val = strip_dot_slash(m.as_str().trim());
            if !val.is_empty() {
                refs.insert(val.to_string());
            }
        }
    }

    for cap in COMPOSE_VOLUME_RE.captures_iter(content) {
        if let Some(m) = cap.get(1) {
            let val = strip_dot_slash(m.as_str().trim());
            if !val.is_empty() {
                refs.insert(val.to_string());
            }
        }
    }

    refs
}

pub struct DockerEdgeBuilder;

impl EdgeBuilder for DockerEdgeBuilder {
    fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
        let dockerfiles: Vec<&Fragment> = fragments
            .iter()
            .filter(|f| is_dockerfile(Path::new(f.path())))
            .collect();
        let compose_files: Vec<&Fragment> = fragments
            .iter()
            .filter(|f| is_compose_file(Path::new(f.path())))
            .collect();

        if dockerfiles.is_empty() && compose_files.is_empty() {
            return EdgeDict::default();
        }

        let idx = FragmentIndex::new(fragments, repo_root);
        let mut edges = EdgeDict::default();

        for df in &dockerfiles {
            let copy_refs = collect_dockerfile_refs(&df.content);
            for r in &copy_refs {
                link_by_name(
                    &df.id,
                    r,
                    &idx,
                    &mut edges,
                    DOCKER.copy_weight,
                    DOCKER.reverse_factor,
                );
            }

            let has_env = DOCKERFILE_ENV_RE.is_match(&df.content);
            let has_arg = DOCKERFILE_ARG_RE.is_match(&df.content);
            if has_env || has_arg {
                for f in fragments {
                    let fpath = Path::new(f.path());
                    let fname = fpath
                        .file_name()
                        .map(|n| n.to_string_lossy().to_lowercase())
                        .unwrap_or_default();
                    let ext = fpath
                        .extension()
                        .map(|e| e.to_string_lossy().to_lowercase())
                        .unwrap_or_default();
                    if (ext == "env" || fname.starts_with(".env")) && f.id != df.id {
                        add_edge(
                            &mut edges,
                            &df.id,
                            &f.id,
                            DOCKER.weight,
                            DOCKER.reverse_factor,
                        );
                    }
                }
            }
        }

        for cf in &compose_files {
            let df_dir = Path::new(cf.path()).parent();
            for df in &dockerfiles {
                let dockerfile_dir = Path::new(df.path()).parent();
                if let (Some(cf_parent), Some(df_parent)) = (df_dir, dockerfile_dir) {
                    if df_parent == cf_parent || df_parent.parent() == Some(cf_parent) {
                        add_edge(
                            &mut edges,
                            &cf.id,
                            &df.id,
                            DOCKER.compose_weight,
                            DOCKER.reverse_factor,
                        );
                    }
                }
            }

            let compose_refs = collect_compose_refs(&cf.content);
            for r in &compose_refs {
                link_by_name(
                    &cf.id,
                    r,
                    &idx,
                    &mut edges,
                    DOCKER.compose_weight,
                    DOCKER.reverse_factor,
                );
            }

            for cap in COMPOSE_CONTEXT_RE.captures_iter(&cf.content) {
                if let Some(m) = cap.get(1) {
                    let context = m.as_str().trim();
                    if !context.is_empty() && !context.starts_with('$') {
                        let context_stripped = strip_dot_slash(context);
                        for f in fragments {
                            let fpath_lower = f.path().to_lowercase();
                            if fpath_lower.contains(context_stripped) && f.id != cf.id {
                                add_edge(
                                    &mut edges,
                                    &cf.id,
                                    &f.id,
                                    DOCKER.compose_weight * DOCKER.compose_context_modifier,
                                    DOCKER.reverse_factor,
                                );
                            }
                        }
                    }
                }
            }

            for cap in COMPOSE_VOLUME_RE.captures_iter(&cf.content) {
                if let Some(m) = cap.get(1) {
                    let vol = strip_dot_slash(m.as_str().trim());
                    if !vol.is_empty() {
                        link_by_name(
                            &cf.id,
                            vol,
                            &idx,
                            &mut edges,
                            DOCKER.compose_weight * DOCKER.compose_volume_modifier,
                            DOCKER.reverse_factor,
                        );
                    }
                }
            }
        }

        edges
    }

    fn discover_related_files(
        &self,
        changed: &[PathBuf],
        candidates: &[PathBuf],
        repo_root: Option<&Path>,
        file_cache: Option<&FxHashMap<PathBuf, String>>,
    ) -> Vec<PathBuf> {
        let docker_files: Vec<&PathBuf> = changed
            .iter()
            .filter(|p| is_dockerfile(p) || is_compose_file(p))
            .collect();
        if docker_files.is_empty() {
            return vec![];
        }

        let mut refs = FxHashSet::default();

        for df in &docker_files {
            let content = match base::read_file_cached(df, file_cache) {
                Some(c) => c,
                None => continue,
            };

            if is_dockerfile(df) {
                refs.extend(collect_dockerfile_refs(&content));
            }
            if is_compose_file(df) {
                refs.extend(collect_compose_refs(&content));
            }
        }

        base::discover_files_by_refs(&refs, changed, candidates, repo_root)
    }
}