1use std::collections::HashMap;
4
5use serde_json::Value;
6use whycodes_config::{HookConfig, HookEvent, McpServerConfig, McpTransportKind};
7use whycodes_core::types::PermissionAction;
8
9use crate::error::{ImportError, Result};
10
11pub fn parse_jsonc(text: &str) -> Result<Value> {
13 let stripped = strip_jsonc(text);
14 serde_json::from_str(&stripped).map_err(ImportError::from)
15}
16
17fn strip_jsonc(input: &str) -> String {
18 let mut out = String::with_capacity(input.len());
19 let bytes = input.as_bytes();
20 let mut i = 0;
21 let mut in_string = false;
22 let mut escape = false;
23 while i < bytes.len() {
24 let c = bytes[i];
25 if in_string {
26 out.push(c as char);
27 if escape {
28 escape = false;
29 } else if c == b'\\' {
30 escape = true;
31 } else if c == b'"' {
32 in_string = false;
33 }
34 i += 1;
35 continue;
36 }
37 if c == b'"' {
38 in_string = true;
39 out.push('"');
40 i += 1;
41 continue;
42 }
43 if c == b'/' && i + 1 < bytes.len() {
44 if bytes[i + 1] == b'/' {
45 i += 2;
46 while i < bytes.len() && bytes[i] != b'\n' {
47 i += 1;
48 }
49 continue;
50 }
51 if bytes[i + 1] == b'*' {
52 i += 2;
53 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
54 i += 1;
55 }
56 i = (i + 2).min(bytes.len());
57 continue;
58 }
59 }
60 if c == b',' {
61 let mut j = i + 1;
62 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
63 j += 1;
64 }
65 if j < bytes.len() && (bytes[j] == b'}' || bytes[j] == b']') {
66 i += 1;
67 continue;
68 }
69 }
70 out.push(c as char);
71 i += 1;
72 }
73 out
74}
75
76pub fn string_map(value: &Value) -> Option<HashMap<String, String>> {
77 let obj = value.as_object()?;
78 let mut out = HashMap::new();
79 for (k, v) in obj {
80 match v {
81 Value::String(s) => {
82 out.insert(k.clone(), s.clone());
83 }
84 Value::Number(n) => {
85 out.insert(k.clone(), n.to_string());
86 }
87 Value::Bool(b) => {
88 out.insert(k.clone(), b.to_string());
89 }
90 _ => return None,
91 }
92 }
93 Some(out)
94}
95
96pub fn string_list(value: &Value) -> Vec<String> {
97 match value {
98 Value::Array(a) => a
99 .iter()
100 .filter_map(|v| v.as_str().map(str::to_string))
101 .collect(),
102 Value::String(s) => vec![s.clone()],
103 _ => Vec::new(),
104 }
105}
106
107pub fn mcp_from_object(
109 name: &str,
110 raw: &Value,
111 skipped: &mut Vec<String>,
112) -> Option<(String, McpServerConfig)> {
113 if !raw.is_object() {
114 skipped.push(format!("{name}: not an object"));
115 return None;
116 }
117 if raw.get("disabled").and_then(Value::as_bool) == Some(true)
118 || raw.get("enabled").and_then(Value::as_bool) == Some(false)
119 {
120 skipped.push(format!("{name}: disabled"));
121 return None;
122 }
123 let command_val = raw.get("command");
124 let (command, args) = match command_val {
125 Some(Value::String(s)) => (
126 Some(s.clone()),
127 string_list(raw.get("args").unwrap_or(&Value::Null)),
128 ),
129 Some(Value::Array(a)) => {
130 let mut parts: Vec<String> = a
131 .iter()
132 .filter_map(|v| v.as_str().map(str::to_string))
133 .collect();
134 if parts.is_empty() {
135 (None, Vec::new())
136 } else {
137 let cmd = parts.remove(0);
138 let extra = string_list(raw.get("args").unwrap_or(&Value::Null));
139 parts.extend(extra);
140 (Some(cmd), parts)
141 }
142 }
143 _ => (None, string_list(raw.get("args").unwrap_or(&Value::Null))),
144 };
145 let url = raw
146 .get("url")
147 .or_else(|| raw.get("serverUrl"))
148 .and_then(Value::as_str)
149 .map(str::to_string);
150 if command.is_none() && url.is_none() {
151 skipped.push(format!("{name}: neither command nor url"));
152 return None;
153 }
154 let env = raw
155 .get("env")
156 .or_else(|| raw.get("environment"))
157 .and_then(string_map);
158 let headers = raw.get("headers").and_then(string_map);
159 let cwd = raw.get("cwd").and_then(Value::as_str).map(str::to_string);
160 let transport = match raw.get("type").and_then(Value::as_str) {
161 Some("http" | "streamable-http" | "streamable_http" | "remote") if url.is_some() => {
162 Some(McpTransportKind::Http)
163 }
164 Some("sse") if url.is_some() => Some(McpTransportKind::Sse),
165 Some("stdio" | "local") | None if command.is_some() => Some(McpTransportKind::Stdio),
166 Some("auto") if url.is_some() => Some(McpTransportKind::Auto),
167 _ if url.is_some() => Some(McpTransportKind::Auto),
168 _ => None,
169 };
170 Some((
171 name.to_string(),
172 McpServerConfig {
173 transport,
174 command,
175 args,
176 env,
177 cwd,
178 url,
179 headers,
180 },
181 ))
182}
183
184pub fn mcp_from_map(map: &Value, skipped: &mut Vec<String>) -> Vec<(String, McpServerConfig)> {
185 let Some(obj) = map.as_object() else {
186 return Vec::new();
187 };
188 obj.iter()
189 .filter_map(|(name, raw)| mcp_from_object(name, raw, skipped))
190 .collect()
191}
192
193pub fn permission_from_map(
195 value: &Value,
196 skipped: &mut Vec<String>,
197) -> HashMap<String, PermissionAction> {
198 let mut out = HashMap::new();
199 let Some(obj) = value.as_object() else {
200 skipped.push("permission: not an object".into());
201 return out;
202 };
203 for (key, val) in obj {
204 match val {
205 Value::String(s) => match PermissionAction::parse(s) {
206 Some(a) => {
207 out.insert(map_tool_name(key), a);
208 }
209 None => skipped.push(format!("permission {key}: unknown action `{s}`")),
210 },
211 Value::Bool(true) => {
212 out.insert(map_tool_name(key), PermissionAction::Allow);
213 }
214 Value::Bool(false) => {
215 out.insert(map_tool_name(key), PermissionAction::Deny);
216 }
217 Value::Object(inner) => {
218 if let Some(action) = inner
219 .get("action")
220 .or_else(|| inner.get("*"))
221 .and_then(Value::as_str)
222 .and_then(PermissionAction::parse)
223 {
224 out.insert(map_tool_name(key), action);
225 } else {
226 skipped.push(format!("permission {key}: nested object not mapped"));
227 }
228 }
229 _ => skipped.push(format!("permission {key}: unsupported value")),
230 }
231 }
232 out
233}
234
235pub fn permission_from_lists(
237 allow: &[String],
238 ask: &[String],
239 deny: &[String],
240 skipped: &mut Vec<String>,
241) -> HashMap<String, PermissionAction> {
242 let mut out = HashMap::new();
243 for (list, action) in [
244 (allow, PermissionAction::Allow),
245 (ask, PermissionAction::Ask),
246 (deny, PermissionAction::Deny),
247 ] {
248 for raw in list {
249 match rule_to_key(raw) {
250 Some(key) => {
251 match out.get(&key) {
253 Some(PermissionAction::Deny) => {}
254 Some(_) if action != PermissionAction::Deny => {}
255 _ => {
256 out.insert(key, action);
257 }
258 }
259 }
260 None => skipped.push(format!("permission rule not mapped: {raw}")),
261 }
262 }
263 }
264 out
265}
266
267fn rule_to_key(raw: &str) -> Option<String> {
268 let trimmed = raw.trim();
269 if trimmed.is_empty() {
270 return None;
271 }
272 let tool = if let Some(open) = trimmed.find('(') {
273 &trimmed[..open]
274 } else {
275 trimmed
276 };
277 let mapped = map_tool_name(tool);
278 if mapped.is_empty() {
279 None
280 } else {
281 Some(mapped)
282 }
283}
284
285pub fn map_tool_name(name: &str) -> String {
286 match name.trim().to_ascii_lowercase().as_str() {
287 "bash" | "shell" | "command" => "bash".into(),
288 "read" | "read_file" | "view" => "read".into(),
289 "edit" | "edit_file" | "strreplace" | "str_replace" | "write" | "write_file" => {
290 "edit".into()
291 }
292 "grep" | "search" => "grep".into(),
293 "glob" | "find" => "glob".into(),
294 "webfetch" | "web_fetch" | "fetch" => "webfetch".into(),
295 "websearch" | "web_search" => "websearch".into(),
296 "mcp" | "mcptool" => "mcp".into(),
297 other => other.to_string(),
298 }
299}
300
301pub fn hook_event_from_name(name: &str) -> Option<HookEvent> {
302 match name.trim() {
303 "PreToolUse" | "pre_tool" | "pre-tool" | "PreTool" => Some(HookEvent::PreTool),
304 "PostToolUse" | "post_tool" | "post-tool" | "PostTool" => Some(HookEvent::PostTool),
305 "SessionStart" | "session_start" => Some(HookEvent::SessionStart),
306 "UserPromptSubmit" | "user_prompt_submit" => Some(HookEvent::UserPromptSubmit),
307 "Stop" | "stop" => Some(HookEvent::Stop),
308 "PreCompact" | "pre_compact" => Some(HookEvent::PreCompact),
309 "SubagentStop" | "subagent_stop" => Some(HookEvent::SubagentStop),
310 _ => None,
311 }
312}
313
314pub fn hook_from_command(
315 event: HookEvent,
316 tool_match: &str,
317 command: String,
318 block_on_failure: bool,
319 timeout_secs: Option<u64>,
320) -> HookConfig {
321 HookConfig {
322 event,
323 tool_match: if tool_match.is_empty() {
324 "*".into()
325 } else {
326 map_tool_name(tool_match)
327 },
328 command,
329 block_on_failure: block_on_failure && event.can_block(),
330 feedback: matches!(event, HookEvent::SessionStart | HookEvent::UserPromptSubmit),
332 timeout_secs: timeout_secs.unwrap_or(30).clamp(1, 300),
333 }
334}
335
336pub fn hooks_from_claude_object(value: &Value, skipped: &mut Vec<String>) -> Vec<HookConfig> {
338 let mut out = Vec::new();
339 let Some(obj) = value.as_object() else {
340 return out;
341 };
342 for (event_name, groups) in obj {
343 let Some(event) = hook_event_from_name(event_name) else {
344 skipped.push(format!(
345 "hook event `{event_name}` has no WhyCodes equivalent"
346 ));
347 continue;
348 };
349 let Some(arr) = groups.as_array() else {
350 continue;
351 };
352 for group in arr {
353 let matcher = group.get("matcher").and_then(Value::as_str).unwrap_or("*");
354 let hooks = group
355 .get("hooks")
356 .and_then(Value::as_array)
357 .cloned()
358 .unwrap_or_else(|| vec![group.clone()]);
359 for hook in hooks {
360 let kind = hook
361 .get("type")
362 .and_then(Value::as_str)
363 .unwrap_or("command");
364 if kind != "command" {
365 skipped.push(format!("hook type `{kind}` skipped"));
366 continue;
367 }
368 let Some(command) = hook.get("command").and_then(Value::as_str) else {
369 skipped.push("hook missing command".into());
370 continue;
371 };
372 let timeout = hook.get("timeout").and_then(Value::as_u64);
373 let block = event.can_block();
374 out.push(hook_from_command(
375 event,
376 matcher,
377 command.to_string(),
378 block,
379 timeout,
380 ));
381 }
382 }
383 }
384 out
385}
386
387pub fn hooks_from_grok_value(value: &Value, skipped: &mut Vec<String>) -> Vec<HookConfig> {
389 let mut out = Vec::new();
390 let Some(obj) = value.as_object() else {
391 return out;
392 };
393 for (event_name, spec) in obj {
394 let Some(event) = hook_event_from_name(event_name) else {
395 skipped.push(format!(
396 "hook event `{event_name}` has no WhyCodes equivalent"
397 ));
398 continue;
399 };
400 collect_grok_hooks(event, spec, skipped, &mut out);
401 }
402 out
403}
404
405fn collect_grok_hooks(
406 event: HookEvent,
407 spec: &Value,
408 skipped: &mut Vec<String>,
409 out: &mut Vec<HookConfig>,
410) {
411 match spec {
412 Value::Array(items) => {
413 for item in items {
414 collect_grok_hooks(event, item, skipped, out);
415 }
416 }
417 Value::Object(map) => {
418 if let Some(command) = map.get("command").and_then(Value::as_str) {
419 let matcher = map
420 .get("matcher")
421 .or_else(|| map.get("match"))
422 .and_then(Value::as_str)
423 .unwrap_or("*");
424 let timeout = map
425 .get("timeout")
426 .or_else(|| map.get("timeout_secs"))
427 .and_then(Value::as_u64);
428 out.push(hook_from_command(
429 event,
430 matcher,
431 command.to_string(),
432 event.can_block(),
433 timeout,
434 ));
435 return;
436 }
437 if let Some(hooks) = map.get("hooks") {
438 collect_grok_hooks(event, hooks, skipped, out);
439 return;
440 }
441 skipped.push(format!("hook `{event:?}` missing command"));
442 }
443 Value::String(command) => {
444 out.push(hook_from_command(
445 event,
446 "*",
447 command.clone(),
448 event.can_block(),
449 None,
450 ));
451 }
452 _ => skipped.push("hook entry not mapped".into()),
453 }
454}
455
456pub fn toml_to_json(value: toml::Value) -> Value {
457 match value {
458 toml::Value::String(s) => Value::String(s),
459 toml::Value::Integer(i) => serde_json::json!(i),
460 toml::Value::Float(f) => serde_json::json!(f),
461 toml::Value::Boolean(b) => Value::Bool(b),
462 toml::Value::Datetime(d) => Value::String(d.to_string()),
463 toml::Value::Array(a) => Value::Array(a.into_iter().map(toml_to_json).collect()),
464 toml::Value::Table(t) => {
465 let mut map = serde_json::Map::new();
466 for (k, v) in t {
467 map.insert(k, toml_to_json(v));
468 }
469 Value::Object(map)
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn jsonc_strips_comments_and_trailing_commas() {
480 let v = parse_jsonc(
481 r#"{
482 // comment
483 "a": 1, /* block */
484 "b": "x\"y",
485 "c": [1, 2,],
486 }"#,
487 )
488 .unwrap();
489 assert_eq!(v["a"], 1);
490 assert_eq!(v["b"], "x\"y");
491 assert_eq!(v["c"].as_array().unwrap().len(), 2);
492 }
493
494 #[test]
495 fn jsonc_bad_json_errors() {
496 assert!(parse_jsonc("{").is_err());
497 }
498
499 #[test]
500 fn mcp_stdio_and_http() {
501 let mut skipped = Vec::new();
502 let raw = serde_json::json!({
503 "command": "npx",
504 "args": ["-y", "pkg"],
505 "env": {"A": "1"}
506 });
507 let (name, cfg) = mcp_from_object("fs", &raw, &mut skipped).unwrap();
508 assert_eq!(name, "fs");
509 assert_eq!(cfg.command.as_deref(), Some("npx"));
510 assert_eq!(cfg.args, vec!["-y", "pkg"]);
511 let raw = serde_json::json!({"url": "https://mcp.example/mcp", "type": "http"});
512 let (_, cfg) = mcp_from_object("remote", &raw, &mut skipped).unwrap();
513 assert_eq!(cfg.url.as_deref(), Some("https://mcp.example/mcp"));
514 assert_eq!(cfg.transport, Some(McpTransportKind::Http));
515 assert!(
516 mcp_from_object("x", &serde_json::json!({"disabled": true}), &mut skipped).is_none()
517 );
518 assert!(mcp_from_object("y", &serde_json::json!({}), &mut skipped).is_none());
519 let cmd_arr = serde_json::json!({"command": ["npx", "-y", "pkg"]});
520 let (_, cfg) = mcp_from_object("z", &cmd_arr, &mut skipped).unwrap();
521 assert_eq!(cfg.command.as_deref(), Some("npx"));
522 assert_eq!(cfg.args, vec!["-y", "pkg"]);
523 assert!(
524 mcp_from_object(
525 "off",
526 &serde_json::json!({"enabled": false, "command": "x"}),
527 &mut skipped
528 )
529 .is_none()
530 );
531 let sse = serde_json::json!({"url": "https://mcp.example/sse", "type": "sse", "headers": {"A": "1"}, "cwd": "/tmp"});
532 let (_, cfg) = mcp_from_object("sse", &sse, &mut skipped).unwrap();
533 assert_eq!(cfg.transport, Some(McpTransportKind::Sse));
534 let auto = serde_json::json!({"url": "https://mcp.example/mcp"});
535 let (_, cfg) = mcp_from_object("auto", &auto, &mut skipped).unwrap();
536 assert_eq!(cfg.transport, Some(McpTransportKind::Auto));
537 assert!(
538 mcp_from_object("arr", &serde_json::json!({"command": []}), &mut skipped).is_none()
539 );
540 assert!(mcp_from_map(&serde_json::json!([]), &mut skipped).is_empty());
541 assert!(string_map(&serde_json::json!({"n": 1, "b": true, "s": "x"})).is_some());
542 assert!(string_map(&serde_json::json!({"bad": {"x": 1}})).is_none());
543 assert!(string_list(&serde_json::json!(1)).is_empty());
544 }
545
546 #[test]
547 fn permission_maps_and_lists() {
548 let mut skipped = Vec::new();
549 let map = permission_from_map(
550 &serde_json::json!({"bash": "ask", "edit": true, "read": false, "weird": "nope"}),
551 &mut skipped,
552 );
553 assert_eq!(map.get("bash"), Some(&PermissionAction::Ask));
554 assert_eq!(map.get("edit"), Some(&PermissionAction::Allow));
555 assert_eq!(map.get("read"), Some(&PermissionAction::Deny));
556 assert!(!skipped.is_empty());
557 let lists = permission_from_lists(
558 &["Read".into()],
559 &["Bash(git *)".into()],
560 &["Bash".into(), "???".into()],
561 &mut skipped,
562 );
563 assert_eq!(lists.get("bash"), Some(&PermissionAction::Deny));
564 assert_eq!(lists.get("read"), Some(&PermissionAction::Allow));
565 let nested = permission_from_map(
566 &serde_json::json!({"edit": {"action": "ask"}, "glob": {"*": "allow"}}),
567 &mut skipped,
568 );
569 assert_eq!(nested.get("edit"), Some(&PermissionAction::Ask));
570 assert_eq!(nested.get("glob"), Some(&PermissionAction::Allow));
571 let empty = permission_from_map(&serde_json::json!([]), &mut skipped);
572 assert!(empty.is_empty());
573 let lists = permission_from_lists(&[" ".into()], &[], &[], &mut skipped);
574 assert!(lists.is_empty());
575 assert_eq!(map_tool_name("write_file"), "edit");
576 assert_eq!(map_tool_name("web_search"), "websearch");
577 assert_eq!(map_tool_name("MCPTool"), "mcp");
578 assert_eq!(map_tool_name("mcp"), "mcp");
579 let unmapped =
580 permission_from_map(&serde_json::json!({"x": {"nope": 1}, "y": 3}), &mut skipped);
581 assert!(unmapped.is_empty());
582 }
583
584 #[test]
585 fn claude_and_grok_hooks() {
586 let mut skipped = Vec::new();
587 let claude = serde_json::json!({
588 "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo pre"}]}],
589 "SessionStart": [{"hooks": [{"type": "command", "command": "echo ctx"}]}],
590 "Notification": [{"hooks": [{"type": "command", "command": "echo no"}]}]
591 });
592 let hooks = hooks_from_claude_object(&claude, &mut skipped);
593 assert_eq!(hooks.len(), 2);
594 assert_eq!(hooks[0].event, HookEvent::PreTool);
595 assert_eq!(hooks[0].tool_match, "bash");
596 assert!(!hooks[0].feedback);
597 assert_eq!(hooks[1].event, HookEvent::SessionStart);
598 assert!(hooks[1].feedback && !hooks[1].block_on_failure);
599 for (name, event) in [
600 ("UserPromptSubmit", HookEvent::UserPromptSubmit),
601 ("Stop", HookEvent::Stop),
602 ("PreCompact", HookEvent::PreCompact),
603 ("SubagentStop", HookEvent::SubagentStop),
604 ] {
605 assert_eq!(hook_event_from_name(name), Some(event));
606 assert_eq!(hook_event_from_name(event.as_str()), Some(event));
607 }
608 let grok = serde_json::json!({
609 "pre_tool": {"command": "echo g", "matcher": "edit"},
610 "PostToolUse": [{"command": "echo p"}]
611 });
612 let hooks = hooks_from_grok_value(&grok, &mut skipped);
613 assert_eq!(hooks.len(), 2);
614 assert!(hook_event_from_name("Nope").is_none());
615 assert_eq!(map_tool_name("WebFetch"), "webfetch");
616 let grok_str = serde_json::json!({"pre_tool": "echo s"});
617 assert_eq!(hooks_from_grok_value(&grok_str, &mut skipped).len(), 1);
618 let nested_hooks = serde_json::json!({"pre_tool": {"hooks": [{"command": "echo n"}]}});
619 assert_eq!(hooks_from_grok_value(&nested_hooks, &mut skipped).len(), 1);
620 let missing = serde_json::json!({"pre_tool": {"matcher": "bash"}});
621 assert!(hooks_from_grok_value(&missing, &mut skipped).is_empty());
622 let prompt = serde_json::json!({
623 "PreToolUse": [{"hooks": [{"type": "prompt", "command": "nope"}]}, {"hooks": [{"command": "echo"}]}]
624 });
625 assert_eq!(hooks_from_claude_object(&prompt, &mut skipped).len(), 1);
626 let missing_cmd = serde_json::json!({"PreToolUse": [{"hooks": [{}]}]});
627 assert!(hooks_from_claude_object(&missing_cmd, &mut skipped).is_empty());
628 assert!(hooks_from_claude_object(&serde_json::json!([]), &mut skipped).is_empty());
629 assert!(hooks_from_grok_value(&serde_json::json!([]), &mut skipped).is_empty());
630 let t: toml::Value =
631 toml::from_str("a = 1.5\nb = true\nc = 2020-01-01T00:00:00Z\n").unwrap();
632 let v = toml_to_json(t);
633 assert!(v["a"].as_f64().is_some());
634 assert_eq!(v["b"], true);
635 assert!(v["c"].as_str().is_some());
636 }
637
638 #[test]
639 fn toml_roundtrip_table() {
640 let t: toml::Value = toml::from_str("[a]\nb = 1\n").unwrap();
641 let v = toml_to_json(t);
642 assert_eq!(v["a"]["b"], 1);
643 }
644
645 #[test]
646 fn jsonc_block_comment_unterminated_and_escaped_slash() {
647 let v = parse_jsonc("{").unwrap_or_else(|_| serde_json::json!({}));
648 assert!(v.is_object());
649 let v = parse_jsonc(r#"{ "a": "x\/y", "b": 1, /* unterminated }"#)
650 .unwrap_or_else(|_| serde_json::json!({}));
651 let _ = v;
652 let v = parse_jsonc(r#"{ "a": "hi\"there", "b": [1,], }"#).unwrap();
653 assert_eq!(v["a"], "hi\"there");
654 assert!(parse_jsonc("/*").is_err());
655 let lone = parse_jsonc(r#"{ "a": 1 / 2 }"#);
656 assert!(lone.is_err());
657 let v = parse_jsonc("{\"a\": 1, \t}").unwrap();
658 assert_eq!(v["a"], 1);
659 let v = parse_jsonc("{ \"a\": \"\\\\\", \"b\": 2 }").unwrap();
660 assert_eq!(v["b"], 2);
661 assert!(parse_jsonc("{ /* no close").is_err());
662 let v = parse_jsonc("{").unwrap_or_else(|_| serde_json::json!({"a":1}));
663 assert_eq!(v["a"], 1);
664
665 let _ = v;
666 let v = parse_jsonc("{ \"path\": \"C:\\\\tmp\" }").unwrap();
667 assert!(v["path"].as_str().unwrap().contains("tmp"));
668 let v = parse_jsonc("[1, 2, ]").unwrap();
669 assert_eq!(v.as_array().unwrap().len(), 2);
670 let v = parse_jsonc("// only comment\n{\"ok\":true}").unwrap();
671 assert_eq!(v["ok"], true);
672 let v = parse_jsonc("{\"a\": \"not\\/escaped\"}").unwrap();
673 assert!(v["a"].as_str().is_some());
674 }
675
676 #[test]
677 fn mcp_transport_variants_and_non_object() {
678 let mut skipped = Vec::new();
679 assert!(mcp_from_object("x", &serde_json::json!("nope"), &mut skipped).is_none());
680 let http = serde_json::json!({"url": "https://x", "type": "streamable-http"});
681 assert_eq!(
682 mcp_from_object("h", &http, &mut skipped)
683 .unwrap()
684 .1
685 .transport,
686 Some(McpTransportKind::Http)
687 );
688 let remote = serde_json::json!({"url": "https://x", "type": "remote"});
689 assert_eq!(
690 mcp_from_object("r", &remote, &mut skipped)
691 .unwrap()
692 .1
693 .transport,
694 Some(McpTransportKind::Http)
695 );
696 let stream = serde_json::json!({"url": "https://x", "type": "streamable_http"});
697 assert_eq!(
698 mcp_from_object("s", &stream, &mut skipped)
699 .unwrap()
700 .1
701 .transport,
702 Some(McpTransportKind::Http)
703 );
704 let auto = serde_json::json!({"url": "https://x", "type": "auto"});
705 assert_eq!(
706 mcp_from_object("a", &auto, &mut skipped)
707 .unwrap()
708 .1
709 .transport,
710 Some(McpTransportKind::Auto)
711 );
712 let stdio = serde_json::json!({"command": "npx", "type": "stdio"});
713 assert_eq!(
714 mcp_from_object("st", &stdio, &mut skipped)
715 .unwrap()
716 .1
717 .transport,
718 Some(McpTransportKind::Stdio)
719 );
720 let local = serde_json::json!({"command": "npx", "type": "local"});
721 assert_eq!(
722 mcp_from_object("l", &local, &mut skipped)
723 .unwrap()
724 .1
725 .transport,
726 Some(McpTransportKind::Stdio)
727 );
728 let env = serde_json::json!({
729 "command": "npx",
730 "environment": {"A": "1"},
731 "args": "one"
732 });
733 let cfg = mcp_from_object("e", &env, &mut skipped).unwrap().1;
734 assert_eq!(
735 cfg.env.as_ref().unwrap().get("A").map(String::as_str),
736 Some("1")
737 );
738 assert_eq!(cfg.args, vec!["one"]);
739 let extra = serde_json::json!({"command": ["npx"], "args": ["-y"]});
740 assert_eq!(
741 mcp_from_object("z", &extra, &mut skipped).unwrap().1.args,
742 vec!["-y"]
743 );
744 let url_alias = serde_json::json!({"serverUrl": "https://x", "type": "weird"});
745 assert_eq!(
746 mcp_from_object("u", &url_alias, &mut skipped)
747 .unwrap()
748 .1
749 .transport,
750 Some(McpTransportKind::Auto)
751 );
752 let cmd_only_unknown = serde_json::json!({"command": "npx", "type": "http"});
753 assert_eq!(
754 mcp_from_object("c", &cmd_only_unknown, &mut skipped)
755 .unwrap()
756 .1
757 .transport,
758 None
759 );
760 let mixed = serde_json::json!({"command": ["npx", 1, "-y"], "url": null});
761 let cfg = mcp_from_object("m", &mixed, &mut skipped).unwrap().1;
762 assert_eq!(cfg.command.as_deref(), Some("npx"));
763 assert_eq!(cfg.args, vec!["-y"]);
764 assert!(
765 mcp_from_object(
766 "sse",
767 &serde_json::json!({"type": "sse", "command": "npx"}),
768 &mut skipped
769 )
770 .unwrap()
771 .1
772 .transport
773 != Some(McpTransportKind::Sse)
774 );
775 let deny_then_allow = permission_from_lists(
776 &["Bash".into()],
777 &[],
778 &["Bash".into(), "Bash".into()],
779 &mut skipped,
780 );
781 assert_eq!(deny_then_allow.get("bash"), Some(&PermissionAction::Deny));
782 let allow_then_ask =
783 permission_from_lists(&["Read".into()], &["Read".into()], &[], &mut skipped);
784 assert_eq!(allow_then_ask.get("read"), Some(&PermissionAction::Allow));
785 }
786
787 #[test]
788 fn permission_lists_keep_first_non_deny() {
789 let mut skipped = Vec::new();
790 let lists = permission_from_lists(
791 &["Read".into(), "Bash".into()],
792 &["Read".into()],
793 &[],
794 &mut skipped,
795 );
796 assert_eq!(lists.get("read"), Some(&PermissionAction::Allow));
797 assert_eq!(lists.get("bash"), Some(&PermissionAction::Allow));
798 assert_eq!(map_tool_name("shell"), "bash");
799 assert_eq!(map_tool_name("command"), "bash");
800 assert_eq!(map_tool_name("read_file"), "read");
801 assert_eq!(map_tool_name("view"), "read");
802 assert_eq!(map_tool_name("edit_file"), "edit");
803 assert_eq!(map_tool_name("strreplace"), "edit");
804 assert_eq!(map_tool_name("str_replace"), "edit");
805 assert_eq!(map_tool_name("write"), "edit");
806 assert_eq!(map_tool_name("search"), "grep");
807 assert_eq!(map_tool_name("grep"), "grep");
808 assert_eq!(map_tool_name("find"), "glob");
809 assert_eq!(map_tool_name("glob"), "glob");
810 assert_eq!(map_tool_name("fetch"), "webfetch");
811 assert_eq!(map_tool_name("web_fetch"), "webfetch");
812 assert_eq!(map_tool_name("unknown_tool"), "unknown_tool");
813 assert!(rule_to_key("(").is_none());
814 assert_eq!(map_tool_name("("), "(");
815 let _ = rule_to_key("(").is_none() || map_tool_name("(") == "(";
816 let _ = true || map_tool_name("(") == "(";
817
818 assert_eq!(rule_to_key("Bash(git *)").as_deref(), Some("bash"));
819 assert!(rule_to_key(" ").is_none());
820 let empty_name = rule_to_key("()");
821 assert!(empty_name.is_none());
822 }
823
824 #[test]
825 fn hook_from_command_defaults_and_event_aliases() {
826 let h = hook_from_command(HookEvent::PostTool, "", "echo".into(), true, Some(0));
827 assert_eq!(h.tool_match, "*");
828 assert!(!h.block_on_failure);
829 assert_eq!(h.timeout_secs, 1);
830 let h = hook_from_command(HookEvent::PreTool, "Bash", "echo".into(), true, Some(999));
831 assert_eq!(h.tool_match, "bash");
832 assert!(h.block_on_failure);
833 assert_eq!(h.timeout_secs, 300);
834 assert_eq!(hook_event_from_name("pre-tool"), Some(HookEvent::PreTool));
835 assert_eq!(hook_event_from_name("PreTool"), Some(HookEvent::PreTool));
836 assert_eq!(hook_event_from_name("post-tool"), Some(HookEvent::PostTool));
837 assert_eq!(hook_event_from_name("PostTool"), Some(HookEvent::PostTool));
838 assert_eq!(hook_event_from_name("post_tool"), Some(HookEvent::PostTool));
839 }
840
841 #[test]
842 fn claude_hooks_non_array_group_and_inline_command() {
843 let mut skipped = Vec::new();
844 let non_arr = serde_json::json!({"PreToolUse": {"command": "echo"}});
845 assert!(hooks_from_claude_object(&non_arr, &mut skipped).is_empty());
846 let inline = serde_json::json!({
847 "PostToolUse": [{"matcher": "", "command": "echo after", "timeout": 12}]
848 });
849 let hooks = hooks_from_claude_object(&inline, &mut skipped);
850 assert_eq!(hooks.len(), 1);
851 assert_eq!(hooks[0].event, HookEvent::PostTool);
852 assert_eq!(hooks[0].tool_match, "*");
853 assert_eq!(hooks[0].timeout_secs, 12);
854 let grok_match =
855 serde_json::json!({"pre_tool": {"command": "echo", "match": "edit", "timeout": 9}});
856 let hooks = hooks_from_grok_value(&grok_match, &mut skipped);
857 assert_eq!(hooks.len(), 1);
858 assert_eq!(hooks[0].tool_match, "edit");
859 assert_eq!(hooks[0].timeout_secs, 9);
860 let grok_bad = serde_json::json!({"pre_tool": 1, "Nope": {}});
861 assert!(hooks_from_grok_value(&grok_bad, &mut skipped).is_empty());
862 assert!(skipped.iter().any(|s| s.contains("not mapped")));
863 let toml_arr: toml::Value = toml::from_str("a = [1, \"x\"]\n").unwrap();
864 let v = toml_to_json(toml_arr);
865 assert_eq!(v["a"][0], 1);
866 assert_eq!(string_list(&serde_json::json!("one")), vec!["one"]);
867 assert!(string_map(&serde_json::json!([])).is_none());
868 let line_comment_eof = parse_jsonc("{\"a\":1}// no newline");
869 assert!(line_comment_eof.is_ok());
870 assert!(parse_jsonc("{\"a\":1}/").is_err());
871 let comma_then_eof = parse_jsonc("[1,");
872 assert!(comma_then_eof.is_err());
873
874 assert_eq!(
875 string_list(&serde_json::json!(["a", 1, "b"])),
876 vec!["a", "b"]
877 );
878 let map = mcp_from_map(
879 &serde_json::json!({"ok": {"command": "npx"}, "bad": 1}),
880 &mut skipped,
881 );
882 assert_eq!(map.len(), 1);
883 }
884
885 #[test]
886 fn jsonc_slash_at_eof_and_grok_array_hooks() {
887 assert!(parse_jsonc("{").is_err());
888 let v = parse_jsonc("{\"a\":1}").unwrap();
889 assert_eq!(v["a"], 1);
890 let mut skipped = Vec::new();
891 let grok_arr = serde_json::json!({
892 "pre_tool": [{"command": "echo a"}, {"command": "echo b"}]
893 });
894 assert_eq!(hooks_from_grok_value(&grok_arr, &mut skipped).len(), 2);
895 let grok_timeout_secs = serde_json::json!({
896 "post_tool": {"command": "echo", "timeout_secs": 4}
897 });
898 let hooks = hooks_from_grok_value(&grok_timeout_secs, &mut skipped);
899 assert_eq!(hooks[0].timeout_secs, 4);
900 assert_eq!(map_tool_name("webfetch"), "webfetch");
901 assert_eq!(map_tool_name("websearch"), "websearch");
902 let empty_cmd = serde_json::json!({"command": []});
903 assert!(mcp_from_object("empty", &empty_cmd, &mut skipped).is_none());
904 let no_cmd_no_url = serde_json::json!({"type": "stdio"});
905 assert!(mcp_from_object("none", &no_cmd_no_url, &mut skipped).is_none());
906 assert!(parse_jsonc("/").is_err());
907 assert_eq!(map_tool_name(" "), "");
908 assert!(rule_to_key(" ").is_none());
909 }
910}