1use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, BTreeSet};
15use std::hash::{Hash, Hasher};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct AgentConfig {
21 pub system_prompt_prefix: String,
23 pub model_pref: ModelPref,
25 pub behavioral_rules: BTreeSet<String>,
27 pub tool_permissions: BTreeSet<String>,
29 pub response_style: ResponseStyle,
31 #[serde(default)]
33 pub extensions: BTreeMap<String, serde_json::Value>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
38#[serde(rename_all = "snake_case")]
39pub enum ModelPref {
40 ClaudeOpus,
42 ClaudeSonnet,
44 ClaudeHaiku,
46 Gpt4o,
48 Gpt4oMini,
50 Ollama(String),
52 AnyCheap,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
58#[serde(rename_all = "snake_case")]
59pub enum ResponseStyle {
60 Terse,
62 Normal,
64 Verbose,
66}
67
68impl AgentConfig {
69 pub fn default_for(adapter_id: &str) -> Self {
74 let (prefix, rules, perms, model) = match adapter_id {
75 "claude-code" => (
76 "You are working alongside the user in their codebase. Prefer small, \
77 verifiable edits over speculative refactors. Run tests after changes \
78 when feasible.",
79 [
80 "always run tests after structural edits",
81 "ask before deleting files",
82 ]
83 .iter()
84 .map(|s| s.to_string())
85 .collect::<BTreeSet<_>>(),
86 ["bash", "edit", "read", "grep", "glob"]
87 .iter()
88 .map(|s| s.to_string())
89 .collect::<BTreeSet<_>>(),
90 ModelPref::ClaudeSonnet,
91 ),
92 "cursor" => (
93 "Generate suggestions that fit the surrounding code style. Prefer \
94 minimal diffs that keep the user in flow.",
95 [
96 "match existing code style",
97 "do not invent new APIs without justification",
98 ]
99 .iter()
100 .map(|s| s.to_string())
101 .collect::<BTreeSet<_>>(),
102 ["edit", "read"]
103 .iter()
104 .map(|s| s.to_string())
105 .collect::<BTreeSet<_>>(),
106 ModelPref::AnyCheap,
107 ),
108 "aider" => (
109 "Apply edits as small, atomic git commits with conventional commit \
110 messages. Run lint and tests before considering a change complete.",
111 [
112 "one logical change per commit",
113 "use conventional commit messages",
114 ]
115 .iter()
116 .map(|s| s.to_string())
117 .collect::<BTreeSet<_>>(),
118 ["edit", "read", "shell"]
119 .iter()
120 .map(|s| s.to_string())
121 .collect::<BTreeSet<_>>(),
122 ModelPref::ClaudeSonnet,
123 ),
124 _ => (
125 "You are a careful, helpful coding assistant.",
126 BTreeSet::new(),
127 BTreeSet::new(),
128 ModelPref::AnyCheap,
129 ),
130 };
131
132 AgentConfig {
133 system_prompt_prefix: prefix.to_string(),
134 model_pref: model,
135 behavioral_rules: rules,
136 tool_permissions: perms,
137 response_style: ResponseStyle::Normal,
138 extensions: BTreeMap::new(),
139 }
140 }
141
142 pub fn fingerprint(&self) -> u64 {
148 let json = serde_json::to_string(self).expect("AgentConfig serializes");
149 let mut hasher = std::collections::hash_map::DefaultHasher::new();
150 json.hash(&mut hasher);
151 hasher.finish()
152 }
153
154 pub fn extension<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
156 self.extensions
157 .get(key)
158 .and_then(|v| serde_json::from_value(v.clone()).ok())
159 }
160
161 pub fn set_extension<T: Serialize>(&mut self, key: &str, value: &T) -> serde_json::Result<()> {
163 let v = serde_json::to_value(value)?;
164 self.extensions.insert(key.to_string(), v);
165 Ok(())
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn roundtrips_through_serde_json() {
175 let cfg = AgentConfig::default_for("claude-code");
176 let json = serde_json::to_string(&cfg).unwrap();
177 let back: AgentConfig = serde_json::from_str(&json).unwrap();
178 assert_eq!(cfg, back);
179 }
180
181 #[test]
182 fn fingerprint_stable_across_clones() {
183 let cfg = AgentConfig::default_for("claude-code");
184 let h1 = cfg.fingerprint();
185 let h2 = cfg.clone().fingerprint();
186 assert_eq!(h1, h2);
187 }
188
189 #[test]
190 fn fingerprint_changes_when_prefix_changes() {
191 let mut cfg = AgentConfig::default_for("claude-code");
192 let h_before = cfg.fingerprint();
193 cfg.system_prompt_prefix.push_str(" Extra clause.");
194 let h_after = cfg.fingerprint();
195 assert_ne!(h_before, h_after);
196 }
197
198 #[test]
199 fn fingerprint_changes_when_model_changes() {
200 let mut cfg = AgentConfig::default_for("claude-code");
201 let h_before = cfg.fingerprint();
202 cfg.model_pref = ModelPref::ClaudeOpus;
203 let h_after = cfg.fingerprint();
204 assert_ne!(h_before, h_after);
205 }
206
207 #[test]
208 fn fingerprint_changes_when_a_rule_is_added() {
209 let mut cfg = AgentConfig::default_for("claude-code");
210 let h_before = cfg.fingerprint();
211 cfg.behavioral_rules
212 .insert("never edit .env files".to_string());
213 let h_after = cfg.fingerprint();
214 assert_ne!(h_before, h_after);
215 }
216
217 #[test]
218 fn default_for_known_adapters_is_non_empty() {
219 for adapter in ["claude-code", "cursor", "aider"] {
220 let cfg = AgentConfig::default_for(adapter);
221 assert!(
222 !cfg.system_prompt_prefix.is_empty(),
223 "{adapter} default has empty system_prompt_prefix",
224 );
225 }
226 }
227
228 #[test]
229 fn default_for_unknown_adapter_returns_safe_fallback() {
230 let cfg = AgentConfig::default_for("never-heard-of-it");
231 assert!(!cfg.system_prompt_prefix.is_empty());
232 assert!(cfg.behavioral_rules.is_empty());
233 assert!(cfg.tool_permissions.is_empty());
234 }
235
236 #[test]
237 fn extension_roundtrip_with_typed_payload() {
238 #[derive(Serialize, Deserialize, PartialEq, Debug)]
239 struct CursorExt {
240 cursorrules_extra: String,
241 file_glob_overrides: Vec<String>,
242 }
243
244 let mut cfg = AgentConfig::default_for("cursor");
245 let ext = CursorExt {
246 cursorrules_extra: "no inline scripts".to_string(),
247 file_glob_overrides: vec!["**/*.tsx".to_string()],
248 };
249 cfg.set_extension("cursor", &ext).unwrap();
250
251 let back: CursorExt = cfg.extension("cursor").unwrap();
252 assert_eq!(ext, back);
253 }
254
255 #[test]
256 fn extension_returns_none_when_key_absent() {
257 let cfg = AgentConfig::default_for("claude-code");
258 let value: Option<String> = cfg.extension("does-not-exist");
259 assert!(value.is_none());
260 }
261}
262
263#[cfg(test)]
264mod proptests {
265 use super::*;
266 use proptest::collection::{btree_map, btree_set};
267 use proptest::prelude::*;
268
269 fn arb_model_pref() -> impl Strategy<Value = ModelPref> {
270 prop_oneof![
271 Just(ModelPref::ClaudeOpus),
272 Just(ModelPref::ClaudeSonnet),
273 Just(ModelPref::ClaudeHaiku),
274 Just(ModelPref::Gpt4o),
275 Just(ModelPref::Gpt4oMini),
276 Just(ModelPref::AnyCheap),
277 "[a-z][a-z0-9.:-]{2,15}".prop_map(ModelPref::Ollama),
278 ]
279 }
280
281 fn arb_response_style() -> impl Strategy<Value = ResponseStyle> {
282 prop_oneof![
283 Just(ResponseStyle::Terse),
284 Just(ResponseStyle::Normal),
285 Just(ResponseStyle::Verbose),
286 ]
287 }
288
289 fn arb_agent_config() -> impl Strategy<Value = AgentConfig> {
290 (
291 "[ -~]{0,200}", arb_model_pref(), btree_set("[a-z ]{1,30}", 0..6), btree_set("[a-z_]{1,15}", 0..6), arb_response_style(), btree_map("[a-z]{1,10}", any::<i32>(), 0..3), )
298 .prop_map(|(prefix, model, rules, perms, style, ext_raw)| {
299 let extensions = ext_raw
300 .into_iter()
301 .map(|(k, v)| (k, serde_json::Value::from(v)))
302 .collect();
303 AgentConfig {
304 system_prompt_prefix: prefix,
305 model_pref: model,
306 behavioral_rules: rules,
307 tool_permissions: perms,
308 response_style: style,
309 extensions,
310 }
311 })
312 }
313
314 proptest! {
315 #[test]
316 fn json_roundtrip_is_identity(cfg in arb_agent_config()) {
317 let json = serde_json::to_string(&cfg).unwrap();
318 let back: AgentConfig = serde_json::from_str(&json).unwrap();
319 prop_assert_eq!(cfg, back);
320 }
321
322 #[test]
323 fn fingerprint_invariant_under_json_roundtrip(cfg in arb_agent_config()) {
324 let json = serde_json::to_string(&cfg).unwrap();
325 let back: AgentConfig = serde_json::from_str(&json).unwrap();
326 prop_assert_eq!(cfg.fingerprint(), back.fingerprint());
327 }
328
329 #[test]
330 fn equal_configs_have_equal_fingerprints(cfg in arb_agent_config()) {
331 let twin = cfg.clone();
332 prop_assert_eq!(cfg.fingerprint(), twin.fingerprint());
333 }
334 }
335}