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