sui_spec/operator_view.rs
1//! `OperatorView` — the shape every operator-facing introspection
2//! mode shares.
3//!
4//! Three callers reach this shape in `sui-spec-inventory`:
5//!
6//! - `--flake-lock <path>` (lock_file primitive)
7//! - `--narinfo <path>` (narinfo primitive)
8//! - `--registry-resolve <ref>` (registry primitive)
9//!
10//! Each one (a) consumes a string input, (b) calls a typed
11//! substrate primitive that returns `Result<T, SpecError>`, and
12//! (c) renders T as a Nord-styled banner + body + optional
13//! summary line. Third-site rule per the PRIME DIRECTIVE — the
14//! trait extraction earns its keep when the next mode (e.g.
15//! `--realisation-parse`, `--substituter-probe`) adds **one**
16//! impl block instead of replicating banner / table / summary
17//! plumbing.
18//!
19//! ## Anatomy of an operator view
20//!
21//! ```text
22//! <glyph> <header> <subject> <- emit_banner
23//! (blank)
24//! <body row>
25//! <body row> <- emit_body
26//! ...
27//! (blank)
28//! <summary line> <- emit_summary (optional)
29//! ```
30//!
31//! Implementors only own `parse()` + `body()`. Banner + summary
32//! defaults match the cppnix-style output the inventory tool
33//! already emits.
34//!
35//! ## Why a trait
36//!
37//! - One place for the banner shape — operators see consistent
38//! output across every mode.
39//! - New introspection modes are 1 impl + 1 `match` arm in CLI
40//! plumbing. No more cut-paste of the Nord styling.
41//! - Test harness in CSE style: a single property test asserts
42//! every implementor emits a banner first, body second,
43//! summary last (or nothing).
44
45use crate::SpecError;
46
47/// One operator-facing introspection view. Implementors plug
48/// into the inventory CLI to surface a typed substrate primitive.
49pub trait OperatorView {
50 /// Source the implementor reports against (file path, ref
51 /// string, etc.). Used in the banner.
52 fn subject(&self) -> &str;
53
54 /// One-word noun for the header, e.g. `"flake.lock"`,
55 /// `"narinfo"`, `"registry resolve"`.
56 fn header_label(&self) -> &str;
57
58 /// Render the body rows under the banner. Pure I/O via
59 /// stdout writes — implementors print row-by-row.
60 fn render_body(&self);
61
62 /// Optional summary line printed below the body. Default
63 /// emits nothing.
64 fn render_summary(&self) {}
65}
66
67/// Helper that renders `view` end-to-end: banner, blank, body,
68/// blank, summary.
69pub fn render<V: OperatorView>(view: &V) {
70 use crate::style::{glyph_snowflake, header, muted};
71 println!(
72 "{} {} {}",
73 glyph_snowflake(),
74 header(view.header_label()),
75 muted(view.subject()),
76 );
77 println!();
78 view.render_body();
79 println!();
80 view.render_summary();
81}
82
83/// Common error path for inventory CLIs. Lifts `SpecError` and
84/// `io::Error` into a single `Box<dyn Error>` consumers can `?`
85/// against.
86pub type ViewResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
87
88/// Helper that loads a substrate primitive by name from a
89/// `load_canonical()` Vec. Most inventory modes do exactly this
90/// to obtain the format spec they parse against.
91pub fn pick_format<F, P>(
92 formats: Vec<F>,
93 predicate: P,
94 label: &'static str,
95) -> Result<F, SpecError>
96where
97 P: Fn(&F) -> bool,
98{
99 formats
100 .into_iter()
101 .find(predicate)
102 .ok_or_else(|| SpecError::Interp {
103 phase: "operator-view".into(),
104 message: format!("missing format spec: {label}"),
105 })
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 struct Fixture {
113 subj: String,
114 body_called: std::cell::RefCell<bool>,
115 summary_called: std::cell::RefCell<bool>,
116 }
117
118 impl OperatorView for Fixture {
119 fn subject(&self) -> &str { &self.subj }
120 fn header_label(&self) -> &str { "fixture" }
121 fn render_body(&self) {
122 *self.body_called.borrow_mut() = true;
123 }
124 fn render_summary(&self) {
125 *self.summary_called.borrow_mut() = true;
126 }
127 }
128
129 #[test]
130 fn render_calls_body_and_summary() {
131 let f = Fixture {
132 subj: "x".into(),
133 body_called: std::cell::RefCell::new(false),
134 summary_called: std::cell::RefCell::new(false),
135 };
136 render(&f);
137 assert!(*f.body_called.borrow());
138 assert!(*f.summary_called.borrow());
139 }
140
141 #[test]
142 fn pick_format_finds_match() {
143 let formats = vec!["a".to_string(), "b".to_string(), "c".to_string()];
144 let result = pick_format(formats, |s| s == "b", "test").unwrap();
145 assert_eq!(result, "b");
146 }
147
148 #[test]
149 fn pick_format_errors_when_missing() {
150 let formats = vec!["a".to_string(), "b".to_string()];
151 let err = pick_format(formats, |s| s == "c", "test-thing")
152 .unwrap_err();
153 match err {
154 SpecError::Interp { phase, message } => {
155 assert_eq!(phase, "operator-view");
156 assert!(message.contains("test-thing"));
157 }
158 _ => panic!("expected Interp error, got {err:?}"),
159 }
160 }
161}