ctxpack 0.1.0

Configuration-driven source packing for LLM context, with a versioning protocol for incremental patch/replace
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;

fn deserialize_size_string<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
    D: Deserializer<'de>,
{
    use serde::de::Error;
    let s = serde_yaml::Value::deserialize(deserializer)?;
    match s {
        serde_yaml::Value::Number(n) => n.as_u64().ok_or_else(|| Error::custom("invalid number")),
        serde_yaml::Value::String(s) => parse_size_string(&s).map_err(Error::custom),
        _ => Err(Error::custom("expected string or number for size")),
    }
}

pub fn parse_size_string(s: &str) -> Result<u64, String> {
    let s = s.trim();
    let lower = s.to_ascii_lowercase();
    if let Some(rest) = lower.strip_suffix("kb") {
        let n: u64 = rest.trim().parse().map_err(|e| format!("parse error: {e}"))?;
        Ok(n * 1024)
    } else if let Some(rest) = lower.strip_suffix("mb") {
        let n: u64 = rest.trim().parse().map_err(|e| format!("parse error: {e}"))?;
        Ok(n * 1024 * 1024)
    } else if let Some(rest) = lower.strip_suffix("gb") {
        let n: u64 = rest.trim().parse().map_err(|e| format!("parse error: {e}"))?;
        Ok(n * 1024 * 1024 * 1024)
    } else {
        s.parse::<u64>().map_err(|e| format!("parse error: {e}"))
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum BinaryPolicy {
    #[default]
    Skip,
    Warn,
    Abort,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SizePolicy {
    #[default]
    Warn,
    Abort,
    Ignore,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ExtractionMode {
    #[default]
    Full,
    Lines,
    Regex,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalConfig {
    #[serde(default = "default_prefix")]
    pub prefix: String,
    #[serde(default = "default_anchor_interval")]
    pub anchor_interval: u32,
    #[serde(default = "default_indent_encoding")]
    pub indent_encoding: bool,
    #[serde(default = "default_tab_width")]
    pub tab_width: u32,
    #[serde(default)]
    pub binary_policy: BinaryPolicy,
    #[serde(default = "default_encoding_detection")]
    pub encoding_detection: bool,
    #[serde(default = "default_max_content_size", deserialize_with = "deserialize_size_string")]
    pub max_content_size: u64,
    #[serde(default = "default_max_file_size", deserialize_with = "deserialize_size_string")]
    pub max_file_size: u64,
    #[serde(default)]
    pub size_policy: SizePolicy,
    #[serde(default = "default_index_file")]
    pub index_file: String,
    #[serde(default = "default_cache_dir")]
    pub cache_dir: String,
    #[serde(default = "default_cache_retention")]
    pub cache_retention: u32,
    #[serde(default = "default_manifest")]
    pub manifest: bool,
    #[serde(default = "default_prompt_generation")]
    pub prompt_generation: bool,
}

fn default_prefix() -> String {
    "ctx".to_string()
}
fn default_anchor_interval() -> u32 {
    10
}
fn default_indent_encoding() -> bool {
    true
}
fn default_tab_width() -> u32 {
    4
}
fn default_encoding_detection() -> bool {
    true
}
fn default_max_content_size() -> u64 {
    500 * 1024
}
fn default_max_file_size() -> u64 {
    100 * 1024
}
fn default_index_file() -> String {
    ".ctx-index.yaml".to_string()
}
fn default_cache_dir() -> String {
    ".ctx-cache".to_string()
}
fn default_cache_retention() -> u32 {
    5
}
fn default_manifest() -> bool {
    true
}
fn default_prompt_generation() -> bool {
    true
}

impl Default for GlobalConfig {
    fn default() -> Self {
        GlobalConfig {
            prefix: default_prefix(),
            anchor_interval: default_anchor_interval(),
            indent_encoding: default_indent_encoding(),
            tab_width: default_tab_width(),
            binary_policy: BinaryPolicy::default(),
            encoding_detection: default_encoding_detection(),
            max_content_size: default_max_content_size(),
            max_file_size: default_max_file_size(),
            size_policy: SizePolicy::default(),
            index_file: default_index_file(),
            cache_dir: default_cache_dir(),
            cache_retention: default_cache_retention(),
            manifest: default_manifest(),
            prompt_generation: default_prompt_generation(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RootEntry {
    pub path: String,
    #[serde(default = "default_label")]
    pub label: String,
}

fn default_label() -> String {
    "project".to_string()
}

impl Default for RootEntry {
    fn default() -> Self {
        RootEntry { path: ".".to_string(), label: default_label() }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DiscoveryConfig {
    #[serde(default = "default_use_gitignore")]
    pub use_gitignore: bool,
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default)]
    pub exclude: Vec<String>,
    #[serde(default)]
    pub stdin_merge: bool,
}

fn default_use_gitignore() -> bool {
    true
}

impl DiscoveryConfig {
    pub fn with_defaults() -> Self {
        DiscoveryConfig {
            use_gitignore: true,
            include: vec![],
            exclude: vec![],
            stdin_merge: false,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractionRule {
    #[serde(rename = "match")]
    pub match_glob: String,
    pub mode: ExtractionMode,
    pub ranges: Option<String>,
    pub pattern: Option<String>,
    #[serde(default)]
    pub context_lines: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExtractionConfig {
    #[serde(default)]
    pub default_mode: ExtractionMode,
    #[serde(default)]
    pub rules: Vec<ExtractionRule>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersioningConfig {
    #[serde(default = "default_auto_diff")]
    pub auto_diff: bool,
    #[serde(default = "default_replace_threshold")]
    pub replace_threshold: f64,
    #[serde(default = "default_max_patches_before_replace")]
    pub max_patches_before_replace: u32,
}

fn default_auto_diff() -> bool {
    true
}
fn default_replace_threshold() -> f64 {
    0.5
}
fn default_max_patches_before_replace() -> u32 {
    5
}

impl Default for VersioningConfig {
    fn default() -> Self {
        VersioningConfig {
            auto_diff: default_auto_diff(),
            replace_threshold: default_replace_threshold(),
            max_patches_before_replace: default_max_patches_before_replace(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
    #[serde(default = "default_output_file")]
    pub file: String,
    #[serde(default = "default_manifest_file")]
    pub manifest: String,
}

fn default_output_file() -> String {
    "context.ctx".to_string()
}
fn default_manifest_file() -> String {
    "context.ctx.manifest".to_string()
}

impl Default for OutputConfig {
    fn default() -> Self {
        OutputConfig { file: default_output_file(), manifest: default_manifest_file() }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
    #[serde(default)]
    pub roots: Vec<RootEntry>,
    #[serde(default)]
    pub discovery: DiscoveryConfig,
    #[serde(default)]
    pub extraction: ExtractionConfig,
    #[serde(default)]
    pub versioning: VersioningConfig,
    #[serde(default)]
    pub output: OutputConfig,
}

impl Default for Profile {
    fn default() -> Self {
        Profile {
            roots: vec![RootEntry::default()],
            discovery: DiscoveryConfig::with_defaults(),
            extraction: ExtractionConfig::default(),
            versioning: VersioningConfig::default(),
            output: OutputConfig::default(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub global: GlobalConfig,
    #[serde(default = "default_profiles")]
    pub profiles: HashMap<String, Profile>,
}

fn default_profiles() -> HashMap<String, Profile> {
    let mut profiles = HashMap::new();
    profiles.insert("default".to_string(), Profile::default());
    profiles
}

impl Default for Config {
    fn default() -> Self {
        Config { global: GlobalConfig::default(), profiles: default_profiles() }
    }
}

impl Config {
    pub fn get_profile(&self, name: &str) -> anyhow::Result<&Profile> {
        self.profiles.get(name).ok_or_else(|| {
            let mut available: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
            available.sort_unstable();
            let available =
                if available.is_empty() { "<none>".to_string() } else { available.join(", ") };
            anyhow::anyhow!("profile '{}' not found (available: {})", name, available)
        })
    }
}

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

    #[test]
    fn test_default_round_trip() {
        let config = Config::default();
        let yaml = serde_yaml::to_string(&config).unwrap();
        let _: Config = serde_yaml::from_str(&yaml).unwrap();
    }

    #[test]
    fn test_size_string_parse() {
        assert_eq!(parse_size_string("500KB").unwrap(), 512000);
        assert_eq!(parse_size_string("10MB").unwrap(), 10485760);
        assert_eq!(parse_size_string("1234").unwrap(), 1234);
        assert_eq!(parse_size_string("1GB").unwrap(), 1073741824);
        assert_eq!(parse_size_string("500kb").unwrap(), 512000);
    }

    #[test]
    fn test_size_string_deserialize() {
        let yaml = r#"
global:
  max_content_size: "500KB"
  max_file_size: "10MB"
"#;
        let config: Config = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(config.global.max_content_size, 512000);
        assert_eq!(config.global.max_file_size, 10485760);
    }

    #[test]
    fn test_size_string_numeric() {
        let yaml = r#"
global:
  max_content_size: 1234
"#;
        let config: Config = serde_yaml::from_str(yaml).unwrap();
        assert_eq!(config.global.max_content_size, 1234);
    }

    #[test]
    fn test_missing_profiles_uses_default_profile() {
        let yaml = r#"
global:
  prefix: "ctx"
"#;
        let config: Config = serde_yaml::from_str(yaml).unwrap();
        assert!(config.get_profile("default").is_ok());
    }

    #[test]
    fn test_missing_profile_reports_available_profiles() {
        let config = Config::default();
        let err = config.get_profile("missing").unwrap_err();
        assert!(err.to_string().contains("profile 'missing' not found"));
        assert!(err.to_string().contains("default"));
    }
}