1use 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 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}