Skip to main content

qcode/ui/profiles/
work.rs

1//! The work the profiles screen asks of the disk and of the engine.
2//!
3//! Everything here blocks, so everything here runs on a task thread and talks back in messages.
4//! Nothing decides what the person is told: a failure carries what went wrong and, where the
5//! engine spoke, the engine's own words, and the screen puts the sentence around it.
6
7use std::path::{Path, PathBuf};
8
9use qframe::widgets::TerminalSession;
10
11use crate::base;
12use crate::engine::names;
13use crate::engine::run::{EngineError, build_image, capture, stream};
14use crate::engine::scratch::Scratch;
15use crate::engine::{Access, ContainerCreate, CopyIn, Engine, Exec, HostUser, ImageBuild, Mount, MountSource, Network};
16use crate::profile::identity::{self, STORE_DIR};
17use crate::profile::own::{self, Own};
18use crate::profile::{Profile, SafeName, account};
19
20use super::recipe;
21use super::shell;
22use super::status::{Readiness, Revision, Status};
23
24/// How long a container that only waits to be `exec`'d into stays up: a day, which outlives any
25/// login and still lets a container forgotten after a crash disappear by itself.
26const IDLE: &str = "86400";
27
28/// Why a piece of work did not do what was asked. The words shown to the person come from the
29/// language files; what is carried here is either the engine's own output or the operating
30/// system's message, both of which are shown as they are.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum Problem {
33    /// The engine ran and refused. The text is everything it printed.
34    Refused(String),
35    /// The engine could not be started at all.
36    Unreachable(String),
37    /// The person stopped it.
38    Cancelled,
39    /// The machine would not do it: a directory that cannot be made, a file that cannot be
40    /// written.
41    Machine(String),
42    /// The container was `exec`'d into and the login was not there afterwards.
43    NoLogin,
44}
45
46impl From<EngineError> for Problem {
47    fn from(error: EngineError) -> Self {
48        match error {
49            EngineError::NotRunnable { error, .. } => Self::Unreachable(error.to_string()),
50            EngineError::Failed(failure) => Self::Refused(failure.output),
51            EngineError::Cancelled { .. } => Self::Cancelled,
52            EngineError::TimedOut { command, after } => {
53                Self::Unreachable(crate::engine::run::timed_out(&command, after))
54            }
55        }
56    }
57}
58
59impl From<base::Failure> for Problem {
60    fn from(failure: base::Failure) -> Self {
61        match failure {
62            base::Failure::Host(error) => Self::Machine(error.to_string()),
63            base::Failure::Engine(error) => error.into(),
64        }
65    }
66}
67
68impl Problem {
69    /// The engine's or the machine's own words, when there are any to show.
70    #[must_use]
71    pub fn output(&self) -> Option<&str> {
72        match self {
73            Self::Refused(text) | Self::Unreachable(text) | Self::Machine(text) => Some(text),
74            Self::Cancelled | Self::NoLogin => None,
75        }
76    }
77}
78
79/// Asks the engine about every profile: whether its image is built and from which recipe, and
80/// whether a login is kept for it.
81#[must_use]
82pub fn probe(engine: &Engine, profiles: &[Profile]) -> Vec<Status> {
83    let volumes = capture(&engine.list_volumes()).ok();
84    profiles
85        .iter()
86        .map(|profile| {
87            // The label is asked for rather than the image's identity: the engine answers only
88            // when the image is there, so the one question says both whether it is and what
89            // it was built from.
90            let asked = capture(&engine.image_label(&profile.image(), recipe::REVISION_LABEL));
91            let (image, revision) = match asked {
92                Ok(label) if label.trim() == recipe::image(profile).revision() => {
93                    (Readiness::Present, Revision::Current)
94                }
95                Ok(_) => (Readiness::Present, Revision::Earlier),
96                Err(_) => (Readiness::Missing, Revision::Unknown),
97            };
98            let identity = match &volumes {
99                Some(listing) => Readiness::of(identity::is_stored(listing, &profile.name)),
100                None => Readiness::Unknown,
101            };
102            Status { name: profile.name.clone(), image, revision, identity }
103        })
104        .collect()
105}
106
107/// Builds a profile's image, handing every line of the build to `line` and stopping when
108/// `cancel` says so. A build that is stopped or fails leaves no image behind.
109///
110/// # Errors
111///
112/// When the context cannot be written, or the engine refuses, fails or is stopped.
113pub fn build(
114    engine: &Engine,
115    profile: &Profile,
116    cancel: &dyn Fn() -> bool,
117    line: &mut dyn FnMut(&str),
118) -> Result<(), Problem> {
119    build_from(engine, &profile.image(), &recipe::image(profile), None, Fresh::No, cancel, line)
120}
121
122/// What a person added to a profile in its shell, as a build puts it back after the recipe.
123#[derive(Debug, Clone, Default)]
124pub struct Additions {
125    /// The commands and the home's paths.
126    pub own: Own,
127    /// The archive of the home's files, when there is one.
128    pub archive: Option<PathBuf>,
129}
130
131impl Additions {
132    /// What was added to `profile`, read from the store's `Profiles/` folder at `profiles`. A
133    /// file that cannot be read adds nothing, and the rebuild's log is not the place it is
134    /// reported: the profile's list says so.
135    #[must_use]
136    pub fn of(profiles: &Path, profile: &SafeName) -> Self {
137        Self { own: Own::load(profiles, profile).0, archive: shell::archive_of(profiles, profile) }
138    }
139}
140
141/// Whether a build may reuse the layers an earlier one left.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143enum Fresh {
144    /// It may: the image is not there, and whatever an earlier build left is as good as new.
145    No,
146    /// It may not, and a build that does not finish leaves the image that was there before.
147    Rebuild,
148}
149
150/// Writes the build context of `recipe` and builds `image` from it.
151fn build_from(
152    engine: &Engine,
153    image: &str,
154    recipe: &recipe::Recipe,
155    additions: Option<&Additions>,
156    fresh: Fresh,
157    cancel: &dyn Fn() -> bool,
158    line: &mut dyn FnMut(&str),
159) -> Result<(), Problem> {
160    // The image's name as a folder name: `qcode/profile/x` has slashes a folder cannot.
161    let folder = Scratch::new(&format!("build-{}", image.replace('/', "-")))
162        .map_err(|error| Problem::Machine(error.to_string()))?;
163    let context = folder.path();
164    let containerfile = context.join("Containerfile");
165    // The additions come after the label, so a profile's image says it was built from its
166    // recipe whatever was added to it, as an image the shell committed does.
167    let tail = additions.map(|added| added.own.containerfile_tail(added.archive.is_some())).unwrap_or_default();
168    let written = std::fs::write(&containerfile, recipe.labelled() + &tail).and_then(|()| {
169        if let Some(archive) = additions.and_then(|added| added.archive.as_ref()) {
170            std::fs::copy(archive, context.join(own::HOME_ARCHIVE))?;
171        }
172        for (path, contents) in &recipe.files {
173            let target = context.join(path);
174            if let Some(parent) = target.parent() {
175                std::fs::create_dir_all(parent)?;
176            }
177            std::fs::write(&target, contents)?;
178        }
179        Ok(())
180    });
181    let result = match written {
182        Ok(()) => {
183            let request = ImageBuild { image, containerfile: &containerfile, context };
184            match fresh {
185                Fresh::No => build_image(engine, &request, cancel, line).map_err(Problem::from),
186                // Not `build_image`, whose rule is to take the name away from anything but a
187                // finished build: here the name is on the image the person has been working in,
188                // and a rebuild that fails or is stopped must leave them that one. Measured on
189                // both engines: a build that fails at a step, or is killed halfway, never moves
190                // the name, which only a finished build does.
191                Fresh::Rebuild => stream(&engine.rebuild_image(&request), cancel, line).map_err(Problem::from),
192            }
193        }
194        Err(error) => Err(Problem::Machine(error.to_string())),
195    };
196    drop(folder);
197    result
198}
199
200/// Builds a profile's image the whole way: its system's base image first when the engine does
201/// not have that one either, then the profile's own on top of it.
202///
203/// It is the build the profile wizard runs, and the one a workspace runs when it opens a tab of
204/// a profile whose image this engine lacks, so the two can never build a profile differently.
205/// `base_started` is told once, at the first line of a base image build, because a base that is
206/// already there builds silently and a log that suddenly shows another image needs saying why.
207///
208/// # Errors
209///
210/// When a build context cannot be written, or the engine refuses, fails or is stopped.
211pub fn build_whole(
212    engine: &Engine,
213    profile: &Profile,
214    cancel: &dyn Fn() -> bool,
215    base_started: &mut dyn FnMut(),
216    line: &mut dyn FnMut(&str),
217) -> Result<(), Problem> {
218    ensure_base(engine, profile, cancel, base_started, line)?;
219    build(engine, profile, cancel, line)
220}
221
222/// [`build_whole`] of a profile of the store whose `Profiles/` folder is `profiles`, with what
223/// the person added to it in its shell put back after the recipe: an engine that never had the
224/// image, or lost it, gets the one the person made.
225///
226/// # Errors
227///
228/// As [`build_whole`].
229pub fn build_whole_in(
230    engine: &Engine,
231    profile: &Profile,
232    profiles: &Path,
233    cancel: &dyn Fn() -> bool,
234    base_started: &mut dyn FnMut(),
235    line: &mut dyn FnMut(&str),
236) -> Result<(), Problem> {
237    ensure_base(engine, profile, cancel, base_started, line)?;
238    let additions = Additions::of(profiles, &profile.name);
239    build_from(engine, &profile.image(), &recipe::image(profile), Some(&additions), Fresh::No, cancel, line)
240}
241
242/// Builds the image of a profile that has one again, from the recipe this QCode writes for it
243/// and from its first step, so that what the recipe installs is fetched again too.
244///
245/// Nothing of the person's is in an image: the homes of the workspaces, the login and the
246/// conversations are volumes, and a container made from the new image mounts the same ones. A
247/// container still running from the old image goes on running from it; the next time one of the
248/// profile's containers is made the new image is used, and the old one is removed once no
249/// container is made from it. A rebuild that fails or is stopped leaves the old image as it was.
250///
251/// After the recipe come what the person added in the profile's shell, read from the store's
252/// `Profiles/` folder at `profiles`: every command they ran there as the administrator, then the
253/// home's files they changed. A command that fails fails the rebuild, the engine's log names it,
254/// and the image that was there stays.
255///
256/// # Errors
257///
258/// When a build context cannot be written, or the engine refuses, fails or is stopped.
259pub fn rebuild(
260    engine: &Engine,
261    profile: &Profile,
262    profiles: &Path,
263    cancel: &dyn Fn() -> bool,
264    base_started: &mut dyn FnMut(),
265    line: &mut dyn FnMut(&str),
266) -> Result<(), Problem> {
267    ensure_base(engine, profile, cancel, base_started, line)?;
268    let additions = Additions::of(profiles, &profile.name);
269    rebuild_from(engine, &profile.image(), &recipe::image(profile), Some(&additions), cancel, line)
270}
271
272/// Builds `image` again from `recipe`, from its first step, lets the image it replaces go when
273/// nothing is made from it, and takes away the images of ours that earlier rebuilds left behind;
274/// the whole of [`rebuild`] but the base image.
275///
276/// # Errors
277///
278/// When a build context cannot be written, or the engine refuses, fails or is stopped.
279pub(crate) fn rebuild_from(
280    engine: &Engine,
281    image: &str,
282    recipe: &recipe::Recipe,
283    additions: Option<&Additions>,
284    cancel: &dyn Fn() -> bool,
285    line: &mut dyn FnMut(&str),
286) -> Result<(), Problem> {
287    let before = capture(&engine.image_exists(image)).ok();
288    build_from(engine, image, recipe, additions, Fresh::Rebuild, cancel, line)?;
289    // The old image keeps its layers on the disk under no name at all. A running container still
290    // holds it and the engine then refuses, which is the answer wanted: the container goes on,
291    // and the next workspace container made for the profile removes the image after it.
292    let after = capture(&engine.image_exists(image)).ok();
293    if let Some(before) = before.as_deref().map(str::trim)
294        && after.as_deref().map(str::trim) != Some(before)
295    {
296        let _ = capture(&engine.remove_unused_image(before));
297    }
298    // A build is the moment images of ours pile up: every one a rebuild replaced kept its gigabytes
299    // under no name, and the ones this build could not account for — a container still holding
300    // them, a QCode closed mid-build — are what it leaves. So it asks which of ours are left and
301    // takes them away, and only those: a build that failed or was stopped above returned already.
302    let _ = clear_leftovers(engine);
303    Ok(())
304}
305
306/// Takes away the profile images of ours that a rebuild left without a name, and answers how many
307/// of them went.
308///
309/// Only untagged images carrying [`PROFILE_LABEL`](crate::engine::names::PROFILE_LABEL) are ever
310/// asked about, and each is removed without a `--force`: the engine refuses the one a container is
311/// still made from, and that refusal is the answer wanted, since the container goes on running and
312/// the image goes with the last of them. A refusal is otherwise not worth a word, so it is
313/// ignored; an engine that cannot say which images are ours at all is asked nothing else either,
314/// because there is no wider question to fall back on.
315#[must_use]
316pub fn clear_leftovers(engine: &Engine) -> usize {
317    let Ok(listed) = capture(&engine.our_leftover_images()) else { return 0 };
318    listed
319        .lines()
320        .map(str::trim)
321        .filter(|id| !id.is_empty())
322        .filter(|id| capture(&engine.remove_unused_image(id)).is_ok())
323        .count()
324}
325
326/// Makes sure the base image of the profile's system is there and current, telling
327/// `base_started` once when it has to be built.
328fn ensure_base(
329    engine: &Engine,
330    profile: &Profile,
331    cancel: &dyn Fn() -> bool,
332    base_started: &mut dyn FnMut(),
333    line: &mut dyn FnMut(&str),
334) -> Result<(), Problem> {
335    let mut announced = false;
336    base::ensure_os(engine, profile.os, cancel, &mut |text| {
337        if !announced {
338            announced = true;
339            base_started();
340        }
341        line(text);
342    })?;
343    Ok(())
344}
345
346/// A container opened for a login, and the directory it drops the login into.
347#[derive(Debug)]
348pub struct LoginContainer {
349    /// The container the harness signs in from.
350    pub name: String,
351    /// The directory on this machine the container copies the login into.
352    pub capture: PathBuf,
353    /// The private folder `capture` is, when this login made it; it goes when this does, so a
354    /// login that ends any way at all leaves no token on the machine.
355    folder: Option<Scratch>,
356}
357
358impl LoginContainer {
359    /// A container that copies its login into `capture`, a folder somebody else made and takes
360    /// away.
361    #[must_use]
362    pub fn into_folder(name: String, capture: PathBuf) -> Self {
363        Self { name, capture, folder: None }
364    }
365}
366
367/// Creates and starts the container a login happens in.
368///
369/// The container always reaches the network, whatever the profile says: a login that cannot
370/// reach the account it signs in to is not a login. It sees one directory of this machine, which
371/// is where the login leaves the container afterwards.
372///
373/// # Errors
374///
375/// When the directory cannot be made or the engine refuses.
376pub fn open_login(engine: &Engine, profile: &Profile) -> Result<LoginContainer, Problem> {
377    let name = login_container(&profile.name);
378    // A container left behind by a login that was interrupted must not be signed in to again.
379    let _ = capture(&engine.remove_container(&name));
380    let folder =
381        Scratch::new(&format!("login-{}", profile.name)).map_err(|error| Problem::Machine(error.to_string()))?;
382    let user = HostUser::current().map_err(|error| Problem::Machine(error.to_string()))?;
383    let mounts = [Mount {
384        source: MountSource::Path(folder.path()),
385        target: Path::new(recipe::CAPTURE_DIR),
386        access: Access::ReadWrite,
387    }];
388    let request = ContainerCreate {
389        name: &name,
390        hostname: names::HOSTNAME,
391        labels: &[],
392        image: &profile.image(),
393        mounts: &mounts,
394        network: Network::Full,
395        user,
396        workdir: None,
397        command: &["sleep", IDLE],
398    };
399    let started = capture(&engine.create_container(&request)).and_then(|_| capture(&engine.start_container(&name)));
400    match started {
401        Ok(_) => Ok(LoginContainer { name, capture: folder.path().to_owned(), folder: Some(folder) }),
402        Err(error) => Err(error.into()),
403    }
404}
405
406/// Starts the harness in `container` on a pseudo-terminal, so the person signs in with the
407/// harness's own flow.
408///
409/// The harness is started plainly, without the arguments that let it work unattended: this run
410/// exists to sign in, not to do work.
411///
412/// # Errors
413///
414/// When no pseudo-terminal can be opened or the engine cannot be started.
415pub fn start_harness(engine: &Engine, profile: &Profile, container: &str) -> Result<TerminalSession, Problem> {
416    let command = [profile.harness.record().command];
417    let request = engine.exec(&Exec { container, command: &command });
418    TerminalSession::spawn(request.program.as_os_str(), &request.args, &std::env::temp_dir())
419        .map_err(|error| Problem::Machine(error.to_string()))
420}
421
422/// Takes the login the person just made out of `container` and puts it in the profile's
423/// credentials volume, and answers how many files it stored.
424///
425/// Nothing is taken on trust. The copy runs inside the container and fails when the login file
426/// is not there, so a login that did not happen cannot be mistaken for one that did; the volume
427/// is only created once there is something to put in it, which is why the volume's existence is
428/// what the list reads as "signed in".
429///
430/// # Errors
431///
432/// When the login is not there, or the engine refuses.
433pub fn store_login(engine: &Engine, profile: &Profile, container: &LoginContainer) -> Result<usize, Problem> {
434    let script = recipe::capture_script(profile);
435    let command = ["sh", "-c", script.as_str()];
436    // The script ends with a failure when the login file is not there, so a refusal from the
437    // engine here is the script's own answer rather than the engine's.
438    match capture(&engine.exec_without_terminal(&Exec { container: &container.name, command: &command })) {
439        Ok(_) => {}
440        Err(EngineError::Failed(_)) => return Err(Problem::NoLogin),
441        Err(error) => return Err(error.into()),
442    }
443    // The half of the login a harness keeps among its other settings goes along, or a workspace
444    // given the files alone is asked to sign in again (see `profile::account`).
445    if let Some(take) = account::take(profile.harness, recipe::CAPTURE_DIR) {
446        let words: Vec<&str> = take.iter().map(String::as_str).collect();
447        capture(&engine.exec_without_terminal(&Exec { container: &container.name, command: &words }))?;
448    }
449    let stored = count_files(&container.capture);
450    if stored == 0 {
451        return Err(Problem::NoLogin);
452    }
453
454    let holder = credentials_container(&profile.name);
455    let _ = capture(&engine.remove_container(&holder));
456    let volume = names::credential_volume(profile.name.as_str());
457    let user = HostUser::current().map_err(|error| Problem::Machine(error.to_string()))?;
458    let mounts =
459        [Mount { source: MountSource::Volume(&volume), target: Path::new(STORE_DIR), access: Access::ReadWrite }];
460    let request = ContainerCreate {
461        name: &holder,
462        hostname: names::HOSTNAME,
463        labels: &[],
464        image: &profile.image(),
465        mounts: &mounts,
466        network: Network::None,
467        user,
468        workdir: None,
469        command: &["sleep", IDLE],
470    };
471    let result = capture(&engine.create_container(&request))
472        .and_then(|_| capture(&engine.start_container(&holder)))
473        .and_then(|_| {
474            capture(&engine.copy_in(&CopyIn {
475                host: &container.capture.join("."),
476                container: &holder,
477                target: Path::new(STORE_DIR),
478            }))
479        });
480    let _ = capture(&engine.remove_container(&holder));
481    match result {
482        Ok(_) => Ok(stored),
483        Err(error) => {
484            // Nothing reached the volume, so nothing may be left claiming a login is there.
485            let _ = capture(&engine.remove_volume(&volume));
486            Err(error.into())
487        }
488    }
489}
490
491/// Removes the container a login was made in and the directory it used, whether the login
492/// finished, failed or was interrupted. Anything that cannot be removed is left rather than
493/// reported: the person is done with it either way, and the next login starts by clearing it.
494pub fn close_login(engine: &Engine, container: &LoginContainer) {
495    let _ = capture(&engine.remove_container(&container.name));
496    // Only a folder this login made is its to remove; one it was handed goes with its maker.
497    if let Some(folder) = &container.folder {
498        let _ = std::fs::remove_dir_all(folder.path());
499    }
500}
501
502/// Removes a profile's credentials volume, which is what signing out means.
503///
504/// # Errors
505///
506/// When the engine refuses, for instance because a container still holds the volume.
507pub fn sign_out(engine: &Engine, profile: &SafeName) -> Result<(), Problem> {
508    let volume = identity::sign_out(profile);
509    match capture(&engine.remove_volume(&volume)) {
510        Ok(_) => Ok(()),
511        Err(error) => Err(error.into()),
512    }
513}
514
515/// How many files ended up in a captured login.
516fn count_files(folder: &Path) -> usize {
517    let Ok(entries) = std::fs::read_dir(folder) else { return 0 };
518    entries
519        .flatten()
520        .map(|entry| {
521            let path = entry.path();
522            if path.is_dir() { count_files(&path) } else { 1 }
523        })
524        .sum()
525}
526
527/// The container a profile's login is made in.
528fn login_container(profile: &SafeName) -> String {
529    format!("qcode-login-{profile}")
530}
531
532/// The container that holds a profile's credentials volume open while a login is put into it.
533fn credentials_container(profile: &SafeName) -> String {
534    format!("qcode-store-{profile}")
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::engine::names::BASE_IMAGE;
541
542    #[test]
543    fn a_problem_carries_the_engines_own_words() {
544        let refused = Problem::Refused("Error: no such image qcode/base".to_owned());
545        assert_eq!(refused.output(), Some("Error: no such image qcode/base"));
546        assert_eq!(Problem::NoLogin.output(), None);
547        assert_eq!(Problem::Cancelled.output(), None);
548    }
549
550    #[test]
551    fn the_base_image_is_what_a_profile_image_is_built_on() {
552        // The name is a contract with the engine layer; a profile image cannot exist without it.
553        assert_eq!(BASE_IMAGE, "qcode/base");
554    }
555
556    /// A stand-in engine that writes every call down, answers `listed` when it is asked for the
557    /// images of ours that carry no name, and refuses each call in `refused` the way an engine
558    /// refuses an image a container is made from.
559    ///
560    /// The refusal is the whole rule: the image stays, the call fails, and the one beside it goes.
561    fn recording(name: &str, listed: &str, refused: &[&str]) -> (Engine, PathBuf, PathBuf) {
562        let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
563        let folder = std::env::temp_dir().join(format!("qcode-work-{name}-{stamp}"));
564        std::fs::create_dir_all(&folder).expect("a folder");
565        let (binary, calls) = (folder.join("engine"), folder.join("calls"));
566        let refusing = refused
567            .iter()
568            .map(|call| format!("  '{call}'*) printf 'Error: the image is in use by a container\\n' >&2; exit 125 ;;"))
569            .collect::<Vec<String>>()
570            .join("\n");
571        let script = format!(
572            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{calls}'\ncase \"$1 $2 $3\" in\n{refusing}\nesac\n\
573             case \"$1 $2\" in\n  'images --quiet') printf '{listed}';;\nesac\nexit 0\n",
574            calls = calls.display(),
575        );
576        std::fs::write(&binary, script).expect("the stand-in engine");
577        std::fs::set_permissions(&binary, std::os::unix::fs::PermissionsExt::from_mode(0o755)).expect("runnable");
578        (Engine::new(crate::engine::EngineKind::Podman, &binary), calls, folder)
579    }
580
581    /// Every call the stand-in engine was given, in order.
582    fn asked(calls: &Path) -> Vec<String> {
583        std::fs::read_to_string(calls).unwrap_or_default().lines().map(str::to_owned).collect()
584    }
585
586    /// The whole rule in one sweep: an image of ours with no name and no container made from it
587    /// goes, the one a container holds stays, and nothing but those two images is ever touched.
588    #[test]
589    fn an_image_of_ours_goes_only_while_no_container_is_made_from_it() {
590        let (engine, calls, folder) = recording("leftovers", "sha-one\\nsha-two\\n", &["image rm sha-two"]);
591        assert_eq!(clear_leftovers(&engine), 1, "one of the two went");
592        assert_eq!(
593            asked(&calls),
594            [
595                "images --quiet --no-trunc --filter dangling=true --filter label=qcode.profile",
596                "image rm sha-one",
597                "image rm sha-two",
598            ],
599            "the listing and one plain removal each: {:#?}",
600            asked(&calls)
601        );
602        let _ = std::fs::remove_dir_all(&folder);
603    }
604
605    /// An engine that cannot say which images are ours is asked nothing else: there is no wider
606    /// question behind this one, and a person who never made an image should never feel one.
607    #[test]
608    fn an_engine_that_cannot_list_the_leftovers_is_asked_to_take_nothing_away() {
609        let (engine, calls, folder) = recording("no-leftovers", "", &["images --quiet"]);
610        assert_eq!(clear_leftovers(&engine), 0);
611        assert_eq!(
612            asked(&calls),
613            ["images --quiet --no-trunc --filter dangling=true --filter label=qcode.profile"],
614            "{:#?}",
615            asked(&calls)
616        );
617        let _ = std::fs::remove_dir_all(&folder);
618    }
619}