Skip to main content

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}