//! Fix with AI (src/ai_fix.rs): the prompt, the dirty-tree refusal, Undo restricted to the run's own files, the //! timeout and kill by PID, and that claude only ever gets the file tools. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; use storebound_desktop::ai_fix::*; fn finding() -> AiFinding { AiFinding { rule: "ats".into(), guideline: "Apple: App Transport Security".into(), store: "apple".into(), title: "NSAllowsArbitraryLoads is true".into(), evidence: vec![ Evidence { file: "ios/App/Info.plist".into(), line: 42, text: Some("NSAllowsArbitraryLoads\ttrue".into()) }, Evidence { file: "../outside.txt".into(), line: 1, text: None }, ], fix: "Remove NSAllowsArbitraryLoads; add exception domains only where needed.".into(), skill: "review-rejections".into(), } } fn tmp(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("sb-aifix-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&d); fs::create_dir_all(&d).unwrap(); d } fn git(dir: &Path, args: &[&str]) { let ok = Command::new("git").current_dir(dir).args(args).env("GIT_AUTHOR_NAME", "T").env("GIT_AUTHOR_EMAIL", "t@example.com").env("GIT_COMMITTER_NAME", "T").env("GIT_COMMITTER_EMAIL", "t@example.com").output().unwrap(); assert!(ok.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&ok.stderr)); } fn repo(name: &str) -> PathBuf { let d = tmp(name); git(&d, &["init", "-q"]); fs::create_dir_all(d.join("app")).unwrap(); fs::write(d.join("app/Info.plist"), "a\n").unwrap(); fs::write(d.join("app/Other.swift"), "b\n").unwrap(); git(&d, &["add", "."]); git(&d, &["commit", "-q", "-m", "init"]); d } #[test] fn prompt_names_the_finding_and_the_limits() { let p = build_prompt(&finding()).unwrap(); for s in ["ats", "NSAllowsArbitraryLoads is true", "ios/App/Info.plist:42", "review-rejections", "Change only what this finding needs", "Do not run commands", "commit or push", "Never invent an answer"] { assert!(p.contains(s), "missing {s}"); } // evidence outside the folder is left out; control characters are flattened assert!(!p.contains("outside.txt")); assert!(!p.contains('\t')); let mut bad = finding(); bad.rule = "ats; rm -rf".into(); assert!(build_prompt(&bad).is_err()); bad = finding(); bad.store = "web".into(); assert!(build_prompt(&bad).is_err()); bad = finding(); bad.title = "x".repeat(5000); assert!(build_prompt(&bad).is_err()); } #[test] fn claude_gets_only_the_file_tools() { let a = claude_args("hi"); assert_eq!(a[0], "-p"); let allowed = &a[a.iter().position(|x| x == "--allowedTools").unwrap() + 1]; assert_eq!(allowed, "Read,Edit,Write,Grep,Glob"); let denied = &a[a.iter().position(|x| x == "--disallowedTools").unwrap() + 1]; for t in ["Bash", "WebFetch", "WebSearch", "Task"] { assert!(denied.split(',').any(|x| x == t), "{t} not denied"); } assert!(!a.iter().any(|x| x.contains("dangerously") || x == "bypassPermissions")); } #[test] fn finds_claude_in_the_usual_folders() { let d = tmp("which"); let home = d.join("home"); fs::create_dir_all(home.join(".local/bin")).unwrap(); let dirs = search_path(Some(""), Some(&home)); assert!(find_claude_in(&dirs).is_none() || !find_claude_in(&dirs).unwrap().starts_with(&home)); let name = if cfg!(windows) { "claude.exe" } else { "claude" }; fs::write(home.join(".local/bin").join(name), "").unwrap(); assert_eq!(find_claude_in(&dirs).unwrap(), home.join(".local/bin").join(name)); } #[test] fn dirty_tree_is_reported_and_clean_tree_is_empty() { let d = repo("dirty"); let (_, clean) = status(&d.join("app")).unwrap(); assert!(clean.is_empty()); fs::write(d.join("app/Other.swift"), "changed\n").unwrap(); fs::write(d.join("app/new.txt"), "n\n").unwrap(); let (_, dirty) = status(&d.join("app")).unwrap(); let mut paths: Vec<(String, String)> = dirty.iter().map(|c| (c.path.clone(), c.status.clone())).collect(); paths.sort(); assert_eq!(paths, vec![("app/Other.swift".into(), "M".into()), ("app/new.txt".into(), "??".into())]); // not a repository: refused with a reason assert!(status(&tmp("nogit")).unwrap_err().contains("git")); } #[test] fn undo_restores_only_the_runs_files() { let d = repo("undo"); // the run changed Info.plist and created new.txt; Other.swift was changed later by the owner, not by the run fs::write(d.join("app/Info.plist"), "fixed\n").unwrap(); fs::write(d.join("app/new.txt"), "n\n").unwrap(); let (_, run) = status(&d.join("app")).unwrap(); fs::write(d.join("app/Other.swift"), "owner edit\n").unwrap(); let diff_text = diff(&d, &run); assert!(diff_text.contains("+fixed") && diff_text.contains("+++ b/app/new.txt")); let done = undo(&d, &run).unwrap(); assert_eq!(done.len(), 2); assert_eq!(fs::read_to_string(d.join("app/Info.plist")).unwrap(), "a\n"); assert!(!d.join("app/new.txt").exists()); assert_eq!(fs::read_to_string(d.join("app/Other.swift")).unwrap(), "owner edit\n"); // paths outside the repository never reach git or the file system let evil = vec![Change { path: "../x".into(), status: "M".into() }, Change { path: "/etc/hosts".into(), status: "??".into() }, Change { path: "-f".into(), status: "M".into() }]; assert_eq!(undo_plan(&evil), (vec![], vec![])); } #[test] fn status_parser_handles_renames_and_new_files() { let z = "R new.txt\0old.txt\0 M a b.txt\0?? n/x.txt\0"; assert_eq!(parse_status(z), vec![ Change { path: "new.txt".into(), status: "R".into() }, Change { path: "a b.txt".into(), status: "M".into() }, Change { path: "n/x.txt".into(), status: "??".into() }, ]); } #[test] fn stream_json_lines_become_progress() { assert_eq!(progress_line(r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"ios/App/Info.plist"}}]}}"#).unwrap(), "Edit ios/App/Info.plist"); assert_eq!(progress_line(r#"{"type":"result","result":"Removed NSAllowsArbitraryLoads."}"#).unwrap(), "Removed NSAllowsArbitraryLoads."); assert!(progress_line(r#"{"type":"system","subtype":"init"}"#).is_none()); assert!(progress_line("not json").is_none()); } #[cfg(unix)] #[test] fn timeout_kills_the_process_group_with_heartbeats() { let mut c = Command::new("sh"); c.args(["-c", "sleep 30 & sleep 30; echo never"]); let t = Instant::now(); let mut lines = Vec::new(); let o = run(c, "t-timeout", Duration::from_millis(800), Duration::from_millis(300), |l| lines.push(l.to_string())).unwrap(); assert!(t.elapsed() < Duration::from_secs(10)); assert!(o.timed_out); assert!(lines.iter().any(|l| l.starts_with("still working"))); assert!(lines.iter().any(|l| l.starts_with("timed out"))); assert!(!lines.iter().any(|l| l.contains("never"))); } #[cfg(unix)] #[test] fn cancel_kills_by_pid() { let h = std::thread::spawn(|| { let mut c = Command::new("sh"); c.args(["-c", "sleep 30"]); run(c, "t-cancel", Duration::from_secs(60), Duration::from_secs(60), |_| {}).unwrap() }); let end = Instant::now() + Duration::from_secs(5); while !cancel("t-cancel") { assert!(Instant::now() < end, "never started"); std::thread::sleep(Duration::from_millis(50)); } let o = h.join().unwrap(); assert!(o.cancelled && !o.timed_out); assert!(!cancel("t-cancel")); }