1use crate::paths::HostPaths;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ApplyMode {
9 DryRun,
11 Apply,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PlannedAction {
18 pub summary: String,
20}
21
22impl PlannedAction {
23 pub fn new(summary: impl Into<String>) -> Self {
25 Self {
26 summary: summary.into(),
27 }
28 }
29}
30
31pub trait PromptsProvider: Send {
33 fn prompt(&mut self, label: &str, default: &str) -> String;
35 fn confirm(&mut self, question: &str, default_yes: bool) -> bool;
37}
38
39#[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#[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
91pub struct HostContext {
93 pub mode: ApplyMode,
95 pub paths: HostPaths,
97 pub setup_kind: crate::pipeline::SetupKind,
99 pub skip_piper: bool,
101 pub apply_nat: bool,
103 pub logs: Vec<String>,
105 pub planned: Vec<PlannedAction>,
107 prompts: Option<Box<dyn PromptsProvider>>,
108 pub prompt_answers: HashMap<String, String>,
110}
111
112impl HostContext {
113 #[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 #[must_use]
134 pub fn with_paths(mut self, paths: HostPaths) -> Self {
135 self.paths = paths;
136 self
137 }
138
139 #[must_use]
141 pub fn with_prompts(mut self, prompts: Box<dyn PromptsProvider>) -> Self {
142 self.prompts = Some(prompts);
143 self
144 }
145
146 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 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 #[must_use]
162 pub fn is_dry_run(&self) -> bool {
163 self.mode == ApplyMode::DryRun
164 }
165
166 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 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
192pub 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#[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 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 let _ = is_root();
366 }
367}