Skip to main content

whycodes_import/
extract.rs

1//! Product-specific extractors. Called only after the user approved the path.
2
3use std::path::Path;
4
5use serde_json::Value;
6
7use crate::error::{ImportError, Result};
8use crate::parse::{
9    hooks_from_claude_object, hooks_from_grok_value, mcp_from_map, parse_jsonc,
10    permission_from_lists, permission_from_map, string_list, toml_to_json,
11};
12use crate::types::{Extracted, FoundSource, Product};
13
14pub fn extract(source: &FoundSource) -> Result<Extracted> {
15    if source.state == crate::types::SourceState::Symlink {
16        return Err(ImportError::SymlinkRejected(
17            source.path.display().to_string(),
18        ));
19    }
20    if source.state != crate::types::SourceState::Approved {
21        return Err(ImportError::ConsentRequired(
22            source.path.display().to_string(),
23        ));
24    }
25    let text = std::fs::read_to_string(&source.path)?;
26    match source.product {
27        Product::Claude => extract_claude(&source.path, &text),
28        Product::OpenCode => extract_opencode(&source.path, &text),
29        Product::Grok => extract_toml_product(Product::Grok, &source.path, &text),
30        Product::Codex => extract_toml_product(Product::Codex, &source.path, &text),
31    }
32}
33
34fn extract_claude(path: &Path, text: &str) -> Result<Extracted> {
35    let value = parse_jsonc(text).map_err(|e| ImportError::Parse {
36        path: path.display().to_string(),
37        message: e.to_string(),
38    })?;
39    let mut skipped = Vec::new();
40    let mut mcp = Vec::new();
41    if let Some(map) = value.get("mcpServers").or_else(|| value.get("mcp_servers")) {
42        mcp.extend(mcp_from_map(map, &mut skipped));
43    }
44    // `.claude/mcp.json` is often just `{ "mcpServers": ... }` or a bare map.
45    if mcp.is_empty()
46        && value.as_object().is_some_and(|o| {
47            !o.contains_key("mcpServers")
48                && !o.contains_key("permissions")
49                && !o.contains_key("hooks")
50        })
51    {
52        mcp.extend(mcp_from_map(&value, &mut skipped));
53    }
54    let mut permission = Default::default();
55    if let Some(perms) = value.get("permissions").or_else(|| value.get("permission")) {
56        if perms.get("allow").is_some() || perms.get("deny").is_some() || perms.get("ask").is_some()
57        {
58            permission = permission_from_lists(
59                &string_list(perms.get("allow").unwrap_or(&Value::Null)),
60                &string_list(perms.get("ask").unwrap_or(&Value::Null)),
61                &string_list(perms.get("deny").unwrap_or(&Value::Null)),
62                &mut skipped,
63            );
64        } else {
65            permission = permission_from_map(perms, &mut skipped);
66        }
67    }
68    let mut hooks = Vec::new();
69    if let Some(h) = value.get("hooks") {
70        hooks = hooks_from_claude_object(h, &mut skipped);
71    }
72    Ok(Extracted {
73        product: Product::Claude,
74        path: path.to_path_buf(),
75        mcp,
76        permission,
77        hooks,
78        skipped,
79    })
80}
81
82fn extract_opencode(path: &Path, text: &str) -> Result<Extracted> {
83    let value = parse_jsonc(text).map_err(|e| ImportError::Parse {
84        path: path.display().to_string(),
85        message: e.to_string(),
86    })?;
87    let mut skipped = Vec::new();
88    let mcp = value
89        .get("mcp")
90        .or_else(|| value.get("mcpServers"))
91        .map(|m| mcp_from_map(m, &mut skipped))
92        .unwrap_or_default();
93    let permission = value
94        .get("permission")
95        .or_else(|| value.get("permissions"))
96        .map(|p| permission_from_map(p, &mut skipped))
97        .unwrap_or_default();
98    let hooks = value
99        .get("hooks")
100        .map(|h| hooks_from_claude_object(h, &mut skipped))
101        .unwrap_or_default();
102    Ok(Extracted {
103        product: Product::OpenCode,
104        path: path.to_path_buf(),
105        mcp,
106        permission,
107        hooks,
108        skipped,
109    })
110}
111
112fn extract_toml_product(product: Product, path: &Path, text: &str) -> Result<Extracted> {
113    let toml_val: toml::Value = toml::from_str(text).map_err(|e| ImportError::Parse {
114        path: path.display().to_string(),
115        message: e.to_string(),
116    })?;
117    let value = toml_to_json(toml_val);
118    let mut skipped = Vec::new();
119    let mcp = value
120        .get("mcp_servers")
121        .or_else(|| value.get("mcp"))
122        .map(|m| mcp_from_map(m, &mut skipped))
123        .unwrap_or_default();
124    let permission = match value.get("permission").or_else(|| value.get("permissions")) {
125        Some(p)
126            if p.get("allow").is_some() || p.get("deny").is_some() || p.get("ask").is_some() =>
127        {
128            permission_from_lists(
129                &string_list(p.get("allow").unwrap_or(&Value::Null)),
130                &string_list(p.get("ask").unwrap_or(&Value::Null)),
131                &string_list(p.get("deny").unwrap_or(&Value::Null)),
132                &mut skipped,
133            )
134        }
135        Some(p) => permission_from_map(p, &mut skipped),
136        None => Default::default(),
137    };
138    let hooks = value
139        .get("hooks")
140        .map(|h| {
141            let mut from_claude = hooks_from_claude_object(h, &mut skipped);
142            if from_claude.is_empty() {
143                from_claude = hooks_from_grok_value(h, &mut skipped);
144            }
145            from_claude
146        })
147        .unwrap_or_default();
148    Ok(Extracted {
149        product,
150        path: path.to_path_buf(),
151        mcp,
152        permission,
153        hooks,
154        skipped,
155    })
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::types::SourceState;
162    use std::path::PathBuf;
163
164    fn approved(product: Product, path: PathBuf) -> FoundSource {
165        FoundSource {
166            product,
167            rel_path: "x",
168            path,
169            state: SourceState::Approved,
170        }
171    }
172
173    #[test]
174    fn claude_mcp_permission_hooks() {
175        let dir = tempfile::tempdir().unwrap();
176        let path = dir.path().join("settings.json");
177        std::fs::write(
178            &path,
179            r#"{
180              "mcpServers": {"fs": {"command": "npx", "args": ["-y", "pkg"]}},
181              "permissions": {"allow": ["Read"], "deny": ["Bash"]},
182              "hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo hi"}]}]}
183            }"#,
184        )
185        .unwrap();
186        let got = extract(&approved(Product::Claude, path)).unwrap();
187        assert_eq!(got.mcp.len(), 1);
188        assert_eq!(got.permission.len(), 2);
189        assert_eq!(got.hooks.len(), 1);
190    }
191
192    #[test]
193    fn claude_bare_mcp_json() {
194        let dir = tempfile::tempdir().unwrap();
195        let path = dir.path().join("mcp.json");
196        std::fs::write(&path, r#"{"github": {"command": "npx"}}"#).unwrap();
197        let got = extract(&approved(Product::Claude, path)).unwrap();
198        assert_eq!(got.mcp.len(), 1);
199        assert_eq!(got.mcp[0].0, "github");
200    }
201
202    #[test]
203    fn opencode_jsonc() {
204        let dir = tempfile::tempdir().unwrap();
205        let path = dir.path().join("opencode.jsonc");
206        std::fs::write(
207            &path,
208            r#"{
209              // comment
210              "mcp": {"fs": {"type": "local", "command": ["npx", "-y", "pkg"]}},
211              "permission": {"bash": "ask"}
212            }"#,
213        )
214        .unwrap();
215        let got = extract(&approved(Product::OpenCode, path)).unwrap();
216        assert_eq!(got.mcp.len(), 1);
217        assert_eq!(
218            got.permission.get("bash"),
219            Some(&whycodes_core::types::PermissionAction::Ask)
220        );
221    }
222
223    #[test]
224    fn grok_toml() {
225        let dir = tempfile::tempdir().unwrap();
226        let path = dir.path().join("config.toml");
227        std::fs::write(
228            &path,
229            r#"
230[mcp_servers.github]
231command = "gh"
232args = ["mcp"]
233
234[permission]
235deny = ["Bash"]
236allow = ["Read"]
237
238[hooks.pre_tool]
239command = "echo pre"
240matcher = "bash"
241"#,
242        )
243        .unwrap();
244        let got = extract(&approved(Product::Grok, path)).unwrap();
245        assert_eq!(got.mcp.len(), 1);
246        assert_eq!(
247            got.permission.get("bash"),
248            Some(&whycodes_core::types::PermissionAction::Deny)
249        );
250        assert_eq!(got.hooks.len(), 1);
251    }
252
253    #[test]
254    fn extract_requires_consent() {
255        let src = FoundSource {
256            product: Product::Codex,
257            rel_path: "x",
258            path: PathBuf::from("/nope"),
259            state: SourceState::New,
260        };
261        assert!(extract(&src).is_err());
262        let src = FoundSource {
263            product: Product::Codex,
264            rel_path: "x",
265            path: PathBuf::from("/nope"),
266            state: SourceState::Symlink,
267        };
268        assert!(extract(&src).is_err());
269    }
270
271    #[test]
272    fn claude_permission_object_and_opencode_parse_error() {
273        let dir = tempfile::tempdir().unwrap();
274        let path = dir.path().join("settings.json");
275        std::fs::write(
276            &path,
277            r#"{"permissions": {"bash": "ask", "read": "allow"}}"#,
278        )
279        .unwrap();
280        let got = extract(&approved(Product::Claude, path)).unwrap();
281        assert_eq!(
282            got.permission.get("bash"),
283            Some(&whycodes_core::types::PermissionAction::Ask)
284        );
285        let bad = dir.path().join("bad.json");
286        std::fs::write(&bad, "{").unwrap();
287        assert!(extract(&approved(Product::Claude, bad)).is_err());
288        let oc = dir.path().join("oc.json");
289        std::fs::write(&oc, "{").unwrap();
290        assert!(extract(&approved(Product::OpenCode, oc)).is_err());
291    }
292
293    #[test]
294    fn grok_permission_map() {
295        let dir = tempfile::tempdir().unwrap();
296        let path = dir.path().join("config.toml");
297        std::fs::write(
298            &path,
299            r#"
300[permission]
301bash = "ask"
302read = "allow"
303
304[mcp]
305fs = { command = "npx" }
306"#,
307        )
308        .unwrap();
309        let got = extract(&approved(Product::Grok, path)).unwrap();
310        assert_eq!(
311            got.permission.get("bash"),
312            Some(&whycodes_core::types::PermissionAction::Ask)
313        );
314        assert_eq!(got.mcp.len(), 1);
315    }
316
317    #[test]
318    fn parse_error() {
319        let dir = tempfile::tempdir().unwrap();
320        let path = dir.path().join("bad.toml");
321        std::fs::write(&path, "[[[").unwrap();
322        assert!(extract(&approved(Product::Codex, path)).is_err());
323    }
324
325    #[test]
326    fn claude_mcp_servers_alias_and_permission_key() {
327        let dir = tempfile::tempdir().unwrap();
328        let path = dir.path().join("settings.json");
329        std::fs::write(
330            &path,
331            r#"{
332              "mcp_servers": {"fs": {"command": "npx"}},
333              "permission": {"ask": ["Bash"], "allow": "Read"},
334              "hooks": {}
335            }"#,
336        )
337        .unwrap();
338        let got = extract(&approved(Product::Claude, path)).unwrap();
339        assert_eq!(got.mcp.len(), 1);
340        assert_eq!(
341            got.permission.get("bash"),
342            Some(&whycodes_core::types::PermissionAction::Ask)
343        );
344        assert_eq!(
345            got.permission.get("read"),
346            Some(&whycodes_core::types::PermissionAction::Allow)
347        );
348    }
349
350    #[test]
351    fn claude_skips_bare_map_when_permissions_present() {
352        let dir = tempfile::tempdir().unwrap();
353        let path = dir.path().join("settings.json");
354        std::fs::write(
355            &path,
356            r#"{"permissions": {"allow": ["Read"]}, "github": {"command": "npx"}}"#,
357        )
358        .unwrap();
359        let got = extract(&approved(Product::Claude, path)).unwrap();
360        assert!(got.mcp.is_empty());
361        assert_eq!(got.permission.len(), 1);
362    }
363
364    #[test]
365    fn extract_io_error_and_denied_consent() {
366        let src = FoundSource {
367            product: Product::Claude,
368            rel_path: "x",
369            path: PathBuf::from("/nope-missing-import-file.json"),
370            state: SourceState::Approved,
371        };
372        assert!(extract(&src).is_err());
373        let src = FoundSource {
374            product: Product::Claude,
375            rel_path: "x",
376            path: PathBuf::from("/nope"),
377            state: SourceState::Denied,
378        };
379        assert!(matches!(
380            extract(&src),
381            Err(ImportError::ConsentRequired(_))
382        ));
383    }
384
385    #[test]
386    fn opencode_mcp_servers_permissions_and_hooks() {
387        let dir = tempfile::tempdir().unwrap();
388        let path = dir.path().join("opencode.json");
389        std::fs::write(
390            &path,
391            r#"{
392              "mcpServers": {"fs": {"command": "npx"}},
393              "permissions": {"bash": "ask"},
394              "hooks": {"PostToolUse": [{"command": "echo after"}]}
395            }"#,
396        )
397        .unwrap();
398        let got = extract(&approved(Product::OpenCode, path)).unwrap();
399        assert_eq!(got.mcp.len(), 1);
400        assert_eq!(
401            got.permission.get("bash"),
402            Some(&whycodes_core::types::PermissionAction::Ask)
403        );
404        assert_eq!(got.hooks.len(), 1);
405    }
406
407    #[test]
408    fn grok_permissions_table_and_claude_shaped_hooks() {
409        let dir = tempfile::tempdir().unwrap();
410        let path = dir.path().join("config.toml");
411        std::fs::write(
412            &path,
413            r#"
414[mcp_servers.fs]
415command = "npx"
416
417[permissions]
418allow = ["Read"]
419ask = ["Bash"]
420
421[hooks.PreToolUse]
422matcher = "bash"
423command = "echo pre"
424"#,
425        )
426        .unwrap();
427        let got = extract(&approved(Product::Grok, path)).unwrap();
428        assert_eq!(got.mcp.len(), 1);
429        assert_eq!(got.permission.len(), 2);
430        assert_eq!(got.hooks.len(), 1);
431    }
432
433    #[test]
434    fn grok_empty_hooks_falls_back_to_grok_value() {
435        let dir = tempfile::tempdir().unwrap();
436        let path = dir.path().join("config.toml");
437        std::fs::write(
438            &path,
439            r#"
440[hooks.post_tool]
441command = "echo post"
442timeout_secs = 5
443"#,
444        )
445        .unwrap();
446        let got = extract(&approved(Product::Grok, path)).unwrap();
447        assert_eq!(got.hooks.len(), 1);
448        assert_eq!(got.hooks[0].timeout_secs, 5);
449    }
450
451    #[test]
452    fn toml_product_without_optional_tables() {
453        let dir = tempfile::tempdir().unwrap();
454        let path = dir.path().join("config.toml");
455        std::fs::write(&path, "default_agent = \"build\"\n").unwrap();
456        let got = extract(&approved(Product::Codex, path)).unwrap();
457        assert!(got.mcp.is_empty());
458        assert!(got.permission.is_empty());
459        assert!(got.hooks.is_empty());
460    }
461
462    #[test]
463    fn cursor_style_mcp_and_empty_opencode() {
464        let dir = tempfile::tempdir().unwrap();
465        let path = dir.path().join("mcp.json");
466        std::fs::write(
467            &path,
468            r#"{
469              "github": {
470                "command": ["npx", "-y", "@modelcontextprotocol/server-github"],
471                "env": {"GITHUB_TOKEN": "x"},
472                "cwd": "/tmp"
473              },
474              "remote": {"url": "https://mcp.example/mcp", "headers": {"A": "1"}}
475            }"#,
476        )
477        .unwrap();
478        let got = extract(&approved(Product::Claude, path)).unwrap();
479        assert_eq!(got.mcp.len(), 2);
480
481        let oc = dir.path().join("opencode.jsonc");
482        std::fs::write(&oc, "{}\n").unwrap();
483        let empty = extract(&approved(Product::OpenCode, oc)).unwrap();
484        assert!(empty.mcp.is_empty());
485        assert!(empty.permission.is_empty());
486        assert!(empty.hooks.is_empty());
487
488        let cursor_settings = dir.path().join("settings.json");
489        std::fs::write(
490            &cursor_settings,
491            r#"{"mcp_servers": {"fs": {"command": "npx", "enabled": true}}, "permission": {"bash": "prompt"}}"#,
492        )
493        .unwrap();
494        let got = extract(&approved(Product::Claude, cursor_settings)).unwrap();
495        assert_eq!(got.mcp.len(), 1);
496        assert_eq!(
497            got.permission.get("bash"),
498            Some(&whycodes_core::types::PermissionAction::Ask)
499        );
500    }
501
502    #[test]
503    fn codex_toml_permissions_map_and_mcp_servers() {
504        let dir = tempfile::tempdir().unwrap();
505        let path = dir.path().join("config.toml");
506        std::fs::write(
507            &path,
508            r#"
509[mcp_servers.fs]
510command = "npx"
511args = ["-y", "pkg"]
512
513[permissions]
514bash = "ask"
515read = "allow"
516"#,
517        )
518        .unwrap();
519        let got = extract(&approved(Product::Codex, path)).unwrap();
520        assert_eq!(got.mcp.len(), 1);
521        assert_eq!(got.permission.len(), 2);
522    }
523
524    #[test]
525    fn grok_deny_only_permissions() {
526        let dir = tempfile::tempdir().unwrap();
527        let path = dir.path().join("config.toml");
528        std::fs::write(
529            &path,
530            r#"
531[permissions]
532deny = ["Bash"]
533"#,
534        )
535        .unwrap();
536        let got = extract(&approved(Product::Grok, path)).unwrap();
537        assert_eq!(
538            got.permission.get("bash"),
539            Some(&whycodes_core::types::PermissionAction::Deny)
540        );
541    }
542
543    #[test]
544    fn grok_unknown_hook_event_is_skipped() {
545        let dir = tempfile::tempdir().unwrap();
546        let path = dir.path().join("config.toml");
547        std::fs::write(
548            &path,
549            r#"
550[hooks.Notification]
551command = "echo no"
552"#,
553        )
554        .unwrap();
555        let got = extract(&approved(Product::Grok, path)).unwrap();
556        assert!(got.hooks.is_empty());
557        assert!(!got.skipped.is_empty());
558    }
559}