Skip to main content

retch_cli/
fields.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Single source of truth for the set of displayable fields and their output strata.
5//!
6//! Historically the field list was hand-duplicated across `main.rs` (collection
7//! allow-lists *and* the generated config template), `display.rs` (display
8//! allow-lists), `config.rs` (`DEFAULT_FIELDS_BLOCK`), plus `README.md` and
9//! `docs/retch.1.md`. Every copy was a raw list of `&str` literals with no shared
10//! definition, so adding or renaming a field risked silent drift — a field could
11//! be collected but never displayed (or vice versa), or documented inconsistently.
12//!
13//! This module replaces the in-code copies with one [`FIELDS`] table. `main.rs`
14//! and `display.rs` derive their per-strata allow-lists from [`fields_for`], and
15//! both config-generation paths derive the commented `fields = [...]` block from
16//! [`config_fields_block`]. The documentation copies (`README.md`,
17//! `docs/retch.1.md`) can't be generated from Rust, so a guardrail test in
18//! `tests/cli_tests.rs` asserts every [`FIELDS`] key appears in both, turning
19//! future drift into a test failure instead of a silent bug.
20
21/// Output verbosity mode, ordered from least to most verbose.
22///
23/// Each mode is a strict superset of the one before it (see NOTES.md §4), so a
24/// field can be described by the single least-verbose mode in which it appears.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum Mode {
27    /// `--short`: fast hardware-only snapshot.
28    Short,
29    /// Default (no flag): daily-use system overview.
30    Standard,
31    /// `--long`: diagnostics — firmware, network detail, consolidated thermals.
32    Long,
33    /// `--full`: everything, including slow and cosmetic fields.
34    Full,
35}
36
37/// A single displayable field: its canonical config/CLI key and the least-verbose
38/// [`Mode`] in which it is shown.
39///
40/// The `key` is the canonical hyphenated form (e.g. `"phys-mem"`, `"terminal-font"`)
41/// as accepted by the `fields` config key and `--fields`. Field-name matching in
42/// the collection and display layers normalizes `-`/`_`/spaces, so only the
43/// canonical form needs to live here.
44struct FieldDef {
45    /// Canonical field key (hyphenated).
46    key: &'static str,
47    /// Least-verbose mode in which the field appears.
48    min_mode: Mode,
49}
50
51/// The authoritative field table.
52///
53/// Ordered for a sensible generated config comment; ordering has no effect on
54/// collection or display (both are membership tests — display order is fixed by
55/// the `print_line` call sequence in `display.rs`). To add a field, add one row
56/// here and wire its `print_line`/collector; the strata allow-lists and config
57/// template update automatically.
58const FIELDS: &[FieldDef] = &[
59    // --- Standard identity/OS (Short subset marked below) ---
60    FieldDef {
61        key: "os",
62        min_mode: Mode::Short,
63    },
64    FieldDef {
65        key: "kernel",
66        min_mode: Mode::Short,
67    },
68    FieldDef {
69        key: "host",
70        min_mode: Mode::Short,
71    },
72    FieldDef {
73        key: "domain",
74        min_mode: Mode::Long,
75    },
76    FieldDef {
77        key: "domain-search",
78        min_mode: Mode::Full,
79    },
80    FieldDef {
81        key: "chassis",
82        min_mode: Mode::Long,
83    },
84    FieldDef {
85        key: "init",
86        min_mode: Mode::Long,
87    },
88    FieldDef {
89        key: "locale",
90        min_mode: Mode::Long,
91    },
92    FieldDef {
93        key: "arch",
94        min_mode: Mode::Long,
95    },
96    // --- CPU ---
97    FieldDef {
98        key: "cpu",
99        min_mode: Mode::Short,
100    },
101    FieldDef {
102        key: "cpu-freq",
103        min_mode: Mode::Long,
104    },
105    FieldDef {
106        key: "cpu-cache",
107        min_mode: Mode::Standard,
108    },
109    // Long, not Standard: on Linux and macOS a usage figure needs two samples at least
110    // 200 ms apart (sysinfo's minimum refresh interval), and nothing else in the default
111    // mode takes that long -- it was ~80% of the default mode's runtime, and the reason
112    // retch was slower than fastfetch there (fastfetch's own default omits CPU usage).
113    FieldDef {
114        key: "cpu-usage",
115        min_mode: Mode::Long,
116    },
117    // --- Graphics / firmware / peripherals ---
118    FieldDef {
119        key: "gpu",
120        min_mode: Mode::Short,
121    },
122    // Graphics/compute API versions. Full-mode: each opens a driver stack and, for
123    // OpenGL, creates a context — measured at ~100 ms combined on this hardware, which
124    // is too much to put in --long while NOTES.md §3 treats slower-than-fastfetch as
125    // blocking. See the v0.11.6 release entry for the measurement.
126    FieldDef {
127        key: "vulkan",
128        min_mode: Mode::Full,
129    },
130    FieldDef {
131        key: "opengl",
132        min_mode: Mode::Full,
133    },
134    FieldDef {
135        key: "opencl",
136        min_mode: Mode::Full,
137    },
138    FieldDef {
139        key: "motherboard",
140        min_mode: Mode::Standard,
141    },
142    FieldDef {
143        key: "bios",
144        min_mode: Mode::Long,
145    },
146    FieldDef {
147        key: "bootmgr",
148        min_mode: Mode::Long,
149    },
150    FieldDef {
151        key: "tpm",
152        min_mode: Mode::Long,
153    },
154    FieldDef {
155        key: "display",
156        min_mode: Mode::Standard,
157    },
158    FieldDef {
159        key: "brightness",
160        min_mode: Mode::Long,
161    },
162    FieldDef {
163        key: "audio",
164        min_mode: Mode::Standard,
165    },
166    FieldDef {
167        key: "camera",
168        min_mode: Mode::Standard,
169    },
170    FieldDef {
171        key: "gamepad",
172        min_mode: Mode::Full,
173    },
174    FieldDef {
175        key: "keyboard",
176        min_mode: Mode::Long,
177    },
178    FieldDef {
179        key: "mouse",
180        min_mode: Mode::Long,
181    },
182    // --- Memory / storage ---
183    FieldDef {
184        key: "memory",
185        min_mode: Mode::Short,
186    },
187    FieldDef {
188        key: "phys-mem",
189        min_mode: Mode::Standard,
190    },
191    FieldDef {
192        key: "swap",
193        min_mode: Mode::Standard,
194    },
195    FieldDef {
196        key: "uptime",
197        min_mode: Mode::Standard,
198    },
199    FieldDef {
200        key: "procs",
201        min_mode: Mode::Long,
202    },
203    FieldDef {
204        key: "load",
205        min_mode: Mode::Standard,
206    },
207    FieldDef {
208        key: "disk",
209        min_mode: Mode::Short,
210    },
211    FieldDef {
212        key: "phys-disk",
213        min_mode: Mode::Standard,
214    },
215    FieldDef {
216        key: "disk-io",
217        min_mode: Mode::Long,
218    },
219    FieldDef {
220        key: "btrfs",
221        min_mode: Mode::Long,
222    },
223    FieldDef {
224        key: "zpool",
225        min_mode: Mode::Long,
226    },
227    FieldDef {
228        key: "temp",
229        min_mode: Mode::Long,
230    },
231    // --- Network ---
232    FieldDef {
233        key: "net",
234        min_mode: Mode::Short,
235    },
236    FieldDef {
237        key: "net-io",
238        min_mode: Mode::Long,
239    },
240    FieldDef {
241        key: "public-ip",
242        min_mode: Mode::Long,
243    },
244    FieldDef {
245        key: "wifi",
246        min_mode: Mode::Long,
247    },
248    FieldDef {
249        key: "dns",
250        min_mode: Mode::Long,
251    },
252    FieldDef {
253        key: "bluetooth",
254        min_mode: Mode::Long,
255    },
256    FieldDef {
257        key: "battery",
258        min_mode: Mode::Long,
259    },
260    FieldDef {
261        key: "power-adapter",
262        min_mode: Mode::Long,
263    },
264    // --- Environment ---
265    FieldDef {
266        key: "shell",
267        min_mode: Mode::Long,
268    },
269    FieldDef {
270        key: "editor",
271        min_mode: Mode::Long,
272    },
273    FieldDef {
274        key: "terminal",
275        min_mode: Mode::Long,
276    },
277    FieldDef {
278        key: "terminal-font",
279        min_mode: Mode::Long,
280    },
281    FieldDef {
282        key: "terminal-size",
283        min_mode: Mode::Long,
284    },
285    FieldDef {
286        key: "desktop",
287        min_mode: Mode::Long,
288    },
289    FieldDef {
290        key: "wm",
291        min_mode: Mode::Long,
292    },
293    FieldDef {
294        key: "login-manager",
295        min_mode: Mode::Long,
296    },
297    // --- Media ---
298    FieldDef {
299        key: "player",
300        min_mode: Mode::Long,
301    },
302    FieldDef {
303        key: "media",
304        min_mode: Mode::Long,
305    },
306    // --- Cosmetic / slow (Full-only unless noted) ---
307    FieldDef {
308        key: "wm-theme",
309        min_mode: Mode::Full,
310    },
311    FieldDef {
312        key: "wallpaper",
313        min_mode: Mode::Full,
314    },
315    FieldDef {
316        key: "terminal-theme",
317        min_mode: Mode::Full,
318    },
319    FieldDef {
320        key: "theme",
321        min_mode: Mode::Full,
322    },
323    FieldDef {
324        key: "icons",
325        min_mode: Mode::Full,
326    },
327    FieldDef {
328        key: "cursor",
329        min_mode: Mode::Full,
330    },
331    FieldDef {
332        key: "font",
333        min_mode: Mode::Long,
334    },
335    FieldDef {
336        key: "users",
337        min_mode: Mode::Long,
338    },
339    FieldDef {
340        key: "packages",
341        min_mode: Mode::Long,
342    },
343    FieldDef {
344        key: "weather",
345        min_mode: Mode::Full,
346    },
347];
348
349/// Returns the ordered list of field keys visible in the given [`Mode`].
350///
351/// A field is included when its `min_mode` is at or below `mode` (modes are
352/// strictly nested supersets). Used by both the collection allow-list in
353/// `main.rs` and the display allow-list in `display.rs`.
354pub fn fields_for(mode: Mode) -> Vec<String> {
355    FIELDS
356        .iter()
357        .filter(|f| f.min_mode <= mode)
358        .map(|f| f.key.to_string())
359        .collect()
360}
361
362/// Returns every field key, in table order.
363pub fn all_keys() -> Vec<&'static str> {
364    FIELDS.iter().map(|f| f.key).collect()
365}
366
367/// Generates the commented `fields = [...]` block for the default config file.
368///
369/// Used by both config-generation paths — `default_config_content()` in
370/// `main.rs` (full write) and `Config::merge_defaults` in `config.rs` (merge
371/// missing) — so the two can no longer drift apart. Emits every field key from
372/// [`FIELDS`], wrapped to a readable width, all commented out.
373pub fn config_fields_block() -> String {
374    const PER_LINE: usize = 6;
375    let mut out = String::new();
376    out.push_str("# List of fields to display (leave empty or omit to show all)\n");
377    out.push_str(
378        "# Note: \"phys-mem\" requires running as root (sudo) on Linux to read DMI memory tables.\n",
379    );
380    out.push_str(
381        "# Note: \"weather\" requires network access and is shown in full mode only by default.\n",
382    );
383    out.push_str(
384        "# Note: \"domain-search\" queries resolvectl and is shown in full mode only by default.\n",
385    );
386    out.push_str("# fields = [\n");
387    for chunk in FIELDS.chunks(PER_LINE) {
388        let quoted: Vec<String> = chunk.iter().map(|f| format!("\"{}\"", f.key)).collect();
389        out.push_str("#     ");
390        out.push_str(&quoted.join(", "));
391        out.push_str(",\n");
392    }
393    // Drop the trailing comma on the last emitted entry for valid TOML-in-comment.
394    if let Some(pos) = out.rfind(",\n") {
395        out.replace_range(pos..pos + 2, "\n");
396    }
397    out.push_str("# ]");
398    out
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use std::collections::HashSet;
405
406    #[test]
407    fn test_no_duplicate_keys() {
408        let mut seen = HashSet::new();
409        for f in FIELDS {
410            assert!(seen.insert(f.key), "duplicate field key: {}", f.key);
411        }
412    }
413
414    #[test]
415    fn test_strata_strictly_nested() {
416        let short: HashSet<_> = fields_for(Mode::Short).into_iter().collect();
417        let standard: HashSet<_> = fields_for(Mode::Standard).into_iter().collect();
418        let long: HashSet<_> = fields_for(Mode::Long).into_iter().collect();
419        let full: HashSet<_> = fields_for(Mode::Full).into_iter().collect();
420
421        assert!(
422            short.is_subset(&standard),
423            "short must be a subset of standard"
424        );
425        assert!(
426            standard.is_subset(&long),
427            "standard must be a subset of long"
428        );
429        assert!(long.is_subset(&full), "long must be a subset of full");
430    }
431
432    #[test]
433    fn test_strata_counts() {
434        // Golden counts pinning the current strata sizes (see NOTES.md §4).
435        // A change here should be deliberate and accompany a docs/NOTES update.
436        assert_eq!(fields_for(Mode::Short).len(), 8, "short field count");
437        assert_eq!(fields_for(Mode::Standard).len(), 18, "standard field count");
438        assert_eq!(fields_for(Mode::Long).len(), 56, "long field count");
439        assert_eq!(fields_for(Mode::Full).len(), 68, "full field count");
440    }
441
442    #[test]
443    fn test_short_set_exact() {
444        let short: HashSet<_> = fields_for(Mode::Short).into_iter().collect();
445        let expected: HashSet<String> = [
446            "os", "kernel", "host", "cpu", "gpu", "memory", "disk", "net",
447        ]
448        .iter()
449        .map(|s| s.to_string())
450        .collect();
451        assert_eq!(short, expected);
452    }
453
454    #[test]
455    fn test_mode_membership_boundaries() {
456        // Fields that must land in specific strata (guards against min_mode typos).
457        let standard: HashSet<_> = fields_for(Mode::Standard).into_iter().collect();
458        assert!(standard.contains("phys-mem"));
459        assert!(standard.contains("cpu-cache"));
460        assert!(!standard.contains("bios"), "bios is long+, not standard");
461        // cpu-usage costs a fixed 200 ms sampling sleep on Unix, so it must stay out of
462        // the default mode (see the FIELDS entry).
463        assert!(
464            !standard.contains("cpu-usage"),
465            "cpu-usage is long+, not standard"
466        );
467
468        let long: HashSet<_> = fields_for(Mode::Long).into_iter().collect();
469        assert!(long.contains("cpu-usage"));
470        assert!(long.contains("bios"));
471        assert!(long.contains("terminal-size"));
472        assert!(long.contains("wm"));
473        assert!(long.contains("login-manager"));
474        assert!(long.contains("brightness"));
475        assert!(long.contains("power-adapter"));
476        assert!(long.contains("keyboard"));
477        assert!(long.contains("mouse"));
478        assert!(long.contains("tpm"));
479        assert!(long.contains("player"));
480        assert!(long.contains("media"));
481        // The input/TPM/media trio is diagnostic, not part of the daily-use overview.
482        assert!(!standard.contains("keyboard"), "keyboard is long+");
483        assert!(!standard.contains("mouse"), "mouse is long+");
484        assert!(!standard.contains("tpm"), "tpm is long+");
485        assert!(!standard.contains("player"), "player is long+");
486        assert!(!standard.contains("media"), "media is long+");
487        // New Long fields must not leak into standard.
488        assert!(
489            !standard.contains("brightness"),
490            "brightness is long+, not standard"
491        );
492        assert!(!long.contains("weather"), "weather is full-only");
493        assert!(!long.contains("gamepad"), "gamepad is full-only");
494        assert!(!long.contains("wm-theme"), "wm-theme is full-only");
495        assert!(!long.contains("wallpaper"), "wallpaper is full-only");
496        assert!(
497            !long.contains("terminal-theme"),
498            "terminal-theme is full-only"
499        );
500
501        let full: HashSet<_> = fields_for(Mode::Full).into_iter().collect();
502        assert!(full.contains("weather"));
503        assert!(full.contains("domain-search"));
504        assert!(full.contains("wm-theme"));
505        assert!(full.contains("wallpaper"));
506        assert!(full.contains("terminal-theme"));
507    }
508
509    #[test]
510    fn test_config_block_shape() {
511        let block = config_fields_block();
512        assert!(block.contains("# fields = ["));
513        assert!(block.trim_end().ends_with("# ]"));
514        // Every field key must appear in the generated block.
515        for key in all_keys() {
516            assert!(
517                block.contains(&format!("\"{}\"", key)),
518                "config block missing key: {}",
519                key
520            );
521        }
522        // Well-formed comment: no line escapes the leading '#'.
523        for line in block.lines() {
524            assert!(
525                line.starts_with('#'),
526                "uncommented line in config block: {line:?}"
527            );
528        }
529        // No dangling comma before the closing bracket.
530        assert!(
531            !block.contains(",\n# ]"),
532            "trailing comma before closing bracket"
533        );
534    }
535}