ctxpack 0.1.0

Configuration-driven source packing for LLM context, with a versioning protocol for incremental patch/replace
use anyhow::Result;
use globset::{Glob, GlobSetBuilder};
use std::path::PathBuf;

use crate::config::Profile;

pub fn discover_builtin(profile: &Profile) -> Result<Vec<(PathBuf, String)>> {
    let mut include_builder = GlobSetBuilder::new();
    let mut exclude_builder = GlobSetBuilder::new();

    let has_includes = !profile.discovery.include.is_empty();
    for pattern in &profile.discovery.include {
        include_builder.add(Glob::new(pattern)?);
    }
    for pattern in &profile.discovery.exclude {
        exclude_builder.add(Glob::new(pattern)?);
    }

    let include_set = include_builder.build()?;
    let exclude_set = exclude_builder.build()?;

    let mut results: Vec<(PathBuf, String)> = Vec::new();

    for root in &profile.roots {
        let root_path = PathBuf::from(&root.path);
        let walker = ignore::WalkBuilder::new(&root_path)
            .git_ignore(profile.discovery.use_gitignore)
            .git_global(profile.discovery.use_gitignore)
            .git_exclude(profile.discovery.use_gitignore)
            .build();

        for entry in walker {
            let entry = entry?;
            if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
                let abs_path = entry.path().to_path_buf();
                let rel = match abs_path.strip_prefix(&root_path) {
                    Ok(r) => r.to_string_lossy().replace('\\', "/"),
                    Err(_) => abs_path.to_string_lossy().replace('\\', "/"),
                };

                if has_includes && !include_set.is_match(&rel) {
                    continue;
                }
                if exclude_set.is_match(&rel) {
                    continue;
                }

                let display_path = if root.label.is_empty() {
                    rel.clone()
                } else {
                    format!("{}/{}", root.label, rel)
                };

                results.push((abs_path, display_path));
            }
        }
    }

    Ok(results)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{
        DiscoveryConfig, ExtractionConfig, OutputConfig, Profile, RootEntry, VersioningConfig,
    };
    use std::fs;
    use tempfile::TempDir;

    fn make_profile(root: &str, include: Vec<String>, exclude: Vec<String>) -> Profile {
        Profile {
            roots: vec![RootEntry { path: root.to_string(), label: "test".to_string() }],
            discovery: DiscoveryConfig {
                use_gitignore: false,
                include,
                exclude,
                stdin_merge: false,
            },
            extraction: ExtractionConfig::default(),
            versioning: VersioningConfig::default(),
            output: OutputConfig::default(),
        }
    }

    #[test]
    fn test_discover_basic() {
        let dir = TempDir::new().unwrap();
        fs::write(dir.path().join("a.rs"), "fn a() {}").unwrap();
        fs::write(dir.path().join("b.txt"), "text").unwrap();

        let profile = make_profile(dir.path().to_str().unwrap(), vec![], vec![]);
        let files = discover_builtin(&profile).unwrap();
        assert_eq!(files.len(), 2);
    }

    #[test]
    fn test_discover_include_filter() {
        let dir = TempDir::new().unwrap();
        fs::write(dir.path().join("a.rs"), "fn a() {}").unwrap();
        fs::write(dir.path().join("b.txt"), "text").unwrap();

        let profile = make_profile(dir.path().to_str().unwrap(), vec!["*.rs".to_string()], vec![]);
        let files = discover_builtin(&profile).unwrap();
        assert_eq!(files.len(), 1);
        assert!(files[0].1.ends_with("a.rs"));
    }

    #[test]
    fn test_discover_exclude_filter() {
        let dir = TempDir::new().unwrap();
        fs::write(dir.path().join("a.rs"), "fn a() {}").unwrap();
        fs::write(dir.path().join("b.txt"), "text").unwrap();

        let profile = make_profile(dir.path().to_str().unwrap(), vec![], vec!["*.txt".to_string()]);
        let files = discover_builtin(&profile).unwrap();
        assert_eq!(files.len(), 1);
        assert!(files[0].1.ends_with("a.rs"));
    }
}