Skip to main content

fez/capabilities/
mod.rs

1//! Capability implementations: the concrete commands fez exposes.
2
3use crate::cli::Cli;
4use crate::envelope::{ApiError, Envelope};
5use crate::error::Result;
6use serde_json::Value;
7
8/// A rendered capability result: the JSON payload plus its human form.
9///
10/// Every capability handler returns one of these (wrapped in [`Result`]); the
11/// shared [`render`] turns it into stdout/stderr output and an exit code. The
12/// `hints` and `pre_rendered` fields are optional features that individual
13/// capabilities opt into (network never sets them; services uses
14/// `pre_rendered` for its streaming `logs` action).
15pub struct View {
16    /// Envelope `kind` discriminant (e.g. `"ServiceList"`).
17    pub kind: &'static str,
18    /// Resolved target host the result pertains to.
19    pub host: String,
20    /// The `--json` envelope `data` payload.
21    pub data: Value,
22    /// The plain-text rendering printed when `--json` is absent.
23    pub human: String,
24    /// Optional envelope `hints` block (next-step suggestions).
25    pub hints: Option<Value>,
26    /// When set, the handler already wrote its own output (e.g. a streamed
27    /// log tail); [`render`] prints nothing and returns success.
28    pub pre_rendered: bool,
29}
30
31impl View {
32    /// A bare view: no hints, not pre-rendered. The common case.
33    pub fn new(kind: &'static str, host: String, data: Value, human: String) -> Self {
34        View {
35            kind,
36            host,
37            data,
38            human,
39            hints: None,
40            pre_rendered: false,
41        }
42    }
43
44    /// Attach an envelope `hints` block.
45    #[must_use]
46    pub fn with_hints(mut self, hints: Value) -> Self {
47        self.hints = Some(hints);
48        self
49    }
50
51    /// Attach an already-optional `hints` block (pass-through for callers that
52    /// compute `Option<Value>` and want to set it without unwrapping).
53    #[must_use]
54    pub fn with_hints_opt(mut self, hints: Option<Value>) -> Self {
55        self.hints = hints;
56        self
57    }
58
59    /// Mark this view as already written to stdout by the handler (e.g. a
60    /// streamed log tail); [`render`] will print nothing for it.
61    #[must_use]
62    pub fn pre_rendered(mut self) -> Self {
63        self.pre_rendered = true;
64        self
65    }
66}
67
68/// Render a [`View`] (or error) to stdout/stderr and return the exit code.
69///
70/// On success: emits the `fez/v1` envelope under `--json` (with `hints` when
71/// present) or the human text otherwise, and returns `0`. A `pre_rendered`
72/// view prints nothing and returns `0`. On error: emits an error envelope
73/// (with structured [`crate::error::FezError::detail`]) under `--json` or a
74/// `error: ...` line otherwise, and returns the error's exit code.
75///
76/// This attaches no error-specific `hints`; a capability that wants safe
77/// read-only follow-up hints on its error envelopes calls [`render_with_hints`]
78/// instead.
79pub fn render(cli: &Cli, result: Result<View>) -> i32 {
80    render_with_hints(cli, result, |_| None)
81}
82
83/// [`render`] with a per-capability error-hints hook.
84///
85/// `error_hints` maps a failing [`crate::error::FezError`] to an optional
86/// envelope `hints` block (safe read-only follow-ups, e.g. a service-status
87/// check). It runs only on the error path and only under `--json`; the success
88/// path and plain-text error path are identical to [`render`]. Domain-specific
89/// hint wording therefore stays in the capability, not in the shared error type.
90pub fn render_with_hints<F>(cli: &Cli, result: Result<View>, error_hints: F) -> i32
91where
92    F: FnOnce(&crate::error::FezError) -> Option<Value>,
93{
94    let host = cli.resolved_host();
95    match result {
96        Ok(view) => {
97            if view.pre_rendered {
98                return 0;
99            }
100            if cli.json {
101                let mut env = Envelope::ok(view.kind, &view.host, view.data);
102                if let Some(h) = view.hints {
103                    env = env.with_hints(h);
104                }
105                println!("{}", env.to_json_string());
106            } else {
107                print!("{}", view.human);
108            }
109            0
110        }
111        Err(e) => {
112            if cli.json {
113                let mut env = Envelope::error(
114                    "Error",
115                    &host,
116                    ApiError {
117                        code: e.code().into(),
118                        message: e.to_string(),
119                        detail: e.detail(),
120                    },
121                );
122                if let Some(h) = error_hints(&e) {
123                    env = env.with_hints(h);
124                }
125                println!("{}", env.to_json_string());
126            } else {
127                eprintln!("error: {e}");
128            }
129            e.exit_code()
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::{render, render_with_hints, View};
137    use crate::cli::Cli;
138    use crate::error::FezError;
139    use clap::Parser;
140    use serde_json::json;
141
142    fn cli(args: &[&str]) -> Cli {
143        Cli::try_parse_from(args).expect("args parse")
144    }
145
146    #[test]
147    fn new_is_bare() {
148        let v = View::new("Kind", "host".into(), json!({"a": 1}), "human\n".into());
149        assert_eq!(v.kind, "Kind");
150        assert_eq!(v.host, "host");
151        assert_eq!(v.human, "human\n");
152        assert!(v.hints.is_none());
153        assert!(!v.pre_rendered);
154    }
155
156    #[test]
157    fn with_hints_sets_hints() {
158        let v = View::new("K", "h".into(), json!(null), String::new())
159            .with_hints(json!({"reverse": "fez x"}));
160        assert_eq!(v.hints.unwrap()["reverse"], "fez x");
161    }
162
163    #[test]
164    fn with_hints_opt_passes_through() {
165        let some = View::new("K", "h".into(), json!(null), String::new())
166            .with_hints_opt(Some(json!({"k": 1})));
167        assert!(some.hints.is_some());
168        let none = View::new("K", "h".into(), json!(null), String::new()).with_hints_opt(None);
169        assert!(none.hints.is_none());
170    }
171
172    #[test]
173    fn pre_rendered_marks_the_view() {
174        let v = View::new("K", "h".into(), json!(null), String::new()).pre_rendered();
175        assert!(v.pre_rendered);
176    }
177
178    #[test]
179    fn render_pre_rendered_view_is_silent_success() {
180        let c = cli(&["fez", "services", "list"]);
181        let v =
182            View::new("LogEntries", "localhost".into(), json!(null), String::new()).pre_rendered();
183        assert_eq!(render(&c, Ok(v)), 0);
184    }
185
186    #[test]
187    fn render_ok_human_returns_zero() {
188        let c = cli(&["fez", "services", "list"]);
189        let v = View::new(
190            "ServiceList",
191            "localhost".into(),
192            json!({"a": 1}),
193            "out\n".into(),
194        );
195        assert_eq!(render(&c, Ok(v)), 0);
196    }
197
198    #[test]
199    fn render_ok_json_with_hints_returns_zero() {
200        let c = cli(&["fez", "--json", "services", "stop", "x"]);
201        let v = View::new(
202            "Stopped",
203            "localhost".into(),
204            json!({"unit": "x"}),
205            "stopped\n".into(),
206        )
207        .with_hints(json!({"reverse": "fez services start x"}));
208        assert_eq!(render(&c, Ok(v)), 0);
209    }
210
211    #[test]
212    fn render_err_human_returns_error_exit_code() {
213        let c = cli(&["fez", "services", "status", "missing"]);
214        let exit = render(&c, Err(FezError::NotFound("missing".into())));
215        assert_eq!(exit, FezError::NotFound("missing".into()).exit_code());
216    }
217
218    #[test]
219    fn render_err_json_emits_detail_and_exit_code() {
220        let c = cli(&["fez", "--json", "packages", "list"]);
221        let err = FezError::DependencyMissing {
222            component: "dnf5daemon".into(),
223            dbus_name: "org.rpm.dnf.v0".into(),
224            remediation: "install dnf5daemon-server".into(),
225        };
226        let expected = err.exit_code();
227        assert_eq!(render(&c, Err(err)), expected);
228    }
229
230    #[test]
231    fn render_with_hints_runs_hook_on_error_and_returns_exit_code() {
232        let c = cli(&["fez", "--json", "firewall", "status"]);
233        let err = FezError::UnsupportedApi("getMasquerade".into());
234        let expected = err.exit_code();
235        let called = std::cell::Cell::new(false);
236        let exit = render_with_hints(&c, Err(err), |_| {
237            called.set(true);
238            Some(json!({"unsupported": "treat as unavailable"}))
239        });
240        assert_eq!(exit, expected);
241        assert!(called.get(), "hook runs on the error path");
242    }
243
244    #[test]
245    fn render_with_hints_skips_hook_on_success() {
246        let c = cli(&["fez", "--json", "firewall", "status"]);
247        let v = View::new(
248            "FirewallStatus",
249            "localhost".into(),
250            json!({}),
251            "ok\n".into(),
252        );
253        let called = std::cell::Cell::new(false);
254        let exit = render_with_hints(&c, Ok(v), |_| {
255            called.set(true);
256            None
257        });
258        assert_eq!(exit, 0);
259        assert!(!called.get(), "hook does not run on the success path");
260    }
261}
262
263/// systemd service management capabilities.
264pub mod services;
265
266/// RPM package management capabilities (via dnf5daemon).
267pub mod packages;
268
269/// PackageKit fallback package backend (used when dnf5daemon is absent).
270pub mod packages_pk;
271
272/// NetworkManager inspection capabilities.
273pub mod network;
274
275/// firewalld management capabilities.
276pub mod firewall;