1use whycodes_config::Config;
4
5use crate::types::{Extracted, ImportPlan};
6
7pub fn plan(config: &Config, extracted: &[Extracted], force: bool) -> ImportPlan {
8 let mut plan = ImportPlan::default();
9 for item in extracted {
10 plan.warnings.extend(item.skipped.iter().cloned());
11 for (name, server) in &item.mcp {
12 if !force && config.mcp_servers.contains_key(name) {
13 plan.mcp_skip
14 .push((name.clone(), format!("already have `{name}`")));
15 continue;
16 }
17 if plan.mcp_add.iter().any(|(n, _)| n == name) {
18 plan.mcp_skip
19 .push((name.clone(), format!("already queued `{name}`")));
20 continue;
21 }
22 plan.mcp_add.push((name.clone(), server.clone()));
23 }
24 for (tool, action) in &item.permission {
25 if !force && config.permission.contains_key(tool) {
26 plan.permission_skip
27 .push((tool.clone(), format!("already have `{tool}`")));
28 continue;
29 }
30 if plan.permission_add.iter().any(|(t, _)| t == tool) {
31 plan.permission_skip
32 .push((tool.clone(), format!("already queued `{tool}`")));
33 continue;
34 }
35 plan.permission_add.push((tool.clone(), *action));
36 }
37 for hook in &item.hooks {
38 let dup_existing = config.hooks.iter().any(|h| {
39 h.event == hook.event
40 && h.command == hook.command
41 && h.tool_match == hook.tool_match
42 });
43 let dup_queued = plan.hooks_add.iter().any(|h| {
44 h.event == hook.event
45 && h.command == hook.command
46 && h.tool_match == hook.tool_match
47 });
48 if dup_existing || dup_queued {
49 plan.hooks_skip
50 .push(format!("{} `{}`", hook.event_label(), hook.command));
51 continue;
52 }
53 plan.hooks_add.push(hook.clone());
54 }
55 }
56 plan
57}
58
59trait HookEventLabel {
60 fn event_label(&self) -> &'static str;
61}
62
63impl HookEventLabel for whycodes_config::HookConfig {
64 fn event_label(&self) -> &'static str {
65 self.event.as_str()
66 }
67}
68
69pub fn apply(config: &mut Config, plan: &ImportPlan) {
70 for (name, server) in &plan.mcp_add {
71 config.mcp_servers.insert(name.clone(), server.clone());
72 }
73 for (tool, action) in &plan.permission_add {
74 config.permission.insert(tool.clone(), *action);
75 }
76 config.hooks.extend(plan.hooks_add.iter().cloned());
77}
78
79fn map_msg(e: impl ToString) -> crate::error::ImportError {
80 crate::error::ImportError::Msg(e.to_string())
81}
82
83pub fn apply_and_save(plan: &ImportPlan) -> crate::error::Result<std::path::PathBuf> {
84 whycodes_config::Config::update_global(&mut |global| apply(global, plan)).map_err(map_msg)?;
85 let path = whycodes_config::Config::default_path().map_err(map_msg)?;
86 Ok(path)
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::types::Product;
93 use std::path::PathBuf;
94 use whycodes_config::{HookConfig, HookEvent, McpServerConfig};
95 use whycodes_core::types::PermissionAction;
96
97 fn mcp(name: &str) -> (String, McpServerConfig) {
98 (
99 name.into(),
100 McpServerConfig {
101 transport: None,
102 command: Some("npx".into()),
103 args: vec![],
104 env: None,
105 cwd: None,
106 url: None,
107 headers: None,
108 },
109 )
110 }
111
112 #[test]
113 fn existing_keys_win_unless_force() {
114 let mut cfg = Config::default();
115 let (n, s) = mcp("fs");
116 cfg.mcp_servers.insert(n.clone(), s);
117 cfg.permission.insert("bash".into(), PermissionAction::Ask);
118 cfg.hooks.push(HookConfig {
119 event: HookEvent::PreTool,
120 tool_match: "bash".into(),
121 command: "echo hi".into(),
122 block_on_failure: true,
123 feedback: false,
124 timeout_secs: 30,
125 });
126 let extracted = Extracted {
127 product: Product::Claude,
128 path: PathBuf::from("/x"),
129 mcp: vec![mcp("fs"), mcp("git")],
130 permission: {
131 let mut m = std::collections::HashMap::new();
132 m.insert("bash".into(), PermissionAction::Deny);
133 m.insert("read".into(), PermissionAction::Allow);
134 m
135 },
136 hooks: vec![
137 HookConfig {
138 event: HookEvent::PreTool,
139 tool_match: "bash".into(),
140 command: "echo hi".into(),
141 block_on_failure: true,
142 feedback: false,
143 timeout_secs: 30,
144 },
145 HookConfig {
146 event: HookEvent::PostTool,
147 tool_match: "*".into(),
148 command: "echo after".into(),
149 block_on_failure: false,
150 feedback: false,
151 timeout_secs: 30,
152 },
153 ],
154 skipped: vec!["ignored event".into()],
155 };
156 let p = plan(&cfg, std::slice::from_ref(&extracted), false);
157 assert_eq!(p.mcp_add.len(), 1);
158 assert_eq!(p.mcp_skip.len(), 1);
159 assert_eq!(p.permission_add.len(), 1);
160 assert_eq!(p.permission_skip.len(), 1);
161 assert_eq!(p.hooks_add.len(), 1);
162 assert_eq!(p.hooks_skip.len(), 1);
163 assert!(!p.warnings.is_empty());
164 let forced = plan(&cfg, std::slice::from_ref(&extracted), true);
165 assert_eq!(forced.mcp_add.len(), 2);
166 apply(&mut cfg, &p);
167 assert!(cfg.mcp_servers.contains_key("git"));
168 assert_eq!(cfg.permission.get("read"), Some(&PermissionAction::Allow));
169 assert_eq!(cfg.hooks.len(), 2);
170 let again = plan(&cfg, &[extracted], false);
172 assert!(again.mcp_add.is_empty());
173 }
174
175 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
176
177 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
178 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
179 }
180
181 fn isolated_home(f: impl FnOnce(&std::path::Path)) {
182 let _guard = lock_env();
183 isolated_home_locked(f);
184 }
185
186 fn isolated_home_locked(f: impl FnOnce(&std::path::Path)) {
187 let dir = tempfile::tempdir().unwrap();
188 let prev = std::env::var_os("WHYCODES_HOME");
189 unsafe { std::env::set_var("WHYCODES_HOME", dir.path()) };
190 f(dir.path());
191 restore_home(prev);
192 }
193
194 fn restore_home(prev: Option<std::ffi::OsString>) {
195 match prev {
196 Some(v) => unsafe { std::env::set_var("WHYCODES_HOME", v) },
197 None => unsafe { std::env::remove_var("WHYCODES_HOME") },
198 }
199 }
200
201 #[test]
202 fn isolated_home_restores_previous_whycodes_home() {
203 let _guard = lock_env();
204 unsafe { std::env::set_var("WHYCODES_HOME", "/tmp/whycodes-import-prev-home") };
205 isolated_home_locked(|_| {
206 assert_ne!(
207 std::env::var_os("WHYCODES_HOME").as_deref(),
208 Some(std::ffi::OsStr::new("/tmp/whycodes-import-prev-home"))
209 );
210 });
211 assert_eq!(
212 std::env::var_os("WHYCODES_HOME").as_deref(),
213 Some(std::ffi::OsStr::new("/tmp/whycodes-import-prev-home"))
214 );
215 restore_home(Some("/tmp/whycodes-import-prev-home".into()));
216 restore_home(None);
217 }
218
219 #[test]
220 fn lock_env_recovers_from_poison() {
221 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
222 let _g = ENV_LOCK.lock().unwrap();
223 panic!("poison");
224 }));
225 let _g = lock_env();
226 }
227
228 fn hook(event: HookEvent, tool: &str, command: &str) -> HookConfig {
229 HookConfig {
230 event,
231 tool_match: tool.into(),
232 command: command.into(),
233 block_on_failure: event == HookEvent::PreTool,
234 feedback: false,
235 timeout_secs: 30,
236 }
237 }
238
239 #[test]
240 fn queued_duplicates_and_force_overwrite() {
241 let cfg = Config::default();
242 let a = Extracted {
243 product: Product::Claude,
244 path: PathBuf::from("/a"),
245 mcp: vec![mcp("fs")],
246 permission: {
247 let mut m = std::collections::HashMap::new();
248 m.insert("bash".into(), PermissionAction::Ask);
249 m
250 },
251 hooks: vec![hook(HookEvent::PreTool, "bash", "echo hi")],
252 skipped: vec![],
253 };
254 let b = Extracted {
255 product: Product::Codex,
256 path: PathBuf::from("/b"),
257 mcp: vec![mcp("fs")],
258 permission: {
259 let mut m = std::collections::HashMap::new();
260 m.insert("bash".into(), PermissionAction::Deny);
261 m
262 },
263 hooks: vec![hook(HookEvent::PreTool, "bash", "echo hi")],
264 skipped: vec!["partial skip".into()],
265 };
266 let p = plan(&cfg, &[a.clone(), b], false);
267 assert_eq!(p.mcp_add.len(), 1);
268 assert_eq!(p.mcp_skip.len(), 1);
269 assert!(p.mcp_skip[0].1.contains("already queued"));
270 assert_eq!(p.permission_add.len(), 1);
271 assert_eq!(p.permission_skip.len(), 1);
272 assert!(p.permission_skip[0].1.contains("already queued"));
273 assert_eq!(p.hooks_add.len(), 1);
274 assert_eq!(p.hooks_skip.len(), 1);
275 assert!(p.hooks_skip[0].contains("pre_tool"));
276 assert!(p.warnings.iter().any(|w| w.contains("partial skip")));
277
278 let mut existing = Config::default();
279 let (n, s) = mcp("fs");
280 existing.mcp_servers.insert(n, s);
281 existing
282 .permission
283 .insert("bash".into(), PermissionAction::Allow);
284 existing
285 .hooks
286 .push(hook(HookEvent::PostTool, "*", "echo after"));
287 let later = Extracted {
288 product: Product::OpenCode,
289 path: PathBuf::from("/c"),
290 mcp: vec![mcp("fs")],
291 permission: {
292 let mut m = std::collections::HashMap::new();
293 m.insert("bash".into(), PermissionAction::Deny);
294 m
295 },
296 hooks: vec![hook(HookEvent::PostTool, "*", "echo after")],
297 skipped: vec![],
298 };
299 let skipped = plan(&existing, std::slice::from_ref(&later), false);
300 assert!(skipped.mcp_add.is_empty());
301 assert!(skipped.permission_add.is_empty());
302 assert!(skipped.hooks_add.is_empty());
303 assert!(skipped.hooks_skip[0].contains("post_tool"));
304 let forced = plan(&existing, &[later], true);
305 assert_eq!(forced.mcp_add.len(), 1);
306 assert_eq!(forced.permission_add.len(), 1);
307 assert!(forced.hooks_add.is_empty());
308 }
309
310 #[test]
311 fn apply_and_save_writes_isolated_home() {
312 isolated_home(|home| {
313 let mut p = ImportPlan::default();
314 p.mcp_add.push(mcp("git"));
315 p.permission_add
316 .push(("read".into(), PermissionAction::Allow));
317 p.hooks_add
318 .push(hook(HookEvent::PostTool, "*", "echo after"));
319 let path = apply_and_save(&p).unwrap();
320 assert_eq!(path, home.join("config.toml"));
321 assert!(path.exists());
322 let loaded = Config::load().unwrap();
323 assert!(loaded.mcp_servers.contains_key("git"));
324 assert_eq!(
325 loaded.permission.get("read"),
326 Some(&PermissionAction::Allow)
327 );
328 assert_eq!(loaded.hooks.len(), 1);
329 });
330 }
331
332 #[test]
333 fn apply_and_save_maps_load_error() {
334 isolated_home(|home| {
335 std::fs::write(home.join("config.toml"), "[[[").unwrap();
336 let err = apply_and_save(&ImportPlan::default()).unwrap_err();
337 assert!(err.to_string().contains("TOML") || err.to_string().contains("parse"));
338 });
339 }
340
341 #[test]
342 fn apply_and_save_maps_save_error() {
343 isolated_home(|home| {
344 std::fs::create_dir(home.join("config.toml")).unwrap();
345 let mut p = ImportPlan::default();
346 p.mcp_add.push(mcp("git"));
347 let err = apply_and_save(&p).unwrap_err();
348 assert!(!err.to_string().is_empty());
349 });
350 }
351
352 #[test]
353 fn map_msg_wraps_display() {
354 let err = map_msg("boom");
355 assert!(err.to_string().contains("boom"));
356 let err = map_msg(std::io::Error::other("io"));
357 assert!(err.to_string().contains("io"));
358 }
359}