1use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct EffectReport {
16 pub files_created: Vec<PathBuf>,
18 pub files_modified: Vec<PathBuf>,
20 pub files_deleted: Vec<PathBuf>,
22 pub key_files_affected: Vec<String>,
25 pub blast_radius: BlastRadius,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum BlastRadius {
35 SingleFile,
37 MultipleFiles,
39 DependencyChange,
41 CiConfig,
43 SecuritySensitive,
45}
46
47impl std::fmt::Display for BlastRadius {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 Self::SingleFile => write!(f, "single_file"),
51 Self::MultipleFiles => write!(f, "multiple_files"),
52 Self::DependencyChange => write!(f, "dependency_change"),
53 Self::CiConfig => write!(f, "ci_config"),
54 Self::SecuritySensitive => write!(f, "security_sensitive"),
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum PostDecision {
65 Allow,
67 Ask(String),
69 Deny(String),
71 Rollback(String),
73}
74
75#[derive(Debug, Clone)]
77pub struct EffectAnalyzer;
78
79impl EffectAnalyzer {
80 pub fn analyze(created: &[PathBuf], modified: &[PathBuf], deleted: &[PathBuf]) -> EffectReport {
85 let all_paths: Vec<&PathBuf> = created
86 .iter()
87 .chain(modified.iter())
88 .chain(deleted.iter())
89 .collect();
90
91 let key_files_affected = Self::find_sensitive_files(&all_paths);
92 let blast_radius = Self::classify_blast_radius(&all_paths, &key_files_affected);
93
94 EffectReport {
95 files_created: created.to_vec(),
96 files_modified: modified.to_vec(),
97 files_deleted: deleted.to_vec(),
98 key_files_affected,
99 blast_radius,
100 }
101 }
102
103 pub fn check_sensitivity(path: &Path) -> Option<String> {
108 let file_name = path.file_name()?.to_str()?;
109 let path_str = path.to_string_lossy();
110
111 if file_name == ".env" {
113 return Some(".env (secret)".to_string());
114 }
115 if file_name == ".env.example" {
116 return Some(".env.example (secret template)".to_string());
117 }
118 if file_name == ".gitignore" {
119 return Some(".gitignore (ignore rules)".to_string());
120 }
121
122 if file_name == "Cargo.toml" {
124 return Some("Cargo.toml (dependency)".to_string());
125 }
126 if file_name == "Cargo.lock" {
127 return Some("Cargo.lock (lockfile)".to_string());
128 }
129 if file_name == "package.json" {
130 return Some("package.json (dependency)".to_string());
131 }
132 if file_name == "package-lock.json" {
133 return Some("package-lock.json (lockfile)".to_string());
134 }
135 if file_name == "yarn.lock" {
136 return Some("yarn.lock (lockfile)".to_string());
137 }
138 if file_name == "pnpm-lock.yaml" {
139 return Some("pnpm-lock.yaml (lockfile)".to_string());
140 }
141
142 if file_name == "Dockerfile" {
144 return Some("Dockerfile (container)".to_string());
145 }
146 if file_name == "docker-compose.yml" || file_name == "docker-compose.yaml" {
147 return Some(format!("{file_name} (container)"));
148 }
149
150 if path_str.contains("/.github/") || path_str.starts_with(".github/") {
152 return Some(".github/ (CI config)".to_string());
153 }
154 if path_str.contains("/ci/") || path_str.starts_with("ci/") {
155 return Some("ci/ (CI config)".to_string());
156 }
157
158 if matches!(
160 file_name.as_ref(),
161 ".npmrc" | ".yarnrc" | ".yarnrc.yml" | ".browserslistrc"
162 ) {
163 return Some(format!("{file_name} (config)"));
164 }
165
166 None
167 }
168
169 fn find_sensitive_files(paths: &[&PathBuf]) -> Vec<String> {
172 let mut sensitive = Vec::new();
173 for path in paths {
174 if let Some(label) = Self::check_sensitivity(path) {
175 if !sensitive.contains(&label) {
176 sensitive.push(label);
177 }
178 }
179 }
180 sensitive
181 }
182
183 fn classify_blast_radius(all_paths: &[&PathBuf], key_files: &[String]) -> BlastRadius {
184 if key_files.iter().any(|l| l.contains("secret")) {
186 return BlastRadius::SecuritySensitive;
187 }
188
189 if key_files.iter().any(|l| l.contains("CI config")) {
191 return BlastRadius::CiConfig;
192 }
193
194 if key_files
196 .iter()
197 .any(|l| l.contains("dependency") || l.contains("lockfile"))
198 {
199 return BlastRadius::DependencyChange;
200 }
201
202 if key_files.iter().any(|l| l.contains("container")) {
204 return BlastRadius::DependencyChange;
205 }
206
207 match all_paths.len() {
208 0 | 1 => BlastRadius::SingleFile,
209 _ => BlastRadius::MultipleFiles,
210 }
211 }
212}
213
214pub fn extract_paths(
220 result: &crate::tool::ToolResult,
221 invocation: &crate::tool::ToolInvocation,
222) -> Vec<PathBuf> {
223 let mut paths: Vec<PathBuf> = Vec::new();
224
225 if let Some(p) = result
227 .output
228 .get("path")
229 .and_then(serde_json::Value::as_str)
230 {
231 push_unique_path(&mut paths, PathBuf::from(p));
232 }
233
234 if let Some(affected) = result
236 .output
237 .get("affected_paths")
238 .and_then(serde_json::Value::as_array)
239 {
240 for v in affected {
241 if let Some(p) = v.as_str() {
242 push_unique_path(&mut paths, PathBuf::from(p));
243 }
244 }
245 }
246
247 for key in &["file", "path", "file_path"] {
249 if let Some(p) = invocation
250 .input
251 .get(*key)
252 .and_then(serde_json::Value::as_str)
253 {
254 push_unique_path(&mut paths, PathBuf::from(p));
255 }
256 }
257
258 if let Some(changed) = result
260 .output
261 .get("files_changed")
262 .and_then(serde_json::Value::as_array)
263 {
264 for v in changed {
265 if let Some(p) = v.as_str() {
266 push_unique_path(&mut paths, PathBuf::from(p));
267 }
268 }
269 }
270
271 paths
272}
273
274fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
275 if !paths.contains(&path) {
276 paths.push(path);
277 }
278}
279
280#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::tool::{ToolInvocation, ToolResult};
288 use serde_json::json;
289 use std::path::PathBuf;
290
291 #[test]
294 fn analyzer_detects_env_file() {
295 let report = EffectAnalyzer::analyze(&[PathBuf::from(".env")], &[], &[]);
296 assert!(
297 report.key_files_affected.iter().any(|k| k.contains(".env")),
298 "expected .env in key_files_affected, got {:?}",
299 report.key_files_affected
300 );
301 assert_eq!(report.blast_radius, BlastRadius::SecuritySensitive);
302 }
303
304 #[test]
305 fn analyzer_detects_cargo_toml_change() {
306 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("Cargo.toml")], &[]);
307 assert!(
308 report
309 .key_files_affected
310 .iter()
311 .any(|k| k.contains("Cargo.toml")),
312 "expected Cargo.toml in key_files_affected, got {:?}",
313 report.key_files_affected
314 );
315 assert_eq!(report.blast_radius, BlastRadius::DependencyChange);
316 }
317
318 #[test]
319 fn analyzer_ignores_src_lib_rs_change() {
320 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("src/lib.rs")], &[]);
321 assert!(
322 report.key_files_affected.is_empty(),
323 "expected no key files for src/lib.rs, got {:?}",
324 report.key_files_affected
325 );
326 assert_eq!(report.blast_radius, BlastRadius::SingleFile);
327 }
328
329 #[test]
330 fn analyzer_detects_package_json() {
331 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("package.json")], &[]);
332 assert!(
333 report
334 .key_files_affected
335 .iter()
336 .any(|k| k.contains("package.json")),
337 "expected package.json in key_files_affected"
338 );
339 assert_eq!(report.blast_radius, BlastRadius::DependencyChange);
340 }
341
342 #[test]
343 fn analyzer_detects_dockerfile() {
344 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("Dockerfile")], &[]);
345 assert!(
346 report
347 .key_files_affected
348 .iter()
349 .any(|k| k.contains("Dockerfile")),
350 "expected Dockerfile in key_files_affected"
351 );
352 }
353
354 #[test]
355 fn analyzer_detects_gitignore() {
356 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from(".gitignore")], &[]);
357 assert!(
358 report
359 .key_files_affected
360 .iter()
361 .any(|k| k.contains(".gitignore")),
362 "expected .gitignore in key_files_affected"
363 );
364 }
365
366 #[test]
367 fn analyzer_detects_github_ci_directory() {
368 let report =
369 EffectAnalyzer::analyze(&[], &[PathBuf::from(".github/workflows/ci.yml")], &[]);
370 assert!(
371 report
372 .key_files_affected
373 .iter()
374 .any(|k| k.contains("CI config")),
375 "expected CI config in key_files_affected, got {:?}",
376 report.key_files_affected
377 );
378 assert_eq!(report.blast_radius, BlastRadius::CiConfig);
379 }
380
381 #[test]
382 fn analyzer_detects_ci_folder() {
383 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("ci/build.sh")], &[]);
384 assert!(
385 report
386 .key_files_affected
387 .iter()
388 .any(|k| k.contains("CI config")),
389 "expected CI config for ci/ path"
390 );
391 }
392
393 #[test]
394 fn analyzer_detects_cargo_lock() {
395 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("Cargo.lock")], &[]);
396 assert!(
397 report
398 .key_files_affected
399 .iter()
400 .any(|k| k.contains("lockfile")),
401 "expected lockfile label for Cargo.lock"
402 );
403 assert_eq!(report.blast_radius, BlastRadius::DependencyChange);
404 }
405
406 #[test]
407 fn analyzer_detects_yarn_lock() {
408 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from("yarn.lock")], &[]);
409 assert!(
410 report
411 .key_files_affected
412 .iter()
413 .any(|k| k.contains("lockfile")),
414 "expected lockfile label for yarn.lock"
415 );
416 }
417
418 #[test]
421 fn sensitive_file_detected_in_deleted_set() {
422 let report = EffectAnalyzer::analyze(&[], &[], &[PathBuf::from(".env")]);
423 assert!(
424 report.key_files_affected.iter().any(|k| k.contains(".env")),
425 "expected .env when it is in the deleted set"
426 );
427 assert_eq!(report.blast_radius, BlastRadius::SecuritySensitive);
428 }
429
430 #[test]
433 fn multiple_files_blast_radius() {
434 let report = EffectAnalyzer::analyze(
435 &[PathBuf::from("src/main.rs")],
436 &[PathBuf::from("src/lib.rs")],
437 &[],
438 );
439 assert_eq!(report.blast_radius, BlastRadius::MultipleFiles);
440 }
441
442 #[test]
445 fn post_decision_rollback_for_guarded_effects() {
446 let report = EffectAnalyzer::analyze(&[], &[PathBuf::from(".env")], &[]);
447
448 let decision = if report.blast_radius == BlastRadius::SecuritySensitive {
449 PostDecision::Rollback(
450 "Modifying .env file -- sensitive secrets may be exposed".to_string(),
451 )
452 } else {
453 PostDecision::Allow
454 };
455
456 assert_eq!(
457 decision,
458 PostDecision::Rollback(
459 "Modifying .env file -- sensitive secrets may be exposed".to_string()
460 )
461 );
462 }
463
464 #[test]
467 fn extract_paths_from_direct_write_result() {
468 let result = ToolResult {
469 invocation_id: "i1".into(),
470 ok: true,
471 output: json!({"path": "src/main.rs", "lines_added": 1}),
472 };
473 let inv = ToolInvocation {
474 id: "i1".into(),
475 tool_name: "write".into(),
476 input: json!({"path": "src/main.rs", "content": "fn main() {}"}),
477 };
478
479 let paths = extract_paths(&result, &inv);
480 assert_eq!(paths, vec![PathBuf::from("src/main.rs")]);
481 }
482
483 #[test]
484 fn extract_paths_from_patch_result() {
485 let result = ToolResult {
486 invocation_id: "i2".into(),
487 ok: true,
488 output: json!({
489 "method": "structured",
490 "affected_paths": ["a.txt", "b.txt"],
491 }),
492 };
493 let inv = ToolInvocation {
494 id: "i2".into(),
495 tool_name: "write".into(),
496 input: json!({"patch": "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch"}),
497 };
498
499 let paths = extract_paths(&result, &inv);
500 assert_eq!(paths.len(), 2);
501 assert!(paths.contains(&PathBuf::from("a.txt")));
502 assert!(paths.contains(&PathBuf::from("b.txt")));
503 }
504
505 #[test]
508 fn blast_radius_display() {
509 assert_eq!(BlastRadius::SingleFile.to_string(), "single_file");
510 assert_eq!(BlastRadius::MultipleFiles.to_string(), "multiple_files");
511 assert_eq!(
512 BlastRadius::DependencyChange.to_string(),
513 "dependency_change"
514 );
515 assert_eq!(BlastRadius::CiConfig.to_string(), "ci_config");
516 assert_eq!(
517 BlastRadius::SecuritySensitive.to_string(),
518 "security_sensitive"
519 );
520 }
521}