1use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9use std::fmt;
10use std::path::Path;
11use std::str::FromStr;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17pub enum ToolTier {
18 T0,
19 T1,
20 T2,
21 T3,
22 T4,
23}
24
25impl fmt::Display for ToolTier {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 ToolTier::T0 => write!(f, "T0"),
29 ToolTier::T1 => write!(f, "T1"),
30 ToolTier::T2 => write!(f, "T2"),
31 ToolTier::T3 => write!(f, "T3"),
32 ToolTier::T4 => write!(f, "T4"),
33 }
34 }
35}
36
37impl FromStr for ToolTier {
38 type Err = anyhow::Error;
39 fn from_str(s: &str) -> Result<Self, Self::Err> {
40 match s.to_uppercase().as_str() {
41 "T0" => Ok(ToolTier::T0),
42 "T1" => Ok(ToolTier::T1),
43 "T2" => Ok(ToolTier::T2),
44 "T3" => Ok(ToolTier::T3),
45 "T4" => Ok(ToolTier::T4),
46 other => anyhow::bail!("invalid tool tier: '{other}' (expected T0..T4)"),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum ApprovalRequirement {
57 None,
59 Lazy,
61 Required,
63 Blocked,
65}
66
67impl fmt::Display for ApprovalRequirement {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 ApprovalRequirement::None => write!(f, "none"),
71 ApprovalRequirement::Lazy => write!(f, "lazy"),
72 ApprovalRequirement::Required => write!(f, "required"),
73 ApprovalRequirement::Blocked => write!(f, "blocked"),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct TierDef {
83 pub description: String,
84 pub approval: ApprovalRequirement,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ToolTierConfig {
92 pub version: u32,
93 pub default_tier: ToolTier,
94 pub tiers: BTreeMap<ToolTier, TierDef>,
95 #[serde(default)]
96 pub tools: BTreeMap<String, ToolTier>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ToolTierResult {
104 pub tool: String,
105 pub tier: ToolTier,
106 pub approval: ApprovalRequirement,
107 pub description: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ToolTierEntry {
115 pub tool: String,
116 pub tier: ToolTier,
117 pub approval: ApprovalRequirement,
118}
119
120pub fn default_tool_tier_config() -> ToolTierConfig {
124 let mut tiers = BTreeMap::new();
125 tiers.insert(
126 ToolTier::T0,
127 TierDef {
128 description: "Safe \u{2014} read-only, no side effects".to_string(),
129 approval: ApprovalRequirement::None,
130 },
131 );
132 tiers.insert(
133 ToolTier::T1,
134 TierDef {
135 description: "Standard \u{2014} normal operations".to_string(),
136 approval: ApprovalRequirement::None,
137 },
138 );
139 tiers.insert(
140 ToolTier::T2,
141 TierDef {
142 description: "Elevated \u{2014} significant changes".to_string(),
143 approval: ApprovalRequirement::Lazy,
144 },
145 );
146 tiers.insert(
147 ToolTier::T3,
148 TierDef {
149 description: "Dangerous \u{2014} destructive or irreversible".to_string(),
150 approval: ApprovalRequirement::Required,
151 },
152 );
153 tiers.insert(
154 ToolTier::T4,
155 TierDef {
156 description: "Forbidden \u{2014} never allowed".to_string(),
157 approval: ApprovalRequirement::Blocked,
158 },
159 );
160
161 ToolTierConfig {
162 version: 1,
163 default_tier: ToolTier::T1,
164 tiers,
165 tools: BTreeMap::new(),
166 }
167}
168
169pub fn resolve_tool_tier(config: &ToolTierConfig, tool_name: &str) -> ToolTierResult {
173 let tier = config
174 .tools
175 .get(tool_name)
176 .copied()
177 .unwrap_or(config.default_tier);
178
179 let tier_def = config.tiers.get(&tier);
180 let (approval, description) = match tier_def {
181 Some(def) => (def.approval, def.description.clone()),
182 None => (approval_for_tier(tier), format!("Tier {tier}")),
184 };
185
186 ToolTierResult {
187 tool: tool_name.to_string(),
188 tier,
189 approval,
190 description,
191 }
192}
193
194pub fn approval_for_tier(tier: ToolTier) -> ApprovalRequirement {
196 match tier {
197 ToolTier::T0 | ToolTier::T1 => ApprovalRequirement::None,
198 ToolTier::T2 => ApprovalRequirement::Lazy,
199 ToolTier::T3 => ApprovalRequirement::Required,
200 ToolTier::T4 => ApprovalRequirement::Blocked,
201 }
202}
203
204const TOOL_TIERS_FILE: &str = "tool_tiers.yaml";
207
208pub fn load_tool_tiers_from_dir(edda_dir: &Path) -> anyhow::Result<ToolTierConfig> {
211 let path = edda_dir.join(TOOL_TIERS_FILE);
212 if !path.exists() {
213 return Ok(default_tool_tier_config());
214 }
215 let content = std::fs::read(&path)?;
216 let config: ToolTierConfig = serde_yaml::from_slice(&content)?;
217 Ok(config)
218}
219
220pub fn save_tool_tiers_to_dir(edda_dir: &Path, config: &ToolTierConfig) -> anyhow::Result<()> {
222 let path = edda_dir.join(TOOL_TIERS_FILE);
223 let yaml = serde_yaml::to_string(config)?;
224 std::fs::write(&path, yaml.as_bytes())?;
225 Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn resolve_known_tool() {
234 let mut config = default_tool_tier_config();
235 config.tools.insert("bash".to_string(), ToolTier::T1);
236 config.tools.insert("rm".to_string(), ToolTier::T3);
237
238 let result = resolve_tool_tier(&config, "bash");
239 assert_eq!(result.tier, ToolTier::T1);
240 assert_eq!(result.approval, ApprovalRequirement::None);
241
242 let result = resolve_tool_tier(&config, "rm");
243 assert_eq!(result.tier, ToolTier::T3);
244 assert_eq!(result.approval, ApprovalRequirement::Required);
245 }
246
247 #[test]
248 fn resolve_unknown_tool_uses_default() {
249 let config = default_tool_tier_config();
250 let result = resolve_tool_tier(&config, "unknown_tool");
251 assert_eq!(result.tier, ToolTier::T1); assert_eq!(result.approval, ApprovalRequirement::None);
253 assert_eq!(result.tool, "unknown_tool");
254 }
255
256 #[test]
257 fn all_tiers_have_correct_approval() {
258 assert_eq!(approval_for_tier(ToolTier::T0), ApprovalRequirement::None);
259 assert_eq!(approval_for_tier(ToolTier::T1), ApprovalRequirement::None);
260 assert_eq!(approval_for_tier(ToolTier::T2), ApprovalRequirement::Lazy);
261 assert_eq!(
262 approval_for_tier(ToolTier::T3),
263 ApprovalRequirement::Required
264 );
265 assert_eq!(
266 approval_for_tier(ToolTier::T4),
267 ApprovalRequirement::Blocked
268 );
269 }
270
271 #[test]
272 fn serde_roundtrip() {
273 let mut config = default_tool_tier_config();
274 config.tools.insert("bash".to_string(), ToolTier::T1);
275 config.tools.insert("Write".to_string(), ToolTier::T2);
276
277 let yaml = serde_yaml::to_string(&config).unwrap();
278 let parsed: ToolTierConfig = serde_yaml::from_str(&yaml).unwrap();
279
280 assert_eq!(parsed.version, 1);
281 assert_eq!(parsed.default_tier, ToolTier::T1);
282 assert_eq!(parsed.tools.get("bash"), Some(&ToolTier::T1));
283 assert_eq!(parsed.tools.get("Write"), Some(&ToolTier::T2));
284 assert_eq!(parsed.tiers.len(), 5);
285 }
286
287 #[test]
288 fn load_missing_file_returns_default() {
289 let tmp = std::env::temp_dir().join(format!(
290 "edda_tool_tier_missing_test_{}",
291 std::process::id()
292 ));
293 let _ = std::fs::create_dir_all(&tmp);
294 let config = load_tool_tiers_from_dir(&tmp).unwrap();
295 assert_eq!(config.default_tier, ToolTier::T1);
296 assert!(config.tools.is_empty());
297 assert_eq!(config.tiers.len(), 5);
298 let _ = std::fs::remove_dir_all(&tmp);
299 }
300
301 #[test]
302 fn save_and_load_roundtrip() {
303 let tmp =
304 std::env::temp_dir().join(format!("edda_tool_tier_io_test_{}", std::process::id()));
305 let _ = std::fs::remove_dir_all(&tmp);
306 std::fs::create_dir_all(&tmp).unwrap();
307
308 let mut config = default_tool_tier_config();
309 config.tools.insert("curl".to_string(), ToolTier::T2);
310
311 save_tool_tiers_to_dir(&tmp, &config).unwrap();
312 let loaded = load_tool_tiers_from_dir(&tmp).unwrap();
313
314 assert_eq!(loaded.tools.get("curl"), Some(&ToolTier::T2));
315 assert_eq!(loaded.default_tier, ToolTier::T1);
316 assert_eq!(loaded.tiers.len(), 5);
317
318 let _ = std::fs::remove_dir_all(&tmp);
319 }
320
321 #[test]
322 fn tool_tier_display_and_parse() {
323 for tier in [
324 ToolTier::T0,
325 ToolTier::T1,
326 ToolTier::T2,
327 ToolTier::T3,
328 ToolTier::T4,
329 ] {
330 let s = tier.to_string();
331 let parsed: ToolTier = s.parse().unwrap();
332 assert_eq!(parsed, tier);
333 }
334 assert_eq!("t2".parse::<ToolTier>().unwrap(), ToolTier::T2);
336 assert!("T5".parse::<ToolTier>().is_err());
338 }
339
340 #[test]
341 fn resolve_with_t4_forbidden() {
342 let mut config = default_tool_tier_config();
343 config
344 .tools
345 .insert("git_push_force".to_string(), ToolTier::T4);
346
347 let result = resolve_tool_tier(&config, "git_push_force");
348 assert_eq!(result.tier, ToolTier::T4);
349 assert_eq!(result.approval, ApprovalRequirement::Blocked);
350 }
351}