Skip to main content

fallow_cli/report/
github.rs

1//! Shared infrastructure for the GitHub-native text formats:
2//! `--format github-annotations` (workflow commands on stdout) and
3//! `--format github-summary` (step-summary markdown).
4//!
5//! The escaping contract is byte-compatible with the strict typed fallback in
6//! `action/scripts/annotate.sh`, NOT the legacy jq `san` helper: message
7//! bodies escape `%` to `%25`, CR to `%0D`, and LF to `%0A`; property values
8//! (`file=`, `title=`) additionally escape `,` to `%2C` and `:` to `%3A`.
9//! Non-ASCII text passes through as UTF-8. Line numbers below 1 clamp to 1,
10//! and fallow's 0-based columns convert to GitHub's 1-based workflow-command
11//! columns at this boundary.
12
13use std::path::Path;
14use std::sync::OnceLock;
15
16use serde_json::Value;
17
18/// Escape a workflow-command message body: `%` to `%25`, CR to `%0D`, LF to
19/// `%0A`. The `%` escape runs first so already-escaped input escapes again
20/// (matching the jq `esc` helper's `gsub` order).
21#[must_use]
22fn escape_message(text: &str) -> String {
23    text.replace('%', "%25")
24        .replace('\r', "%0D")
25        .replace('\n', "%0A")
26}
27
28/// Escape a workflow-command property value (`file=`, `title=`): message
29/// escaping plus `,` to `%2C` and `:` to `%3A`, because commas separate
30/// properties and colons terminate the command prefix.
31#[must_use]
32fn escape_property(text: &str) -> String {
33    escape_message(text).replace(',', "%2C").replace(':', "%3A")
34}
35
36/// Clamp a line number below 1 to 1: GitHub rejects `line=0`, and the typed
37/// annotation fallback in `annotate.sh` applies the same floor.
38#[must_use]
39const fn clamp_line(line: u64) -> u64 {
40    if line < 1 { 1 } else { line }
41}
42
43/// Convert fallow's 0-based column to GitHub's 1-based workflow-command
44/// column (the jq layer's `col + 1` convention).
45#[must_use]
46pub(crate) const fn one_based_col(col: u64) -> u64 {
47    col + 1
48}
49
50/// Annotation severity, ordered most-severe-first so a plain sort puts
51/// errors ahead of warnings ahead of notices (GitHub shows at most 10
52/// annotations per type per step; the worst findings must sort into view).
53#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
54pub enum AnnotationLevel {
55    Error,
56    Warning,
57    Notice,
58}
59
60impl AnnotationLevel {
61    /// The workflow-command name (`::error`, `::warning`, `::notice`).
62    #[must_use]
63    const fn command(self) -> &'static str {
64        match self {
65            Self::Error => "error",
66            Self::Warning => "warning",
67            Self::Notice => "notice",
68        }
69    }
70}
71
72/// One GitHub workflow-command annotation, pre-escaping. `message` holds real
73/// newlines; `render_annotation` applies the escaping contract.
74#[derive(Debug)]
75pub struct Annotation {
76    pub(crate) level: AnnotationLevel,
77    /// Repo-root-relative once [`PathRebase::apply`] ran.
78    pub(crate) path: String,
79    /// Omitted from the command when `None`; clamped to 1 when below 1.
80    pub(crate) line: Option<u64>,
81    pub(crate) end_line: Option<u64>,
82    /// Already converted to GitHub's 1-based columns.
83    pub(crate) col: Option<u64>,
84    pub(crate) title: String,
85    pub(crate) message: String,
86    /// Saved audit provenance, used only for new-only annotation ordering.
87    pub(crate) introduced: Option<bool>,
88}
89
90/// Render one annotation as a workflow-command line. Property order matches
91/// the jq layer: `file`, `line`, `endLine`, `col`, `title`.
92#[must_use]
93pub(crate) fn render_annotation(annotation: &Annotation) -> String {
94    use std::fmt::Write as _;
95    let mut props = format!("file={}", escape_property(&annotation.path));
96    if let Some(line) = annotation.line {
97        let _ = write!(props, ",line={}", clamp_line(line));
98    }
99    if let Some(end_line) = annotation.end_line {
100        let _ = write!(props, ",endLine={end_line}");
101    }
102    if let Some(col) = annotation.col {
103        let _ = write!(props, ",col={col}");
104    }
105    format!(
106        "::{} {props},title={}::{}",
107        annotation.level.command(),
108        escape_property(&annotation.title),
109        escape_message(&annotation.message)
110    )
111}
112
113/// Sort annotations most-severe-first, then by path, then by line. Default
114/// result ordering is path-sorted (ADR-004), so without this sort GitHub's
115/// visible 10 per type would be the lexicographically first paths, not the
116/// worst findings.
117pub(crate) fn sort_annotations(annotations: &mut [Annotation]) {
118    annotations.sort_by(|a, b| {
119        a.level
120            .cmp(&b.level)
121            .then_with(|| a.path.cmp(&b.path))
122            .then_with(|| a.line.unwrap_or(0).cmp(&b.line.unwrap_or(0)))
123    });
124}
125
126/// The trailing total notice: always emitted after the annotation stream when
127/// at least one annotation exists, so consumers know when GitHub's 10-per-type
128/// display cap truncated the visible set. There is deliberately no
129/// producer-side cap (the bundled action keeps its own `MAX_ANNOTATIONS`
130/// during migration).
131#[must_use]
132pub(crate) fn budget_notice(total: usize) -> Option<String> {
133    (total > 0).then(|| {
134        let noun = if total == 1 {
135            "annotation"
136        } else {
137            "annotations"
138        };
139        format!(
140            "::notice::fallow emitted {total} {noun}; GitHub shows at most 10 per type per step"
141        )
142    })
143}
144
145/// Package manager for fix-command hints, mirroring the jq layer's
146/// `PKG_MANAGER` parameterization plus native lockfile sniffing (bun is new
147/// relative to the jq layer).
148#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
149pub enum PackageManager {
150    #[default]
151    Npm,
152    Pnpm,
153    Yarn,
154    Bun,
155}
156
157impl PackageManager {
158    /// The `remove <pkg>` command for this package manager.
159    #[must_use]
160    pub(crate) fn remove_command(self, package: &str) -> String {
161        match self {
162            Self::Npm => format!("npm uninstall {package}"),
163            Self::Pnpm => format!("pnpm remove {package}"),
164            Self::Yarn => format!("yarn remove {package}"),
165            Self::Bun => format!("bun remove {package}"),
166        }
167    }
168
169    /// The `add <pkg>` command for this package manager.
170    #[must_use]
171    pub(crate) fn add_command(self, package: &str) -> String {
172        match self {
173            Self::Npm => format!("npm install {package}"),
174            Self::Pnpm => format!("pnpm add {package}"),
175            Self::Yarn => format!("yarn add {package}"),
176            Self::Bun => format!("bun add {package}"),
177        }
178    }
179}
180
181/// Resolve the package manager: an explicit `PKG_MANAGER` env value wins
182/// (action parity; unrecognized values fall back to npm exactly like the jq
183/// `else` branch), otherwise lockfile sniffing at the analysis root.
184#[must_use]
185fn resolve_package_manager(env_value: Option<&str>, root: &Path) -> PackageManager {
186    if let Some(value) = env_value {
187        return match value {
188            "pnpm" => PackageManager::Pnpm,
189            "yarn" => PackageManager::Yarn,
190            "bun" => PackageManager::Bun,
191            _ => PackageManager::Npm,
192        };
193    }
194    if root.join("pnpm-lock.yaml").is_file() {
195        PackageManager::Pnpm
196    } else if root.join("yarn.lock").is_file() {
197        PackageManager::Yarn
198    } else if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
199        PackageManager::Bun
200    } else {
201        PackageManager::Npm
202    }
203}
204
205/// How report paths are rebased onto the git repository root. CI platforms
206/// address files by repo-root-relative path (GitHub annotations, GitLab's
207/// Code Quality widget, the review-discussion APIs), while fallow emits
208/// analysis-root-relative paths; when the analysis root is a subdirectory
209/// (e.g. `packages/app/`), every path needs the offset prefixed.
210#[derive(Clone, PartialEq, Eq, Debug, Default)]
211pub enum PathRebase {
212    /// Paths pass through unchanged (analysis root == repo root, or no git).
213    #[default]
214    None,
215    /// Prefix (no trailing slash) prepended to every emitted path.
216    Prefix(String),
217}
218
219impl PathRebase {
220    /// Apply the rebase to one analysis-root-relative path.
221    #[must_use]
222    pub(crate) fn apply(&self, path: &str) -> String {
223        match self {
224            Self::None => path.to_owned(),
225            Self::Prefix(prefix) => format!("{prefix}/{path}"),
226        }
227    }
228
229    /// The prefix this rebase prepends, empty when it prepends nothing.
230    ///
231    /// Consumers that resolve ownership from an already-rebased path strip
232    /// this back off to recover the analysis-root-relative path CODEOWNERS
233    /// patterns are written against.
234    #[must_use]
235    fn prefix(&self) -> &str {
236        match self {
237            Self::None => "",
238            Self::Prefix(prefix) => prefix,
239        }
240    }
241
242    /// Resolve the rebase: an explicit `--report-path-prefix` wins over
243    /// git-toplevel detection; no git and no flag means paths pass through.
244    /// An explicit empty prefix disables rebasing.
245    #[must_use]
246    fn resolve(root: &Path, explicit: Option<&str>) -> Self {
247        if let Some(prefix) = explicit {
248            return Self::from_explicit_prefix(prefix);
249        }
250        let Some(toplevel) = crate::base_worktree::git_toplevel(root) else {
251            return Self::None;
252        };
253        let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
254        Self::from_root_offset(&canonical_root, &toplevel)
255    }
256
257    fn from_explicit_prefix(prefix: &str) -> Self {
258        let normalized = prefix.replace('\\', "/");
259        let trimmed = normalized.trim_matches('/');
260        if trimmed.is_empty() {
261            Self::None
262        } else {
263            Self::Prefix(trimmed.to_owned())
264        }
265    }
266
267    /// Compute the analysis root's offset below the repo toplevel. Pure so
268    /// tests can pin the rebasing behavior without a live git repo.
269    #[must_use]
270    fn from_root_offset(root: &Path, toplevel: &Path) -> Self {
271        match root.strip_prefix(toplevel) {
272            Ok(offset) if offset.as_os_str().is_empty() => Self::None,
273            Ok(offset) => Self::Prefix(offset.display().to_string().replace('\\', "/")),
274            Err(_) => Self::None,
275        }
276    }
277}
278
279/// Process-wide `--report-path-prefix` override, set once by `main`
280/// after parse (same ambient pattern as the report sink and the
281/// max-file-size override).
282static REPORT_PATH_PREFIX: OnceLock<Option<String>> = OnceLock::new();
283
284/// Record the `--report-path-prefix` flag value. Call at most once.
285pub(crate) fn set_report_path_prefix(prefix: Option<String>) {
286    let _ = REPORT_PATH_PREFIX.set(prefix);
287}
288
289fn report_path_prefix() -> Option<&'static str> {
290    REPORT_PATH_PREFIX
291        .get()
292        .and_then(|prefix| prefix.as_deref())
293}
294
295/// The presentation prefix for this run, resolved once.
296///
297/// `report_rebase` shells out to `git rev-parse --show-toplevel`; the emitters
298/// need the answer on every issue, and the review renderer has no `root` to
299/// re-derive it from.
300static RESOLVED_REPORT_PREFIX: OnceLock<String> = OnceLock::new();
301
302/// Resolve the presentation prefix once, after `--report-path-prefix` is
303/// recorded. Call at most once.
304pub(crate) fn init_report_prefix(root: &Path) {
305    let _ = RESOLVED_REPORT_PREFIX.set(report_rebase(root).prefix().to_owned());
306}
307
308/// The prefix prepended to every CI-facing path emitted this run. Empty when
309/// the analysis root is the repository root, or when no CLI run resolved it.
310#[must_use]
311pub(crate) fn report_prefix() -> &'static str {
312    RESOLVED_REPORT_PREFIX.get().map_or("", String::as_str)
313}
314
315/// Rebase every CI-facing report path emitted for this run onto the repo root.
316///
317/// Shared by the GitHub renderers and the CodeClimate emitters (which the
318/// review and sticky-summary formats derive their paths from), so all CI
319/// surfaces address files the same way the platform does.
320#[must_use]
321fn report_rebase(root: &Path) -> PathRebase {
322    PathRebase::resolve(root, report_path_prefix())
323}
324
325/// Ambient options for the GitHub renderers, resolved once per render at the
326/// CLI print boundary so the pure render functions stay deterministic.
327#[derive(Debug, Default)]
328pub struct RenderOptions {
329    pub rebase: PathRebase,
330    pub pm: PackageManager,
331}
332
333/// Resolve render options from the process environment and the analysis root.
334#[must_use]
335pub(crate) fn resolve_render_options(root: &Path) -> RenderOptions {
336    let env_pm = std::env::var("PKG_MANAGER").ok();
337    RenderOptions {
338        rebase: report_rebase(root),
339        pm: resolve_package_manager(env_pm.as_deref(), root),
340    }
341}
342
343/// Iterate an optional array field (`.[key][]?` in jq terms).
344pub(crate) fn arr<'a>(value: &'a Value, key: &str) -> impl Iterator<Item = &'a Value> {
345    value
346        .get(key)
347        .and_then(Value::as_array)
348        .map(Vec::as_slice)
349        .unwrap_or_default()
350        .iter()
351}
352
353/// String field, or `""` when missing or not a string.
354#[must_use]
355pub(crate) fn s<'a>(value: &'a Value, key: &str) -> &'a str {
356    value.get(key).and_then(Value::as_str).unwrap_or_default()
357}
358
359/// Unsigned number field, or 0 when missing.
360#[must_use]
361pub(crate) fn u(value: &Value, key: &str) -> u64 {
362    value.get(key).and_then(Value::as_u64).unwrap_or_default()
363}
364
365/// Boolean field, or `false` when missing.
366#[must_use]
367pub(crate) fn b(value: &Value, key: &str) -> bool {
368    value.get(key).and_then(Value::as_bool).unwrap_or_default()
369}
370
371/// Format a JSON number the way jq interpolates it: integers without a
372/// decimal point, floats in shortest round-trip form.
373#[must_use]
374pub(crate) fn fmt_num(value: &Value) -> String {
375    if let Some(int) = value.as_i64() {
376        return int.to_string();
377    }
378    value.as_f64().map_or_else(String::new, |float| {
379        if float.fract() == 0.0 && float.abs() < 1e15 {
380            format!("{}", float as i64)
381        } else {
382            format!("{float}")
383        }
384    })
385}
386
387/// Format a numeric field, or `"0"` when missing.
388#[must_use]
389pub(crate) fn num(value: &Value, key: &str) -> String {
390    // jq's `// 0` also replaces an explicit null, so the ports do the same.
391    value
392        .get(key)
393        .filter(|value| !value.is_null())
394        .map_or_else(|| "0".to_owned(), fmt_num)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn escape_property_path_with_comma() {
403        assert_eq!(escape_property("src/a,b.ts"), "src/a%2Cb.ts");
404    }
405
406    #[test]
407    fn escape_property_title_with_colon() {
408        assert_eq!(escape_property("High: complexity"), "High%3A complexity");
409    }
410
411    #[test]
412    fn escape_message_percent() {
413        assert_eq!(escape_message("100% sure"), "100%25 sure");
414    }
415
416    #[test]
417    fn escape_message_crlf_reason() {
418        assert_eq!(escape_message("reason\r\nnext"), "reason%0D%0Anext");
419    }
420
421    #[test]
422    fn escape_message_non_ascii_passes_through_as_utf8() {
423        assert_eq!(escape_message("München.ts"), "München.ts");
424        assert_eq!(escape_property("München.ts"), "München.ts");
425    }
426
427    #[test]
428    fn escape_message_empty_string() {
429        assert_eq!(escape_message(""), "");
430        assert_eq!(escape_property(""), "");
431    }
432
433    #[test]
434    fn escape_message_already_escaped_input_escapes_again() {
435        // Matches jq: `%` escaping runs first, so `%25` becomes `%2525`.
436        assert_eq!(escape_message("50%25 done"), "50%2525 done");
437    }
438
439    #[test]
440    fn escape_message_leaves_comma_and_colon_untouched() {
441        assert_eq!(escape_message("a,b: c"), "a,b: c");
442    }
443
444    #[test]
445    fn escape_property_escapes_all_reserved_characters_together() {
446        assert_eq!(escape_property("a,b:c%d\r\ne"), "a%2Cb%3Ac%25d%0D%0Ae");
447    }
448
449    #[test]
450    fn clamp_line_floors_zero_to_one() {
451        assert_eq!(clamp_line(0), 1);
452    }
453
454    #[test]
455    fn clamp_line_keeps_positive_lines() {
456        assert_eq!(clamp_line(1), 1);
457        assert_eq!(clamp_line(42), 42);
458    }
459
460    #[test]
461    fn one_based_col_converts_zero_based() {
462        assert_eq!(one_based_col(0), 1);
463        assert_eq!(one_based_col(7), 8);
464    }
465
466    fn annotation(level: AnnotationLevel, path: &str, line: Option<u64>) -> Annotation {
467        Annotation {
468            level,
469            path: path.to_owned(),
470            line,
471            end_line: None,
472            col: None,
473            title: "T".to_owned(),
474            message: "m".to_owned(),
475            introduced: None,
476        }
477    }
478
479    #[test]
480    fn render_annotation_property_order_matches_jq_layer() {
481        let rendered = render_annotation(&Annotation {
482            level: AnnotationLevel::Warning,
483            path: "src/a.ts".to_owned(),
484            line: Some(3),
485            end_line: Some(9),
486            col: Some(5),
487            title: "Code duplication".to_owned(),
488            message: "first\nsecond".to_owned(),
489            introduced: None,
490        });
491        assert_eq!(
492            rendered,
493            "::warning file=src/a.ts,line=3,endLine=9,col=5,title=Code duplication::first%0Asecond"
494        );
495    }
496
497    #[test]
498    fn render_annotation_omits_absent_properties_and_clamps_line() {
499        let rendered =
500            render_annotation(&annotation(AnnotationLevel::Error, "pkg/a,b.ts", Some(0)));
501        assert_eq!(rendered, "::error file=pkg/a%2Cb.ts,line=1,title=T::m");
502        let no_line = render_annotation(&annotation(AnnotationLevel::Notice, "a.ts", None));
503        assert_eq!(no_line, "::notice file=a.ts,title=T::m");
504    }
505
506    #[test]
507    fn sort_annotations_orders_severity_then_path_then_line() {
508        let mut annotations = vec![
509            annotation(AnnotationLevel::Notice, "a/a.ts", Some(1)),
510            annotation(AnnotationLevel::Warning, "z/z.ts", Some(9)),
511            annotation(AnnotationLevel::Error, "z/late.ts", Some(2)),
512            annotation(AnnotationLevel::Warning, "a/a.ts", Some(5)),
513            annotation(AnnotationLevel::Warning, "a/a.ts", Some(2)),
514        ];
515        sort_annotations(&mut annotations);
516        let order: Vec<(AnnotationLevel, &str, Option<u64>)> = annotations
517            .iter()
518            .map(|a| (a.level, a.path.as_str(), a.line))
519            .collect();
520        assert_eq!(
521            order,
522            vec![
523                (AnnotationLevel::Error, "z/late.ts", Some(2)),
524                (AnnotationLevel::Warning, "a/a.ts", Some(2)),
525                (AnnotationLevel::Warning, "a/a.ts", Some(5)),
526                (AnnotationLevel::Warning, "z/z.ts", Some(9)),
527                (AnnotationLevel::Notice, "a/a.ts", Some(1)),
528            ]
529        );
530    }
531
532    #[test]
533    fn budget_notice_appears_only_when_annotations_exist() {
534        assert_eq!(budget_notice(0), None);
535        assert_eq!(
536            budget_notice(12).as_deref(),
537            Some(
538                "::notice::fallow emitted 12 annotations; GitHub shows at most 10 per type per step"
539            )
540        );
541    }
542
543    #[test]
544    fn budget_notice_uses_singular_noun_for_one_annotation() {
545        assert_eq!(
546            budget_notice(1).as_deref(),
547            Some(
548                "::notice::fallow emitted 1 annotation; GitHub shows at most 10 per type per step"
549            )
550        );
551    }
552
553    #[test]
554    fn package_manager_env_wins_and_unrecognized_falls_back_to_npm() {
555        let dir = tempfile::tempdir().expect("tempdir");
556        std::fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("write");
557        assert_eq!(
558            resolve_package_manager(Some("yarn"), dir.path()),
559            PackageManager::Yarn
560        );
561        assert_eq!(
562            resolve_package_manager(Some("something-else"), dir.path()),
563            PackageManager::Npm
564        );
565    }
566
567    #[test]
568    fn package_manager_lockfile_sniffing_covers_bun() {
569        let dir = tempfile::tempdir().expect("tempdir");
570        assert_eq!(
571            resolve_package_manager(None, dir.path()),
572            PackageManager::Npm
573        );
574        std::fs::write(dir.path().join("bun.lockb"), "").expect("write");
575        assert_eq!(
576            resolve_package_manager(None, dir.path()),
577            PackageManager::Bun
578        );
579        std::fs::write(dir.path().join("bun.lock"), "").expect("write");
580        assert_eq!(
581            resolve_package_manager(None, dir.path()),
582            PackageManager::Bun
583        );
584        std::fs::write(dir.path().join("yarn.lock"), "").expect("write");
585        assert_eq!(
586            resolve_package_manager(None, dir.path()),
587            PackageManager::Yarn
588        );
589        std::fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("write");
590        assert_eq!(
591            resolve_package_manager(None, dir.path()),
592            PackageManager::Pnpm
593        );
594    }
595
596    #[test]
597    fn package_manager_commands() {
598        assert_eq!(PackageManager::Npm.remove_command("x"), "npm uninstall x");
599        assert_eq!(PackageManager::Pnpm.remove_command("x"), "pnpm remove x");
600        assert_eq!(PackageManager::Yarn.add_command("x"), "yarn add x");
601        assert_eq!(PackageManager::Bun.add_command("x"), "bun add x");
602        assert_eq!(PackageManager::Bun.remove_command("x"), "bun remove x");
603    }
604
605    #[test]
606    fn path_rebase_from_root_offset_prefixes_subdirectory_roots() {
607        use std::path::Path;
608        let rebase =
609            PathRebase::from_root_offset(Path::new("/repo/packages/app"), Path::new("/repo"));
610        assert_eq!(rebase, PathRebase::Prefix("packages/app".to_owned()));
611        assert_eq!(rebase.apply("src/a.ts"), "packages/app/src/a.ts");
612    }
613
614    #[test]
615    fn path_rebase_is_none_at_repo_root_or_outside_toplevel() {
616        use std::path::Path;
617        assert_eq!(
618            PathRebase::from_root_offset(Path::new("/repo"), Path::new("/repo")),
619            PathRebase::None
620        );
621        assert_eq!(
622            PathRebase::from_root_offset(Path::new("/elsewhere"), Path::new("/repo")),
623            PathRebase::None
624        );
625        assert_eq!(PathRebase::None.apply("src/a.ts"), "src/a.ts");
626    }
627
628    #[test]
629    fn path_rebase_explicit_prefix_wins_and_is_trimmed() {
630        use std::path::Path;
631        let rebase = PathRebase::resolve(Path::new("/nonexistent-fallow-root"), Some("/pkg/app/"));
632        assert_eq!(rebase, PathRebase::Prefix("pkg/app".to_owned()));
633        let empty = PathRebase::resolve(Path::new("/nonexistent-fallow-root"), Some("//"));
634        assert_eq!(empty, PathRebase::None);
635    }
636
637    #[test]
638    fn fmt_num_matches_jq_interpolation() {
639        assert_eq!(fmt_num(&serde_json::json!(42)), "42");
640        assert_eq!(fmt_num(&serde_json::json!(24.0)), "24");
641        assert_eq!(fmt_num(&serde_json::json!(30.5)), "30.5");
642        assert_eq!(fmt_num(&serde_json::json!(-3)), "-3");
643    }
644
645    #[test]
646    fn value_helpers_default_missing_fields() {
647        let value = serde_json::json!({"name": "x", "line": 4, "flag": true, "items": [1]});
648        assert_eq!(s(&value, "name"), "x");
649        assert_eq!(s(&value, "missing"), "");
650        assert_eq!(u(&value, "line"), 4);
651        assert_eq!(u(&value, "missing"), 0);
652        assert!(b(&value, "flag"));
653        assert!(!b(&value, "missing"));
654        assert_eq!(arr(&value, "items").count(), 1);
655        assert_eq!(arr(&value, "missing").count(), 0);
656        assert_eq!(num(&value, "line"), "4");
657        assert_eq!(num(&value, "missing"), "0");
658    }
659}