1use std::path::{Path, PathBuf};
11
12use crate::engine::known::{self, Known};
13use crate::engine::{Engine, EngineKind, Unavailable, detect};
14
15use super::install::IdRanges;
16use crate::store::{Config, SetupStep, Store};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum EngineProblem {
24 NotInstalled,
26 DaemonStopped {
28 output: String,
30 },
31 MachineStopped {
33 output: String,
35 },
36 Refused {
38 code: Option<i32>,
40 output: String,
42 },
43 NoPermission {
46 output: String,
48 in_group: bool,
51 },
52 NoIdRanges {
55 line: String,
57 output: String,
60 },
61 NotRunnable {
63 bin: PathBuf,
65 message: String,
67 },
68}
69
70impl EngineProblem {
71 #[must_use]
74 pub fn output(&self) -> Option<&str> {
75 match self {
76 Self::NotInstalled => None,
77 Self::DaemonStopped { output }
78 | Self::MachineStopped { output }
79 | Self::Refused { output, .. }
80 | Self::NoPermission { output, .. }
81 | Self::NoIdRanges { output, .. } => Some(output).filter(|text| !text.is_empty()).map(String::as_str),
82 Self::NotRunnable { message, .. } => Some(message),
83 }
84 }
85}
86
87impl From<Unavailable> for EngineProblem {
88 fn from(unavailable: Unavailable) -> Self {
89 match unavailable {
90 Unavailable::NotInstalled => Self::NotInstalled,
91 Unavailable::DaemonStopped { output } => Self::DaemonStopped { output },
92 Unavailable::MachineStopped { output } => Self::MachineStopped { output },
93 Unavailable::InfoFailed { code, output } => Self::Refused { code, output },
94 Unavailable::NotRunnable { bin, error } => Self::NotRunnable { bin, message: error.to_string() },
95 }
96 }
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub enum EngineCheck {
105 #[default]
107 Unknown,
108 Running,
110 Working,
112 Broken(EngineProblem),
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum LocationProblem {
119 Unset,
121 NotAFolder,
123 Blocked(String),
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub enum LocationCheck {
130 #[default]
132 Unknown,
133 Running,
135 Usable,
137 Broken(LocationProblem),
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Gates {
147 pub language: bool,
149 pub engine: EngineCheck,
151 pub location: LocationCheck,
153}
154
155impl Gates {
156 #[must_use]
162 pub fn entry(&self) -> Option<SetupStep> {
163 if !self.language {
164 return Some(SetupStep::Language);
165 }
166 if self.engine != EngineCheck::Working {
167 return Some(SetupStep::Engine);
168 }
169 if self.location != LocationCheck::Usable {
170 return Some(SetupStep::Location);
171 }
172 None
173 }
174
175 #[must_use]
182 pub fn probe(config: &Config) -> Self {
183 Self::probe_with(config, &detect).0
184 }
185
186 #[must_use]
199 pub fn probe_with(
200 config: &Config,
201 detect: &dyn Fn(EngineKind) -> Result<Engine, Unavailable>,
202 ) -> (Self, Option<Engine>) {
203 let mut gates = Self {
204 language: config.settings().language().is_some(),
205 engine: EngineCheck::Unknown,
206 location: LocationCheck::Unknown,
207 };
208 if !gates.language {
209 return (gates, None);
210 }
211 let mut found = None;
212 gates.engine = match config.engine_kind().and_then(EngineKind::from_name) {
213 Some(kind) => {
214 let (check, engine) = check_engine_found(kind, detect);
215 found = engine;
216 check
217 }
218 None => EngineCheck::Unknown,
219 };
220 if gates.engine != EngineCheck::Working {
221 return (gates, found);
222 }
223 gates.location = match config.folder_path() {
224 Some(path) => check_location(&path),
225 None => LocationCheck::Broken(LocationProblem::Unset),
226 };
227 (gates, found)
228 }
229}
230
231#[must_use]
235pub fn check_engine(kind: EngineKind) -> EngineCheck {
236 check_engine_found(kind, &detect).0
237}
238
239#[must_use]
248pub fn check_engine_found(
249 kind: EngineKind,
250 detect: &dyn Fn(EngineKind) -> Result<Engine, Unavailable>,
251) -> (EngineCheck, Option<Engine>) {
252 match detect(kind) {
253 Ok(engine) => (EngineCheck::Working, Some(engine)),
254 Err(unavailable) => {
255 let group = std::fs::read_to_string("/etc/group").unwrap_or_default();
256 let user = IdRanges::here().user;
257 (EngineCheck::Broken(refine(EngineProblem::from(unavailable), &group, &user)), None)
258 }
259 }
260}
261
262#[must_use]
270pub fn check_engine_for_setup(kind: EngineKind) -> EngineCheck {
271 let check = check_engine(kind);
272 let linux = crate::store::Platform::host() == crate::store::Platform::Linux;
273 if check == EngineCheck::Working && kind == EngineKind::Podman && linux {
274 return with_ranges(check, &IdRanges::here());
275 }
276 check
277}
278
279fn with_ranges(check: EngineCheck, ranges: &IdRanges) -> EngineCheck {
281 if check == EngineCheck::Working && !ranges.present() {
282 return EngineCheck::Broken(EngineProblem::NoIdRanges { line: ranges.line(), output: String::new() });
283 }
284 check
285}
286
287fn refine(problem: EngineProblem, group: &str, user: &str) -> EngineProblem {
291 let EngineProblem::Refused { output, .. } = &problem else { return problem };
292 match known::recognise(output) {
293 Some(Known::NoPermission) => {
294 EngineProblem::NoPermission { output: output.clone(), in_group: in_group(group, "docker", user) }
295 }
296 Some(Known::NoIdRanges) => EngineProblem::NoIdRanges { line: IdRanges::here().line(), output: output.clone() },
297 _ => problem,
298 }
299}
300
301fn in_group(text: &str, name: &str, user: &str) -> bool {
303 text.lines().any(|line| {
304 let mut fields = line.split(':');
305 fields.next() == Some(name)
306 && fields.nth(2).is_some_and(|members| members.split(',').any(|member| member.trim() == user))
307 })
308}
309
310#[must_use]
319pub fn check_location(path: &Path) -> LocationCheck {
320 if path.is_file() {
321 return LocationCheck::Broken(LocationProblem::NotAFolder);
322 }
323 let store = Store::new(path);
324 if let Err(diagnostic) = store.prepare() {
325 return LocationCheck::Broken(LocationProblem::Blocked(diagnostic.message));
326 }
327 let probe = path.join(format!(".qcode-write-{}", std::process::id()));
328 match std::fs::write(&probe, b"") {
329 Ok(()) => {
330 let _ = std::fs::remove_file(&probe);
331 LocationCheck::Usable
332 }
333 Err(error) => LocationCheck::Broken(LocationProblem::Blocked(error.to_string())),
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::store::{Config, SetupStep};
341
342 fn working() -> Gates {
343 Gates { language: true, engine: EngineCheck::Working, location: LocationCheck::Usable }
344 }
345
346 #[test]
347 fn a_wizard_whose_gates_all_hold_has_no_entry_step() {
348 assert_eq!(working().entry(), None);
349 }
350
351 #[test]
352 fn the_language_gate_is_asked_first() {
353 let gates = Gates { language: false, ..working() };
354 assert_eq!(gates.entry(), Some(SetupStep::Language));
355 }
356
357 #[test]
358 fn a_broken_engine_is_the_entry_even_when_every_later_gate_holds() {
359 let gates = Gates { engine: EngineCheck::Broken(EngineProblem::NotInstalled), ..working() };
360 assert_eq!(gates.entry(), Some(SetupStep::Engine));
361 }
362
363 #[test]
364 fn an_engine_that_was_never_asked_does_not_pass_its_gate() {
365 let gates = Gates { engine: EngineCheck::Unknown, ..working() };
366 assert_eq!(gates.entry(), Some(SetupStep::Engine));
367 let gates = Gates { engine: EngineCheck::Running, ..working() };
368 assert_eq!(gates.entry(), Some(SetupStep::Engine));
369 }
370
371 #[test]
372 fn an_unusable_location_is_the_entry_when_everything_before_it_holds() {
373 let gates = Gates { location: LocationCheck::Broken(LocationProblem::Unset), ..working() };
374 assert_eq!(gates.entry(), Some(SetupStep::Location));
375 }
376
377 #[test]
378 fn a_recorded_step_is_never_jumped_to_over_a_gate_that_fell() {
379 let gates = Gates {
381 language: true,
382 engine: EngineCheck::Broken(EngineProblem::DaemonStopped { output: "no socket".to_owned() }),
383 location: LocationCheck::Usable,
384 };
385 assert_eq!(gates.entry(), Some(SetupStep::Engine));
386 }
387
388 #[test]
389 fn probing_a_fresh_config_stops_at_the_language_gate() {
390 let gates = Gates::probe(&Config::parse_str("code.conf", ""));
393 assert!(!gates.language);
394 assert_eq!(gates.engine, EngineCheck::Unknown);
395 assert_eq!(gates.location, LocationCheck::Unknown);
396 assert_eq!(gates.entry(), Some(SetupStep::Language));
397 }
398
399 #[test]
400 fn a_probe_whose_engine_cannot_be_used_says_the_same_thing_as_asking_that_engine_alone() {
401 let config = Config::parse_str("code.conf", "language = \"tr\"\n\n[engine]\nkind = \"podman\"\n");
404 let refused = |_kind: EngineKind| Err::<Engine, Unavailable>(Unavailable::NotInstalled);
405 let (gates, found) = Gates::probe_with(&config, &refused);
406 assert_eq!(gates.engine, EngineCheck::Broken(EngineProblem::NotInstalled), "{:?}", gates.engine);
407 assert_eq!(gates.engine, check_engine_found(EngineKind::Podman, &refused).0);
408 assert_eq!(found, None, "an engine that cannot be used is none to keep");
409 }
410
411 #[test]
412 fn probing_a_config_without_an_engine_stops_at_the_engine_gate() {
413 let gates = Gates::probe(&Config::parse_str("code.conf", "language = \"tr\"\n"));
414 assert!(gates.language);
415 assert_eq!(gates.engine, EngineCheck::Unknown);
416 assert_eq!(gates.location, LocationCheck::Unknown);
417 }
418
419 #[test]
420 fn a_folder_that_can_be_made_and_written_passes_the_location_gate() {
421 let dir = std::env::temp_dir().join(format!("qcode-setup-usable-{}", std::process::id()));
422 assert_eq!(check_location(&dir), LocationCheck::Usable);
423 assert!(dir.join("Workspaces").is_dir(), "the store tree is made while it is checked");
424 std::fs::remove_dir_all(&dir).expect("the test cleans up after itself");
425 }
426
427 #[test]
428 fn a_path_that_names_a_file_is_not_a_folder() {
429 let file = std::env::temp_dir().join(format!("qcode-setup-file-{}", std::process::id()));
430 std::fs::write(&file, "not a folder").expect("the temporary directory takes a file");
431 assert_eq!(check_location(&file), LocationCheck::Broken(LocationProblem::NotAFolder));
432 std::fs::remove_file(&file).expect("the test cleans up after itself");
433 }
434
435 #[test]
436 fn a_folder_that_cannot_be_made_carries_the_systems_own_reason() {
437 let file = std::env::temp_dir().join(format!("qcode-setup-block-{}", std::process::id()));
438 std::fs::write(&file, "in the way").expect("the temporary directory takes a file");
439 let LocationCheck::Broken(LocationProblem::Blocked(reason)) = check_location(&file.join("QCode")) else {
440 panic!("a folder below a file cannot be made")
441 };
442 assert!(!reason.is_empty(), "the reason is the system's own words");
443 std::fs::remove_file(&file).expect("the test cleans up after itself");
444 }
445
446 #[test]
447 fn every_reason_an_engine_gives_keeps_what_the_screen_has_to_say_about_it() {
448 let problem = EngineProblem::from(Unavailable::NotInstalled);
449 assert_eq!(problem, EngineProblem::NotInstalled);
450 assert_eq!(problem.output(), None);
451
452 let stopped = EngineProblem::from(Unavailable::DaemonStopped { output: "no socket".to_owned() });
453 assert_eq!(stopped.output(), Some("no socket"));
454
455 let machine = EngineProblem::from(Unavailable::MachineStopped { output: "no machine".to_owned() });
456 assert_eq!(machine.output(), Some("no machine"));
457
458 let refused = EngineProblem::from(Unavailable::InfoFailed { code: Some(125), output: "no tty".to_owned() });
459 assert_eq!(refused, EngineProblem::Refused { code: Some(125), output: "no tty".to_owned() });
460
461 let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
462 let unrunnable = EngineProblem::from(Unavailable::NotRunnable { bin: "/usr/bin/podman".into(), error });
463 let EngineProblem::NotRunnable { bin, message } = unrunnable else { panic!("it stays unrunnable") };
464 assert_eq!(bin, std::path::PathBuf::from("/usr/bin/podman"));
465 assert!(!message.is_empty());
466 }
467
468 #[test]
469 fn a_docker_socket_this_account_may_not_open_asks_for_the_group_or_only_for_a_new_login() {
470 let output = "permission denied while trying to connect to the docker API at unix:///var/run/docker.sock";
471 let refused = || EngineProblem::Refused { code: Some(1), output: output.to_owned() };
472 let group = "wheel:x:998:ada\ndocker:x:951:bob,ada\n";
473 assert_eq!(
474 super::refine(refused(), group, "ada"),
475 EngineProblem::NoPermission { output: output.to_owned(), in_group: true }
476 );
477 assert_eq!(
478 super::refine(refused(), group, "carol"),
479 EngineProblem::NoPermission { output: output.to_owned(), in_group: false }
480 );
481 let other = EngineProblem::Refused { code: Some(1), output: "no tty".to_owned() };
482 assert_eq!(super::refine(other.clone(), group, "ada"), other, "anything else is left as it was");
483 }
484
485 #[test]
486 fn a_working_podman_on_an_account_without_id_ranges_is_not_ready_yet() {
487 let ranges = |subuid: &str| super::IdRanges {
488 user: "ada".to_owned(),
489 from_env: true,
490 uid: 1001,
491 subuid: subuid.to_owned(),
492 subgid: subuid.to_owned(),
493 };
494 let missing = super::with_ranges(EngineCheck::Working, &ranges(""));
495 let EngineCheck::Broken(EngineProblem::NoIdRanges { line, .. }) = missing else { panic!("{missing:?}") };
496 assert!(line.contains("--add-subuids 100000-165535"), "{line}");
497 assert_eq!(super::with_ranges(EngineCheck::Working, &ranges("ada:100000:65536\n")), EngineCheck::Working);
498 }
499}