Skip to main content

whycodes_import/
apply.rs

1//! Merge extracted settings into a WhyCodes `Config`. Existing keys win.
2
3use 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        // queued duplicate skipped
171        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}