cargo_context_core/
config.rs1use 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}