Skip to main content

horto_os_ui_shared/
context.rs

1//! Host execution context for setup steps: apply vs dry-run, paths, logs, prompts.
2
3use crate::paths::HostPaths;
4use std::collections::HashMap;
5
6/// Whether steps mutate the host (`Apply`) or only record planned actions (`DryRun`).
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ApplyMode {
9    /// Record planned actions; kits skip mutating side effects.
10    DryRun,
11    /// Perform real filesystem / package / systemd changes (needs root).
12    Apply,
13}
14
15/// One planned side effect recorded during dry-run (or planning).
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PlannedAction {
18    /// Human-readable summary of the intended action.
19    pub summary: String,
20}
21
22impl PlannedAction {
23    /// Build a planned action from a summary string.
24    pub fn new(summary: impl Into<String>) -> Self {
25        Self {
26            summary: summary.into(),
27        }
28    }
29}
30
31/// Optional interactive prompts. Non-interactive defaults skip prompts.
32pub trait PromptsProvider: Send {
33    /// Ask for a free-form value; return `default` when the operator accepts empty input.
34    fn prompt(&mut self, label: &str, default: &str) -> String;
35    /// Yes/no confirm; return `default_yes` when input is empty or unrecognized.
36    fn confirm(&mut self, question: &str, default_yes: bool) -> bool;
37}
38
39/// Always returns defaults / no for confirms.
40#[derive(Debug, Default)]
41pub struct NonInteractivePrompts;
42
43impl PromptsProvider for NonInteractivePrompts {
44    fn prompt(&mut self, _label: &str, default: &str) -> String {
45        default.to_string()
46    }
47
48    fn confirm(&mut self, _question: &str, default_yes: bool) -> bool {
49        default_yes
50    }
51}
52
53/// Stdin/stdout prompts for interactive CLI/TUI use.
54#[derive(Debug, Default)]
55pub struct StdioPrompts;
56
57impl PromptsProvider for StdioPrompts {
58    fn prompt(&mut self, label: &str, default: &str) -> String {
59        use std::io::{self, Write};
60        eprint!("{label} [{default}]: ");
61        let _ = io::stderr().flush();
62        let mut line = String::new();
63        if io::stdin().read_line(&mut line).is_err() {
64            return default.to_string();
65        }
66        let trimmed = line.trim();
67        if trimmed.is_empty() {
68            default.to_string()
69        } else {
70            trimmed.to_string()
71        }
72    }
73
74    fn confirm(&mut self, question: &str, default_yes: bool) -> bool {
75        use std::io::{self, Write};
76        let hint = if default_yes { "Y/n" } else { "y/N" };
77        eprint!("{question} [{hint}]: ");
78        let _ = io::stderr().flush();
79        let mut line = String::new();
80        if io::stdin().read_line(&mut line).is_err() {
81            return default_yes;
82        }
83        match line.trim().to_ascii_lowercase().as_str() {
84            "y" | "yes" => true,
85            "n" | "no" => false,
86            _ => default_yes,
87        }
88    }
89}
90
91/// Mutable per-run state passed into steps and kits.
92pub struct HostContext {
93    /// Dry-run vs apply.
94    pub mode: ApplyMode,
95    /// Host filesystem layout for this run.
96    pub paths: HostPaths,
97    /// Selected Full / Minimal pipeline.
98    pub setup_kind: crate::pipeline::SetupKind,
99    /// When true, Piper-related install paths are skipped.
100    pub skip_piper: bool,
101    /// When true, NAT-related confirms auto-accept (also set via `HORTO_APPLY_NAT`).
102    pub apply_nat: bool,
103    /// Accumulated log lines for TUI / API surfaces (also mirrored to `tracing`).
104    pub logs: Vec<String>,
105    /// Planned actions collected during dry-run / `plan`.
106    pub planned: Vec<PlannedAction>,
107    prompts: Option<Box<dyn PromptsProvider>>,
108    /// Pre-filled answers keyed by prompt label (tests / automation).
109    pub prompt_answers: HashMap<String, String>,
110}
111
112impl HostContext {
113    /// Create a context with default [`HostPaths`] and empty logs / planned actions.
114    ///
115    /// `apply_nat` is initialized from `HORTO_APPLY_NAT=1` / `true` when set.
116    #[must_use]
117    pub fn new(mode: ApplyMode, kind: crate::pipeline::SetupKind) -> Self {
118        Self {
119            mode,
120            paths: HostPaths::default(),
121            setup_kind: kind,
122            skip_piper: false,
123            apply_nat: std::env::var("HORTO_APPLY_NAT")
124                .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")),
125            logs: Vec::new(),
126            planned: Vec::new(),
127            prompts: None,
128            prompt_answers: HashMap::new(),
129        }
130    }
131
132    /// Replace host paths (tests and alternate roots).
133    #[must_use]
134    pub fn with_paths(mut self, paths: HostPaths) -> Self {
135        self.paths = paths;
136        self
137    }
138
139    /// Attach an interactive or recording prompts provider.
140    #[must_use]
141    pub fn with_prompts(mut self, prompts: Box<dyn PromptsProvider>) -> Self {
142        self.prompts = Some(prompts);
143        self
144    }
145
146    /// Append a log line and emit `tracing::info!`.
147    pub fn log(&mut self, msg: impl AsRef<str>) {
148        let s = msg.as_ref().to_string();
149        tracing::info!("{s}");
150        self.logs.push(s);
151    }
152
153    /// Record a planned action, log it with a `[plan]` prefix, and push onto [`Self::planned`].
154    pub fn plan_action(&mut self, summary: impl Into<String>) {
155        let action = PlannedAction::new(summary);
156        self.log(format!("[plan] {}", action.summary));
157        self.planned.push(action);
158    }
159
160    /// True when [`Self::mode`] is [`ApplyMode::DryRun`].
161    #[must_use]
162    pub fn is_dry_run(&self) -> bool {
163        self.mode == ApplyMode::DryRun
164    }
165
166    /// Resolve a prompt: prefer [`Self::prompt_answers`], then the prompts provider, else `default`.
167    pub fn prompt(&mut self, label: &str, default: &str) -> String {
168        if let Some(ans) = self.prompt_answers.get(label) {
169            return ans.clone();
170        }
171        if let Some(ref mut p) = self.prompts {
172            return p.prompt(label, default);
173        }
174        default.to_string()
175    }
176
177    /// Resolve a yes/no confirm.
178    ///
179    /// When [`Self::apply_nat`] is set and `question` contains `"nat"` (ASCII case-insensitive),
180    /// returns `true` without asking. Otherwise uses the prompts provider or `default_yes`.
181    pub fn confirm(&mut self, question: &str, default_yes: bool) -> bool {
182        if self.apply_nat && question.to_ascii_lowercase().contains("nat") {
183            return true;
184        }
185        if let Some(ref mut p) = self.prompts {
186            return p.confirm(question, default_yes);
187        }
188        default_yes
189    }
190}
191
192/// In Apply mode, require effective uid 0.
193///
194/// # Errors
195///
196/// Returns [`HortoError::RootRequired`](crate::error::HortoError::RootRequired) when
197/// `mode` is [`ApplyMode::Apply`] and the process is not root.
198pub fn require_root_for_apply(mode: ApplyMode) -> crate::error::Result<()> {
199    if mode == ApplyMode::Apply && !is_root() {
200        return Err(crate::error::HortoError::RootRequired);
201    }
202    Ok(())
203}
204
205/// True when the process effective uid is 0 (Unix). Non-Unix always returns `false`.
206#[must_use]
207pub fn is_root() -> bool {
208    #[cfg(unix)]
209    {
210        extern "C" {
211            fn geteuid() -> u32;
212        }
213        unsafe { geteuid() == 0 }
214    }
215    #[cfg(not(unix))]
216    {
217        false
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::paths::HostPaths;
225    use crate::pipeline::SetupKind;
226
227    struct RecordingPrompts {
228        prompt_calls: Vec<(String, String)>,
229        confirm_calls: Vec<(String, bool)>,
230    }
231
232    impl RecordingPrompts {
233        fn new() -> Self {
234            Self {
235                prompt_calls: Vec::new(),
236                confirm_calls: Vec::new(),
237            }
238        }
239    }
240
241    impl PromptsProvider for RecordingPrompts {
242        fn prompt(&mut self, label: &str, default: &str) -> String {
243            self.prompt_calls.push((label.into(), default.into()));
244            format!("answer::{label}")
245        }
246
247        fn confirm(&mut self, question: &str, default_yes: bool) -> bool {
248            self.confirm_calls.push((question.into(), default_yes));
249            !default_yes
250        }
251    }
252
253    #[test]
254    fn planned_action_summary_roundtrip() {
255        let action = PlannedAction::new("run apt update");
256        assert_eq!(action.summary, "run apt update");
257    }
258
259    #[test]
260    fn context_new_defaults() {
261        let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Minimal);
262        assert!(ctx.is_dry_run());
263        assert!(!ctx.skip_piper);
264        assert_eq!(ctx.logs, Vec::<String>::new());
265        assert_eq!(ctx.planned.len(), 0);
266    }
267
268    #[test]
269    fn context_log_records_and_stores() {
270        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
271        ctx.log("hello");
272        assert_eq!(ctx.logs, vec!["hello".to_string()]);
273    }
274
275    #[test]
276    fn context_plan_action_logs_dry_run_prefix() {
277        let mut ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
278        ctx.plan_action("something");
279        assert_eq!(ctx.planned.len(), 1);
280        assert!(ctx.logs.iter().any(|l| l.contains("[plan] something")));
281    }
282
283    #[test]
284    fn context_with_paths_replaces_host_paths() {
285        let paths = HostPaths::default();
286        let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full).with_paths(paths.clone());
287        assert_eq!(ctx.paths.active_setup, paths.active_setup);
288    }
289
290    #[test]
291    fn context_prompt_prefers_prefilled_answers_over_prompts_provider() {
292        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
293            .with_prompts(Box::new(RecordingPrompts::new()));
294        ctx.prompt_answers
295            .insert("label".into(), "prefilled".into());
296        assert_eq!(ctx.prompt("label", "default"), "prefilled");
297    }
298
299    #[test]
300    fn context_prompt_delegates_to_provider() {
301        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
302            .with_prompts(Box::new(RecordingPrompts::new()));
303        assert_eq!(ctx.prompt("hostname", "def"), "answer::hostname");
304    }
305
306    #[test]
307    fn context_prompt_without_provider_returns_default() {
308        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
309        assert_eq!(ctx.prompt("x", "fallback"), "fallback");
310    }
311
312    #[test]
313    fn context_confirm_apply_nat_short_circuits_when_question_matches() {
314        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
315        ctx.apply_nat = true;
316        assert!(ctx.confirm("Apply NAT rules now?", false));
317    }
318
319    #[test]
320    fn context_confirm_apply_nat_ignores_unrelated_questions() {
321        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
322        ctx.apply_nat = true;
323        assert!(!ctx.confirm("Restart dnsmasq?", false));
324        assert!(ctx.confirm("Restart dnsmasq?", true));
325    }
326
327    #[test]
328    fn context_confirm_delegates_to_provider() {
329        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full)
330            .with_prompts(Box::new(RecordingPrompts::new()));
331        // RecordingPrompts.confirm flips default_yes.
332        assert!(!ctx.confirm("Anything?", true));
333        assert!(ctx.confirm("Anything?", false));
334    }
335
336    #[test]
337    fn context_confirm_without_provider_returns_default() {
338        let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
339        assert!(ctx.confirm("q", true));
340        assert!(!ctx.confirm("q", false));
341    }
342
343    #[test]
344    fn non_interactive_prompts_return_defaults() {
345        let mut prompts = NonInteractivePrompts;
346        assert_eq!(prompts.prompt("label", "default"), "default");
347        assert!(prompts.confirm("q?", true));
348        assert!(!prompts.confirm("q?", false));
349    }
350
351    #[test]
352    fn require_root_for_apply_dry_run_is_ok() {
353        require_root_for_apply(ApplyMode::DryRun).unwrap();
354    }
355
356    #[test]
357    fn require_root_for_apply_apply_mode_matches_is_root() {
358        let result = require_root_for_apply(ApplyMode::Apply);
359        assert_eq!(result.is_ok(), is_root());
360    }
361
362    #[test]
363    fn is_root_returns_bool() {
364        // Just exercise the code path; value depends on env.
365        let _ = is_root();
366    }
367}