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    FieldDef {
110        key: "cpu-usage",
111        min_mode: Mode::Standard,
112    },
113    // --- Graphics / firmware / peripherals ---
114    FieldDef {
115        key: "gpu",
116        min_mode: Mode::Short,
117    },
118    FieldDef {
119        key: "motherboard",
120        min_mode: Mode::Standard,
121    },
122    FieldDef {
123        key: "bios",
124        min_mode: Mode::Long,
125    },
126    FieldDef {
127        key: "bootmgr",
128        min_mode: Mode::Long,
129    },
130    FieldDef {
131        key: "display",
132        min_mode: Mode::Standard,
133    },
134    FieldDef {
135        key: "audio",
136        min_mode: Mode::Standard,
137    },
138    FieldDef {
139        key: "camera",
140        min_mode: Mode::Standard,
141    },
142    FieldDef {
143        key: "gamepad",
144        min_mode: Mode::Full,
145    },
146    // --- Memory / storage ---
147    FieldDef {
148        key: "memory",
149        min_mode: Mode::Short,
150    },
151    FieldDef {
152        key: "phys-mem",
153        min_mode: Mode::Standard,
154    },
155    FieldDef {
156        key: "swap",
157        min_mode: Mode::Standard,
158    },
159    FieldDef {
160        key: "uptime",
161        min_mode: Mode::Standard,
162    },
163    FieldDef {
164        key: "procs",
165        min_mode: Mode::Long,
166    },
167    FieldDef {
168        key: "load",
169        min_mode: Mode::Standard,
170    },
171    FieldDef {
172        key: "disk",
173        min_mode: Mode::Short,
174    },
175    FieldDef {
176        key: "phys-disk",
177        min_mode: Mode::Standard,
178    },
179    FieldDef {
180        key: "btrfs",
181        min_mode: Mode::Long,
182    },
183    FieldDef {
184        key: "zpool",
185        min_mode: Mode::Long,
186    },
187    FieldDef {
188        key: "temp",
189        min_mode: Mode::Long,
190    },
191    // --- Network ---
192    FieldDef {
193        key: "net",
194        min_mode: Mode::Short,
195    },
196    FieldDef {
197        key: "public-ip",
198        min_mode: Mode::Long,
199    },
200    FieldDef {
201        key: "wifi",
202        min_mode: Mode::Long,
203    },
204    FieldDef {
205        key: "dns",
206        min_mode: Mode::Long,
207    },
208    FieldDef {
209        key: "bluetooth",
210        min_mode: Mode::Long,
211    },
212    FieldDef {
213        key: "battery",
214        min_mode: Mode::Long,
215    },
216    // --- Environment ---
217    FieldDef {
218        key: "shell",
219        min_mode: Mode::Long,
220    },
221    FieldDef {
222        key: "editor",
223        min_mode: Mode::Long,
224    },
225    FieldDef {
226        key: "terminal",
227        min_mode: Mode::Long,
228    },
229    FieldDef {
230        key: "terminal-font",
231        min_mode: Mode::Long,
232    },
233    FieldDef {
234        key: "terminal-size",
235        min_mode: Mode::Long,
236    },
237    FieldDef {
238        key: "desktop",
239        min_mode: Mode::Long,
240    },
241    FieldDef {
242        key: "wm",
243        min_mode: Mode::Long,
244    },
245    // --- Cosmetic / slow (Full-only unless noted) ---
246    FieldDef {
247        key: "theme",
248        min_mode: Mode::Full,
249    },
250    FieldDef {
251        key: "icons",
252        min_mode: Mode::Full,
253    },
254    FieldDef {
255        key: "cursor",
256        min_mode: Mode::Full,
257    },
258    FieldDef {
259        key: "font",
260        min_mode: Mode::Long,
261    },
262    FieldDef {
263        key: "users",
264        min_mode: Mode::Long,
265    },
266    FieldDef {
267        key: "packages",
268        min_mode: Mode::Long,
269    },
270    FieldDef {
271        key: "weather",
272        min_mode: Mode::Full,
273    },
274];
275
276/// Returns the ordered list of field keys visible in the given [`Mode`].
277///
278/// A field is included when its `min_mode` is at or below `mode` (modes are
279/// strictly nested supersets). Used by both the collection allow-list in
280/// `main.rs` and the display allow-list in `display.rs`.
281pub fn fields_for(mode: Mode) -> Vec<String> {
282    FIELDS
283        .iter()
284        .filter(|f| f.min_mode <= mode)
285        .map(|f| f.key.to_string())
286        .collect()
287}
288
289/// Returns every field key, in table order.
290pub fn all_keys() -> Vec<&'static str> {
291    FIELDS.iter().map(|f| f.key).collect()
292}
293
294/// Generates the commented `fields = [...]` block for the default config file.
295///
296/// Used by both config-generation paths — `default_config_content()` in
297/// `main.rs` (full write) and `Config::merge_defaults` in `config.rs` (merge
298/// missing) — so the two can no longer drift apart. Emits every field key from
299/// [`FIELDS`], wrapped to a readable width, all commented out.
300pub fn config_fields_block() -> String {
301    const PER_LINE: usize = 6;
302    let mut out = String::new();
303    out.push_str("# List of fields to display (leave empty or omit to show all)\n");
304    out.push_str(
305        "# Note: \"phys-mem\" requires running as root (sudo) on Linux to read DMI memory tables.\n",
306    );
307    out.push_str(
308        "# Note: \"weather\" requires network access and is shown in full mode only by default.\n",
309    );
310    out.push_str(
311        "# Note: \"domain-search\" queries resolvectl and is shown in full mode only by default.\n",
312    );
313    out.push_str("# fields = [\n");
314    for chunk in FIELDS.chunks(PER_LINE) {
315        let quoted: Vec<String> = chunk.iter().map(|f| format!("\"{}\"", f.key)).collect();
316        out.push_str("#     ");
317        out.push_str(&quoted.join(", "));
318        out.push_str(",\n");
319    }
320    // Drop the trailing comma on the last emitted entry for valid TOML-in-comment.
321    if let Some(pos) = out.rfind(",\n") {
322        out.replace_range(pos..pos + 2, "\n");
323    }
324    out.push_str("# ]");
325    out
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::collections::HashSet;
332
333    #[test]
334    fn test_no_duplicate_keys() {
335        let mut seen = HashSet::new();
336        for f in FIELDS {
337            assert!(seen.insert(f.key), "duplicate field key: {}", f.key);
338        }
339    }
340
341    #[test]
342    fn test_strata_strictly_nested() {
343        let short: HashSet<_> = fields_for(Mode::Short).into_iter().collect();
344        let standard: HashSet<_> = fields_for(Mode::Standard).into_iter().collect();
345        let long: HashSet<_> = fields_for(Mode::Long).into_iter().collect();
346        let full: HashSet<_> = fields_for(Mode::Full).into_iter().collect();
347
348        assert!(
349            short.is_subset(&standard),
350            "short must be a subset of standard"
351        );
352        assert!(
353            standard.is_subset(&long),
354            "standard must be a subset of long"
355        );
356        assert!(long.is_subset(&full), "long must be a subset of full");
357    }
358
359    #[test]
360    fn test_strata_counts() {
361        // Golden counts pinning the current strata sizes (see NOTES.md §4).
362        // A change here should be deliberate and accompany a docs/NOTES update.
363        assert_eq!(fields_for(Mode::Short).len(), 8, "short field count");
364        assert_eq!(fields_for(Mode::Standard).len(), 19, "standard field count");
365        assert_eq!(fields_for(Mode::Long).len(), 46, "long field count");
366        assert_eq!(fields_for(Mode::Full).len(), 52, "full field count");
367    }
368
369    #[test]
370    fn test_short_set_exact() {
371        let short: HashSet<_> = fields_for(Mode::Short).into_iter().collect();
372        let expected: HashSet<String> = [
373            "os", "kernel", "host", "cpu", "gpu", "memory", "disk", "net",
374        ]
375        .iter()
376        .map(|s| s.to_string())
377        .collect();
378        assert_eq!(short, expected);
379    }
380
381    #[test]
382    fn test_mode_membership_boundaries() {
383        // Fields that must land in specific strata (guards against min_mode typos).
384        let standard: HashSet<_> = fields_for(Mode::Standard).into_iter().collect();
385        assert!(standard.contains("phys-mem"));
386        assert!(standard.contains("cpu-cache"));
387        assert!(!standard.contains("bios"), "bios is long+, not standard");
388
389        let long: HashSet<_> = fields_for(Mode::Long).into_iter().collect();
390        assert!(long.contains("bios"));
391        assert!(long.contains("terminal-size"));
392        assert!(long.contains("wm"));
393        assert!(!long.contains("weather"), "weather is full-only");
394        assert!(!long.contains("gamepad"), "gamepad is full-only");
395
396        let full: HashSet<_> = fields_for(Mode::Full).into_iter().collect();
397        assert!(full.contains("weather"));
398        assert!(full.contains("domain-search"));
399    }
400
401    #[test]
402    fn test_config_block_shape() {
403        let block = config_fields_block();
404        assert!(block.contains("# fields = ["));
405        assert!(block.trim_end().ends_with("# ]"));
406        // Every field key must appear in the generated block.
407        for key in all_keys() {
408            assert!(
409                block.contains(&format!("\"{}\"", key)),
410                "config block missing key: {}",
411                key
412            );
413        }
414        // Well-formed comment: no line escapes the leading '#'.
415        for line in block.lines() {
416            assert!(
417                line.starts_with('#'),
418                "uncommented line in config block: {line:?}"
419            );
420        }
421        // No dangling comma before the closing bracket.
422        assert!(
423            !block.contains(",\n# ]"),
424            "trailing comma before closing bracket"
425        );
426    }
427}