Skip to main content

cargo_context_core/
config.rs

1//! Project-level `.cargo-context/config.yaml` support.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::budget::BudgetStrategy;
9use crate::error::{Error, Result};
10use crate::expand::ExpandMode;
11use crate::options::PackOptions;
12use crate::pack::{Format, Preset};
13use crate::tokenize::Tokenizer;
14
15pub const DEFAULT_CONFIG_PATH: &str = ".cargo-context/config.yaml";
16
17#[derive(Debug, Default, Clone, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ProjectConfig {
20    #[serde(default)]
21    pub default_profile: Option<String>,
22    #[serde(default)]
23    pub profiles: BTreeMap<String, PackProfile>,
24}
25
26impl ProjectConfig {
27    pub fn load(path: &Path) -> Result<Self> {
28        let raw = std::fs::read_to_string(path)?;
29        serde_yaml::from_str(&raw).map_err(Error::from)
30    }
31
32    pub fn load_from_workspace(root: &Path) -> Result<Option<Self>> {
33        let path = root.join(DEFAULT_CONFIG_PATH);
34        if path.exists() {
35            Self::load(&path).map(Some)
36        } else {
37            Ok(None)
38        }
39    }
40
41    pub fn resolve_pack_options(&self, requested_profile: Option<&str>) -> Result<PackOptions> {
42        let mut options = PackOptions::default();
43        let Some(profile_name) = requested_profile.or(self.default_profile.as_deref()) else {
44            return Ok(options);
45        };
46        let profile = self.profiles.get(profile_name).ok_or_else(|| {
47            Error::Config(format!(
48                "profile `{profile_name}` not found in {DEFAULT_CONFIG_PATH}"
49            ))
50        })?;
51        profile.apply_to(&mut options)?;
52        Ok(options)
53    }
54}
55
56#[derive(Debug, Default, Clone, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct PackProfile {
59    #[serde(default)]
60    pub preset: Option<Preset>,
61    #[serde(default)]
62    pub max_tokens: Option<usize>,
63    #[serde(default)]
64    pub reserve_tokens: Option<usize>,
65    #[serde(default)]
66    pub budget_strategy: Option<BudgetStrategy>,
67    #[serde(default)]
68    pub tokenizer: Option<String>,
69    #[serde(default)]
70    pub hf_llama3_vocab: Option<PathBuf>,
71    #[serde(default)]
72    pub format: Option<Format>,
73    #[serde(default)]
74    pub expand_macros: Option<ExpandMode>,
75    #[serde(default)]
76    pub diff: Option<String>,
77    #[serde(default, alias = "include_path")]
78    pub include_paths: Vec<String>,
79    #[serde(default, alias = "exclude_path")]
80    pub exclude_paths: Vec<String>,
81}
82
83impl PackProfile {
84    pub fn apply_to(&self, options: &mut PackOptions) -> Result<()> {
85        if let Some(preset) = self.preset {
86            options.preset = preset;
87        }
88        if let Some(max_tokens) = self.max_tokens {
89            options.budget.max_tokens = max_tokens;
90        }
91        if let Some(reserve_tokens) = self.reserve_tokens {
92            options.budget.reserve_tokens = reserve_tokens;
93        }
94        if let Some(strategy) = self.budget_strategy {
95            options.budget.strategy = strategy;
96        }
97        if let Some(tokenizer) = &self.tokenizer {
98            options.tokenizer = parse_tokenizer(tokenizer, self.hf_llama3_vocab.clone())?;
99        } else if let Some(vocab_path) = &self.hf_llama3_vocab {
100            options.tokenizer = Tokenizer::HfLlama3 {
101                vocab_path: vocab_path.clone(),
102            };
103        }
104        if let Some(format) = self.format {
105            options.format = format;
106        }
107        if let Some(expand_macros) = self.expand_macros {
108            options.expand_mode = expand_macros;
109        }
110        if let Some(diff) = &self.diff {
111            options.diff_range = Some(diff.clone());
112        }
113        options.include_paths.extend(self.include_paths.clone());
114        options.exclude_paths.extend(self.exclude_paths.clone());
115        Ok(())
116    }
117}
118
119pub fn parse_preset(value: &str) -> Result<Preset> {
120    match normalize(value).as_str() {
121        "fix" => Ok(Preset::Fix),
122        "feature" => Ok(Preset::Feature),
123        "custom" => Ok(Preset::Custom),
124        _ => Err(Error::Config(format!("unknown preset `{value}`"))),
125    }
126}
127
128pub fn parse_format(value: &str) -> Result<Format> {
129    match normalize(value).as_str() {
130        "markdown" | "md" => Ok(Format::Markdown),
131        "xml" => Ok(Format::Xml),
132        "json" => Ok(Format::Json),
133        "plain" | "text" => Ok(Format::Plain),
134        _ => Err(Error::Config(format!("unknown format `{value}`"))),
135    }
136}
137
138pub fn parse_budget_strategy(value: &str) -> Result<BudgetStrategy> {
139    match normalize(value).as_str() {
140        "priority" => Ok(BudgetStrategy::Priority),
141        "proportional" => Ok(BudgetStrategy::Proportional),
142        "truncate" => Ok(BudgetStrategy::Truncate),
143        _ => Err(Error::Config(format!("unknown budget strategy `{value}`"))),
144    }
145}
146
147pub fn parse_expand_mode(value: &str) -> Result<ExpandMode> {
148    match normalize(value).as_str() {
149        "off" => Ok(ExpandMode::Off),
150        "auto" => Ok(ExpandMode::Auto),
151        "on" => Ok(ExpandMode::On),
152        _ => Err(Error::Config(format!(
153            "unknown macro expansion mode `{value}`"
154        ))),
155    }
156}
157
158pub fn parse_tokenizer(value: &str, hf_vocab_path: Option<PathBuf>) -> Result<Tokenizer> {
159    match normalize(value).as_str() {
160        "llama3" => Ok(Tokenizer::Llama3),
161        "llama2" => Ok(Tokenizer::Llama2),
162        "tiktoken-cl100k" => Ok(Tokenizer::TiktokenCl100k),
163        "tiktoken-o200k" => Ok(Tokenizer::TiktokenO200k),
164        "claude" => Ok(Tokenizer::Claude),
165        "chars-div4" | "chars-div-4" => Ok(Tokenizer::CharsDiv4),
166        "hf-llama3" => hf_vocab_path
167            .map(|vocab_path| Tokenizer::HfLlama3 { vocab_path })
168            .ok_or_else(|| Error::Config("tokenizer `hf-llama3` requires hf_llama3_vocab".into())),
169        _ => Err(Error::Config(format!("unknown tokenizer `{value}`"))),
170    }
171}
172
173fn normalize(value: &str) -> String {
174    value.trim().to_ascii_lowercase().replace('_', "-")
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn resolves_default_profile() {
183        let config: ProjectConfig = serde_yaml::from_str(
184            r#"
185default_profile: review
186profiles:
187  review:
188    preset: feature
189    max_tokens: 12000
190    reserve_tokens: 1000
191    budget_strategy: proportional
192    tokenizer: chars-div-4
193    format: json
194    expand_macros: off
195    diff: HEAD~3..HEAD
196    include_path:
197      - crates/**/src/lib.rs
198    exclude_path:
199      - target/**
200"#,
201        )
202        .unwrap();
203
204        let options = config.resolve_pack_options(None).unwrap();
205
206        assert_eq!(options.preset, Preset::Feature);
207        assert_eq!(options.budget.max_tokens, 12000);
208        assert_eq!(options.budget.reserve_tokens, 1000);
209        assert_eq!(options.budget.strategy, BudgetStrategy::Proportional);
210        assert_eq!(options.tokenizer, Tokenizer::CharsDiv4);
211        assert_eq!(options.format, Format::Json);
212        assert_eq!(options.expand_mode, ExpandMode::Off);
213        assert_eq!(options.diff_range.as_deref(), Some("HEAD~3..HEAD"));
214        assert_eq!(options.include_paths, vec!["crates/**/src/lib.rs"]);
215        assert_eq!(options.exclude_paths, vec!["target/**"]);
216    }
217
218    #[test]
219    fn requested_profile_overrides_default_profile() {
220        let config: ProjectConfig = serde_yaml::from_str(
221            r#"
222default_profile: fix
223profiles:
224  fix:
225    preset: fix
226  audit:
227    preset: custom
228    tokenizer: tiktoken-o200k
229"#,
230        )
231        .unwrap();
232
233        let options = config.resolve_pack_options(Some("audit")).unwrap();
234
235        assert_eq!(options.preset, Preset::Custom);
236        assert_eq!(options.tokenizer, Tokenizer::TiktokenO200k);
237    }
238
239    #[test]
240    fn missing_profile_is_config_error() {
241        let config = ProjectConfig::default();
242        let err = config.resolve_pack_options(Some("nope")).unwrap_err();
243        assert!(err.to_string().contains("profile `nope` not found"));
244    }
245}