Skip to main content

whycodes_import/
parse.rs

1//! Shared parsers for foreign JSON / JSONC / TOML settings.
2
3use 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
11/// Strip `//` and `/* */` comments plus trailing commas so JSONC parses as JSON.
12pub 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
107/// Map a generic MCP object (Claude / Cursor / VS Code style `mcpServers`).
108pub 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
193/// OpenCode-style `{ "bash": "ask", "edit": "allow" }`.
194pub 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
235/// Claude / Grok allow-ask-deny lists (`["Bash", "Read"]` or `"Bash(git *)"`).
236pub 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                    // Deny wins if the same tool appears in multiple lists.
252                    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        // Claude Code adds SessionStart / UserPromptSubmit stdout to context.
331        feedback: matches!(event, HookEvent::SessionStart | HookEvent::UserPromptSubmit),
332        timeout_secs: timeout_secs.unwrap_or(30).clamp(1, 300),
333    }
334}
335
336/// Claude `settings.json` `hooks` object: `{ "PreToolUse": [ { matcher, hooks: [{type,command}] } ] }`.
337pub 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
387/// Grok `[hooks.<event>]` TOML tables (or JSON object of the same shape).
388pub 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}