Skip to main content

stryke/
builtins_github.rs

1//! GitHub REST API primitives — pragmatic wrappers around `api.github.com`.
2//!
3//! Designed for parallel-map workflows:
4//! ```stryke
5//! my @repos = gh_repos("MenkeTechnologies")
6//! my @stars = pmap { gh_repo($_->{full_name})->{stargazers_count} } @repos
7//! ```
8//!
9//! All builtins authenticate via the `GITHUB_TOKEN` environment variable
10//! when present (5000 req/hour); otherwise fall back to unauthenticated
11//! access (60 req/hour). List endpoints auto-paginate up to a safety cap
12//! (`GH_MAX_PAGES`, default 10 = up to 1000 items at per_page=100).
13//!
14//! Builtins:
15//!   gh_get(PATH, [opts])              — generic GET, parsed JSON
16//!   gh_user(USER)                     — /users/USER
17//!   gh_org(ORG)                       — /orgs/ORG
18//!   gh_repo(OWNER, REPO)              — /repos/OWNER/REPO
19//!   gh_repos(USER)                    — /users/USER/repos          (paginated)
20//!   gh_org_repos(ORG)                 — /orgs/ORG/repos            (paginated)
21//!   gh_starred(USER)                  — /users/USER/starred        (paginated)
22//!   gh_followers(USER)                — /users/USER/followers      (paginated)
23//!   gh_following(USER)                — /users/USER/following      (paginated)
24//!   gh_gists(USER)                    — /users/USER/gists          (paginated)
25//!   gh_gist(ID)                       — /gists/ID
26//!   gh_issues(OWNER, REPO)            — /repos/OWNER/REPO/issues   (paginated)
27//!   gh_prs(OWNER, REPO)               — /repos/OWNER/REPO/pulls    (paginated)
28//!   gh_commits(OWNER, REPO)           — /repos/OWNER/REPO/commits  (paginated)
29//!   gh_branches(OWNER, REPO)          — /repos/OWNER/REPO/branches (paginated)
30//!   gh_tags(OWNER, REPO)              — /repos/OWNER/REPO/tags     (paginated)
31//!   gh_releases(OWNER, REPO)          — /repos/OWNER/REPO/releases (paginated)
32//!   gh_contributors(OWNER, REPO)      — /repos/OWNER/REPO/contributors (paginated)
33//!   gh_forks(OWNER, REPO)             — /repos/OWNER/REPO/forks    (paginated)
34//!   gh_stargazers(OWNER, REPO)        — /repos/OWNER/REPO/stargazers (paginated)
35//!   gh_topics(OWNER, REPO)            — array of topic names
36//!   gh_languages(OWNER, REPO)         — { language => bytes } hashref
37//!   gh_readme(OWNER, REPO)            — decoded README content (string)
38//!   gh_workflows(OWNER, REPO)         — workflows array
39//!   gh_runs(OWNER, REPO)              — workflow runs array
40//!   gh_search_repos(QUERY)            — /search/repositories       (paginated)
41//!   gh_search_users(QUERY)            — /search/users              (paginated)
42//!   gh_search_code(QUERY)             — /search/code               (paginated)
43//!   gh_search_issues(QUERY)           — /search/issues             (paginated)
44//!   gh_rate_limit()                   — /rate_limit
45//!   gh_meta()                         — /meta
46//!   gh_zen()                          — /zen (plain-text string)
47//!   gh_emojis()                       — /emojis (hashref)
48//!
49//! Errors: network / 4xx / 5xx → runtime error. 404 returns `undef` so callers
50//! can `pmap { gh_repo(...) }` over a list including dead names without
51//! aborting the whole pipeline.
52
53use crate::error::{StrykeError, StrykeResult};
54use crate::value::StrykeValue;
55use indexmap::IndexMap;
56use parking_lot::RwLock;
57use std::sync::Arc;
58use std::time::Duration;
59
60// ── helpers ────────────────────────────────────────────────────────────
61
62const API_ROOT: &str = "https://api.github.com";
63const USER_AGENT: &str = "strykelang-gh-builtins";
64const DEFAULT_MAX_PAGES: usize = 10;
65
66fn arg_str(args: &[StrykeValue], i: usize) -> String {
67    args.get(i).map(|v| v.to_string()).unwrap_or_default()
68}
69
70fn json_to_perl(v: serde_json::Value) -> StrykeValue {
71    match v {
72        serde_json::Value::Null => StrykeValue::UNDEF,
73        serde_json::Value::Bool(b) => StrykeValue::integer(i64::from(b)),
74        serde_json::Value::Number(n) => {
75            if let Some(i) = n.as_i64() {
76                StrykeValue::integer(i)
77            } else if let Some(u) = n.as_u64() {
78                StrykeValue::integer(u as i64)
79            } else {
80                StrykeValue::float(n.as_f64().unwrap_or(0.0))
81            }
82        }
83        serde_json::Value::String(s) => StrykeValue::string(s),
84        serde_json::Value::Array(a) => StrykeValue::array_ref(Arc::new(RwLock::new(
85            a.into_iter().map(json_to_perl).collect(),
86        ))),
87        serde_json::Value::Object(o) => {
88            let mut map = IndexMap::new();
89            for (k, v) in o {
90                map.insert(k, json_to_perl(v));
91            }
92            StrykeValue::hash_ref(Arc::new(RwLock::new(map)))
93        }
94    }
95}
96
97fn agent() -> ureq::Agent {
98    ureq::AgentBuilder::new()
99        .timeout(Duration::from_secs(30))
100        .build()
101}
102
103fn prepare_request(req: ureq::Request) -> ureq::Request {
104    let req = req
105        .set("Accept", "application/vnd.github+json")
106        .set("User-Agent", USER_AGENT)
107        .set("X-GitHub-Api-Version", "2022-11-28");
108    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
109        if !token.is_empty() {
110            return req.set("Authorization", &format!("Bearer {}", token));
111        }
112    }
113    req
114}
115
116fn build_url(path: &str) -> String {
117    if path.starts_with("http://") || path.starts_with("https://") {
118        path.to_string()
119    } else if let Some(rest) = path.strip_prefix('/') {
120        format!("{}/{}", API_ROOT, rest)
121    } else {
122        format!("{}/{}", API_ROOT, path)
123    }
124}
125
126fn max_pages() -> usize {
127    std::env::var("GH_MAX_PAGES")
128        .ok()
129        .and_then(|s| s.parse::<usize>().ok())
130        .filter(|n| *n > 0)
131        .unwrap_or(DEFAULT_MAX_PAGES)
132}
133
134fn http_get_json(url: &str) -> StrykeResult<Option<serde_json::Value>> {
135    let req = prepare_request(agent().get(url));
136    match req.call() {
137        Ok(resp) => {
138            let body = resp
139                .into_string()
140                .map_err(|e| StrykeError::runtime(format!("gh: read body: {}", e), 0))?;
141            if body.is_empty() {
142                return Ok(Some(serde_json::Value::Null));
143            }
144            let v: serde_json::Value = serde_json::from_str(&body)
145                .map_err(|e| StrykeError::runtime(format!("gh: parse json: {}", e), 0))?;
146            Ok(Some(v))
147        }
148        Err(ureq::Error::Status(404, _)) => Ok(None),
149        Err(ureq::Error::Status(code, resp)) => {
150            let body = resp.into_string().unwrap_or_default();
151            let snippet = body.chars().take(200).collect::<String>();
152            Err(StrykeError::runtime(
153                format!("gh: HTTP {}: {}", code, snippet),
154                0,
155            ))
156        }
157        Err(e) => Err(StrykeError::runtime(format!("gh: {}", e), 0)),
158    }
159}
160
161fn http_get_text(url: &str) -> StrykeResult<Option<String>> {
162    let req = prepare_request(agent().get(url));
163    match req.call() {
164        Ok(resp) => resp
165            .into_string()
166            .map(Some)
167            .map_err(|e| StrykeError::runtime(format!("gh: read body: {}", e), 0)),
168        Err(ureq::Error::Status(404, _)) => Ok(None),
169        Err(ureq::Error::Status(code, resp)) => {
170            let body = resp.into_string().unwrap_or_default();
171            let snippet = body.chars().take(200).collect::<String>();
172            Err(StrykeError::runtime(
173                format!("gh: HTTP {}: {}", code, snippet),
174                0,
175            ))
176        }
177        Err(e) => Err(StrykeError::runtime(format!("gh: {}", e), 0)),
178    }
179}
180
181/// Fetch a single endpoint and return the parsed JSON as a StrykeValue.
182/// 404 → undef.
183fn single(path: &str) -> StrykeResult<StrykeValue> {
184    let url = build_url(path);
185    match http_get_json(&url)? {
186        Some(v) => Ok(json_to_perl(v)),
187        None => Ok(StrykeValue::UNDEF),
188    }
189}
190
191/// Fetch a paginated list endpoint, concatenating page results into a
192/// single flat list (Perl list context — `my @r = gh_repos(...)` works).
193/// Stops on first empty/short page or hitting `max_pages`. 404 → empty list.
194fn paginated(path: &str) -> StrykeResult<StrykeValue> {
195    let per_page = 100usize;
196    let cap = max_pages();
197    let mut all: Vec<StrykeValue> = Vec::new();
198    let join = if path.contains('?') { '&' } else { '?' };
199    for page in 1..=cap {
200        let url = build_url(&format!(
201            "{}{}per_page={}&page={}",
202            path, join, per_page, page
203        ));
204        let Some(v) = http_get_json(&url)? else {
205            break;
206        };
207        match v {
208            serde_json::Value::Array(items) => {
209                let n = items.len();
210                all.extend(items.into_iter().map(json_to_perl));
211                if n < per_page {
212                    break;
213                }
214            }
215            // Search endpoints wrap results in { items: [...], total_count }
216            serde_json::Value::Object(ref o) if o.contains_key("items") => {
217                let Some(serde_json::Value::Array(items)) = o.get("items").cloned() else {
218                    break;
219                };
220                let n = items.len();
221                all.extend(items.into_iter().map(json_to_perl));
222                if n < per_page {
223                    break;
224                }
225            }
226            _ => break,
227        }
228    }
229    Ok(StrykeValue::array(all))
230}
231
232fn url_encode(s: &str) -> String {
233    let mut out = String::with_capacity(s.len());
234    for b in s.bytes() {
235        match b {
236            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
237                out.push(b as char);
238            }
239            _ => out.push_str(&format!("%{:02X}", b)),
240        }
241    }
242    out
243}
244
245// ── generic ────────────────────────────────────────────────────────────
246
247/// `gh_get(PATH)` — GET an arbitrary GitHub REST endpoint. `PATH` can be
248/// a relative path (`/users/MenkeTechnologies`) or a full URL. Returns
249/// parsed JSON as a stryke value; 404 → `undef`.
250pub fn gh_get(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
251    single(&arg_str(args, 0))
252}
253
254// ── user / org ─────────────────────────────────────────────────────────
255/// `gh_user` — see implementation.
256pub fn gh_user(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
257    single(&format!("/users/{}", arg_str(args, 0)))
258}
259/// `gh_org` — see implementation.
260pub fn gh_org(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
261    single(&format!("/orgs/{}", arg_str(args, 0)))
262}
263/// `gh_followers` — see implementation.
264pub fn gh_followers(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
265    paginated(&format!("/users/{}/followers", arg_str(args, 0)))
266}
267/// `gh_following` — see implementation.
268pub fn gh_following(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
269    paginated(&format!("/users/{}/following", arg_str(args, 0)))
270}
271
272// ── repos ──────────────────────────────────────────────────────────────
273/// `gh_repo` — see implementation.
274pub fn gh_repo(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
275    let s = arg_str(args, 0);
276    // Accept either `gh_repo("owner/repo")` or `gh_repo("owner", "repo")`
277    let path = if let Some((owner, repo)) = s.split_once('/') {
278        format!("/repos/{}/{}", owner, repo)
279    } else {
280        format!("/repos/{}/{}", s, arg_str(args, 1))
281    };
282    single(&path)
283}
284/// `gh_repos` — see implementation.
285pub fn gh_repos(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
286    paginated(&format!("/users/{}/repos", arg_str(args, 0)))
287}
288/// `gh_org_repos` — see implementation.
289pub fn gh_org_repos(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
290    paginated(&format!("/orgs/{}/repos", arg_str(args, 0)))
291}
292/// `gh_starred` — see implementation.
293pub fn gh_starred(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
294    paginated(&format!("/users/{}/starred", arg_str(args, 0)))
295}
296
297// ── gists ──────────────────────────────────────────────────────────────
298/// `gh_gists` — see implementation.
299pub fn gh_gists(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
300    paginated(&format!("/users/{}/gists", arg_str(args, 0)))
301}
302/// `gh_gist` — see implementation.
303pub fn gh_gist(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
304    single(&format!("/gists/{}", arg_str(args, 0)))
305}
306
307// ── repo-scoped collections ────────────────────────────────────────────
308
309fn split_owner_repo(args: &[StrykeValue]) -> (String, String) {
310    let a = arg_str(args, 0);
311    if let Some((o, r)) = a.split_once('/') {
312        (o.to_string(), r.to_string())
313    } else {
314        (a, arg_str(args, 1))
315    }
316}
317/// `gh_issues` — see implementation.
318pub fn gh_issues(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
319    let (o, r) = split_owner_repo(args);
320    paginated(&format!("/repos/{}/{}/issues", o, r))
321}
322/// `gh_prs` — see implementation.
323pub fn gh_prs(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
324    let (o, r) = split_owner_repo(args);
325    paginated(&format!("/repos/{}/{}/pulls", o, r))
326}
327/// `gh_commits` — see implementation.
328pub fn gh_commits(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
329    let (o, r) = split_owner_repo(args);
330    paginated(&format!("/repos/{}/{}/commits", o, r))
331}
332/// `gh_branches` — see implementation.
333pub fn gh_branches(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
334    let (o, r) = split_owner_repo(args);
335    paginated(&format!("/repos/{}/{}/branches", o, r))
336}
337/// `gh_tags` — see implementation.
338pub fn gh_tags(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
339    let (o, r) = split_owner_repo(args);
340    paginated(&format!("/repos/{}/{}/tags", o, r))
341}
342/// `gh_releases` — see implementation.
343pub fn gh_releases(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
344    let (o, r) = split_owner_repo(args);
345    paginated(&format!("/repos/{}/{}/releases", o, r))
346}
347/// `gh_contributors` — see implementation.
348pub fn gh_contributors(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
349    let (o, r) = split_owner_repo(args);
350    paginated(&format!("/repos/{}/{}/contributors", o, r))
351}
352/// `gh_forks` — see implementation.
353pub fn gh_forks(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
354    let (o, r) = split_owner_repo(args);
355    paginated(&format!("/repos/{}/{}/forks", o, r))
356}
357/// `gh_stargazers` — see implementation.
358pub fn gh_stargazers(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
359    let (o, r) = split_owner_repo(args);
360    paginated(&format!("/repos/{}/{}/stargazers", o, r))
361}
362/// `gh_workflows` — see implementation.
363pub fn gh_workflows(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
364    let (o, r) = split_owner_repo(args);
365    match single(&format!("/repos/{}/{}/actions/workflows", o, r))? {
366        v if v.is_undef() => Ok(StrykeValue::array_ref(Arc::new(RwLock::new(vec![])))),
367        v => {
368            let ws = v
369                .as_hash_ref()
370                .and_then(|h| h.read().get("workflows").cloned())
371                .unwrap_or(StrykeValue::UNDEF);
372            Ok(ws)
373        }
374    }
375}
376/// `gh_runs` — see implementation.
377pub fn gh_runs(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
378    let (o, r) = split_owner_repo(args);
379    match single(&format!("/repos/{}/{}/actions/runs", o, r))? {
380        v if v.is_undef() => Ok(StrykeValue::array_ref(Arc::new(RwLock::new(vec![])))),
381        v => {
382            let runs = v
383                .as_hash_ref()
384                .and_then(|h| h.read().get("workflow_runs").cloned())
385                .unwrap_or(StrykeValue::UNDEF);
386            Ok(runs)
387        }
388    }
389}
390
391/// `gh_topics(OWNER, REPO)` — returns an arrayref of topic name strings.
392pub fn gh_topics(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
393    let (o, r) = split_owner_repo(args);
394    let v = single(&format!("/repos/{}/{}/topics", o, r))?;
395    if v.is_undef() {
396        return Ok(StrykeValue::array_ref(Arc::new(RwLock::new(vec![]))));
397    }
398    let names = v
399        .as_hash_ref()
400        .and_then(|h| h.read().get("names").cloned())
401        .unwrap_or(StrykeValue::UNDEF);
402    Ok(names)
403}
404
405/// `gh_languages(OWNER, REPO)` — `{ language => bytes }` hashref.
406pub fn gh_languages(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
407    let (o, r) = split_owner_repo(args);
408    single(&format!("/repos/{}/{}/languages", o, r))
409}
410
411/// `gh_readme(OWNER, REPO)` — base64-decoded README content as a UTF-8 string.
412pub fn gh_readme(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
413    let (o, r) = split_owner_repo(args);
414    let v = single(&format!("/repos/{}/{}/readme", o, r))?;
415    if v.is_undef() {
416        return Ok(StrykeValue::UNDEF);
417    }
418    let h = match v.as_hash_ref() {
419        Some(h) => h,
420        None => return Ok(StrykeValue::UNDEF),
421    };
422    let guard = h.read();
423    let encoding = guard
424        .get("encoding")
425        .map(|v| v.to_string())
426        .unwrap_or_default();
427    let content = guard
428        .get("content")
429        .map(|v| v.to_string())
430        .unwrap_or_default();
431    drop(guard);
432    if encoding == "base64" {
433        let cleaned: String = content.chars().filter(|c| !c.is_whitespace()).collect();
434        use base64::Engine;
435        match base64::engine::general_purpose::STANDARD.decode(cleaned.as_bytes()) {
436            Ok(bytes) => Ok(StrykeValue::string(
437                String::from_utf8_lossy(&bytes).into_owned(),
438            )),
439            Err(_) => Ok(StrykeValue::string(content)),
440        }
441    } else {
442        Ok(StrykeValue::string(content))
443    }
444}
445
446// ── search ─────────────────────────────────────────────────────────────
447/// `gh_search_repos` — see implementation.
448pub fn gh_search_repos(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
449    let q = url_encode(&arg_str(args, 0));
450    paginated(&format!("/search/repositories?q={}", q))
451}
452/// `gh_search_users` — see implementation.
453pub fn gh_search_users(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
454    let q = url_encode(&arg_str(args, 0));
455    paginated(&format!("/search/users?q={}", q))
456}
457/// `gh_search_code` — see implementation.
458pub fn gh_search_code(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
459    let q = url_encode(&arg_str(args, 0));
460    paginated(&format!("/search/code?q={}", q))
461}
462/// `gh_search_issues` — see implementation.
463pub fn gh_search_issues(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
464    let q = url_encode(&arg_str(args, 0));
465    paginated(&format!("/search/issues?q={}", q))
466}
467
468// ── meta ───────────────────────────────────────────────────────────────
469/// `gh_rate_limit` — see implementation.
470pub fn gh_rate_limit(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
471    single("/rate_limit")
472}
473/// `gh_meta` — see implementation.
474pub fn gh_meta(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
475    single("/meta")
476}
477/// `gh_emojis` — see implementation.
478pub fn gh_emojis(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
479    single("/emojis")
480}
481
482/// `gh_zen()` — GitHub's "zen" endpoint. Returns a plain-text string,
483/// not JSON.
484pub fn gh_zen(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
485    let url = build_url("/zen");
486    match http_get_text(&url)? {
487        Some(s) => Ok(StrykeValue::string(s)),
488        None => Ok(StrykeValue::UNDEF),
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    // ─── build_url ───────────────────────────────────────────────────────
497
498    #[test]
499    fn build_url_prepends_api_root_for_leading_slash() {
500        assert_eq!(build_url("/users/foo"), format!("{}/users/foo", API_ROOT));
501    }
502
503    #[test]
504    fn build_url_prepends_api_root_for_bare_path() {
505        assert_eq!(build_url("users/foo"), format!("{}/users/foo", API_ROOT));
506    }
507
508    #[test]
509    fn build_url_passes_through_absolute_https() {
510        let abs = "https://example.com/x";
511        assert_eq!(build_url(abs), abs);
512    }
513
514    #[test]
515    fn build_url_passes_through_absolute_http() {
516        let abs = "http://example.com/x";
517        assert_eq!(build_url(abs), abs);
518    }
519
520    // ─── url_encode ──────────────────────────────────────────────────────
521
522    #[test]
523    fn url_encode_preserves_unreserved_chars() {
524        // RFC 3986 unreserved set: ALPHA / DIGIT / "-" / "_" / "." / "~"
525        assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~");
526    }
527
528    #[test]
529    fn url_encode_percent_encodes_space() {
530        assert_eq!(url_encode("a b"), "a%20b");
531    }
532
533    #[test]
534    fn url_encode_percent_encodes_slash_and_question() {
535        assert_eq!(url_encode("a/b?c"), "a%2Fb%3Fc");
536    }
537
538    #[test]
539    fn url_encode_multibyte_utf8_per_byte() {
540        // é is U+00E9 → UTF-8 bytes C3 A9
541        assert_eq!(url_encode("é"), "%C3%A9");
542    }
543
544    #[test]
545    fn url_encode_empty_is_empty() {
546        assert_eq!(url_encode(""), "");
547    }
548
549    // ─── split_owner_repo ────────────────────────────────────────────────
550
551    #[test]
552    fn split_owner_repo_single_arg_with_slash() {
553        let (o, r) = split_owner_repo(&[StrykeValue::string("MenkeTechnologies/zpwr".into())]);
554        assert_eq!(o, "MenkeTechnologies");
555        assert_eq!(r, "zpwr");
556    }
557
558    #[test]
559    fn split_owner_repo_two_args_no_slash() {
560        let (o, r) = split_owner_repo(&[
561            StrykeValue::string("Owner".into()),
562            StrykeValue::string("Repo".into()),
563        ]);
564        assert_eq!(o, "Owner");
565        assert_eq!(r, "Repo");
566    }
567
568    #[test]
569    fn split_owner_repo_missing_repo_returns_empty_string() {
570        // arg_str returns "" when arg missing → repo half is empty.
571        let (o, r) = split_owner_repo(&[StrykeValue::string("only_owner".into())]);
572        assert_eq!(o, "only_owner");
573        assert_eq!(r, "");
574    }
575
576    // ─── json_to_perl ────────────────────────────────────────────────────
577
578    #[test]
579    fn json_to_perl_null_becomes_undef() {
580        assert!(json_to_perl(serde_json::Value::Null).is_undef());
581    }
582
583    #[test]
584    fn json_to_perl_bool_maps_to_one_or_zero() {
585        assert_eq!(json_to_perl(serde_json::json!(true)).to_int(), 1);
586        assert_eq!(json_to_perl(serde_json::json!(false)).to_int(), 0);
587    }
588
589    #[test]
590    fn json_to_perl_object_round_trip_keys_preserved() {
591        let v = json_to_perl(serde_json::json!({"k": 1, "name": "x"}));
592        let h = v.as_hash_ref().expect("hash_ref");
593        let g = h.read();
594        assert_eq!(g.get("k").unwrap().to_int(), 1);
595        assert_eq!(g.get("name").unwrap().to_string(), "x");
596    }
597
598    #[test]
599    fn json_to_perl_array_length_preserved() {
600        let v = json_to_perl(serde_json::json!([1, 2, 3, 4]));
601        let arr = v.as_array_ref().unwrap();
602        assert_eq!(arr.read().len(), 4);
603    }
604
605    // ─── arg_str ─────────────────────────────────────────────────────────
606
607    #[test]
608    fn arg_str_missing_index_returns_empty_string() {
609        assert_eq!(arg_str(&[], 0), "");
610        assert_eq!(arg_str(&[StrykeValue::string("x".into())], 5), "");
611    }
612}