Skip to main content

qcode/ui/profiles/
wizard.rs

1//! The profile wizard's state: the seven steps (six for a profile that signs in to nothing), what
2//! has been chosen on each of them, and how far the image build and the login have got.
3//!
4//! The state changes by message only. Nothing here starts work or waits for it, so every state
5//! the screen can be in — including a failed build and an interrupted login — is reached in a
6//! test without a container engine anywhere near it.
7
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use qframe::runtime::TaskId;
12use qframe::text::fuzzy;
13use qframe::widgets::{LogBuffer, TerminalSession};
14
15use crate::base::{Os, Refusal};
16use crate::profile::{
17    AccountKind, Extra, HarnessKind, MountAccess, NetworkMode, Pick, Profile, ProviderChoice, SafeName, Template,
18};
19use crate::provider::{Lineup, Model, Price, ProviderEntry};
20use crate::ui::stalling;
21
22use crate::desktop::callback;
23use crate::desktop::login::SignIn;
24
25use super::recipe;
26use super::work::{LoginContainer, Problem};
27
28/// How many lines of a build are kept. A build talks for minutes; this is more than any of them
29/// says and still a fixed amount of memory.
30const LOG_LINES: usize = 4000;
31
32/// One page of the wizard.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Stage {
35    /// Which harness, and what the profile is called.
36    Harness,
37    /// Which operating system the image is built on. Right after the harness, because a system
38    /// can refuse a harness, and the person should learn that before choosing anything else.
39    System,
40    /// The harness as it comes, or set up the way QCode runs it.
41    Template,
42    /// What the profile signs in with.
43    Account,
44    /// What the container may see and reach.
45    Permissions,
46    /// Building the image the profile's containers start from.
47    Image,
48    /// Signing in, so that workspaces that use the profile have a login to copy.
49    Login,
50}
51
52impl Stage {
53    /// Every page, in order.
54    pub const ALL: [Self; 7] =
55        [Self::Harness, Self::System, Self::Template, Self::Account, Self::Permissions, Self::Image, Self::Login];
56
57    /// The pages of a profile that has no login: everything up to the image. The sign-in page is
58    /// last so that leaving it out moves no other page.
59    pub const WITHOUT_LOGIN: [Self; 6] =
60        [Self::Harness, Self::System, Self::Template, Self::Account, Self::Permissions, Self::Image];
61
62    /// Which page this is, counting from the first.
63    #[must_use]
64    pub fn index(self) -> usize {
65        Self::ALL.iter().position(|stage| *stage == self).unwrap_or(0)
66    }
67
68    /// The page at `index`, if there is one.
69    #[must_use]
70    pub fn at(index: usize) -> Option<Self> {
71        Self::ALL.get(index).copied()
72    }
73}
74
75/// How far the image build has got.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Build {
78    /// It has not been started.
79    Waiting,
80    /// It is running; the task is kept so it can be stopped.
81    Running(TaskId),
82    /// The image is built.
83    Done,
84    /// The engine refused or could not be reached; nothing was left behind.
85    Failed(Problem),
86    /// The person stopped it; the half-made image was removed.
87    Stopped,
88}
89
90/// Why a login ended without one.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Unfinished {
93    /// The person stopped it, or left the step.
94    Stopped,
95    /// The harness closed, or the person said they were done, and no login file was there.
96    Missing,
97}
98
99/// How far the login has got.
100#[derive(Debug)]
101pub enum Login {
102    /// It has not been started: the page explains what is about to happen.
103    Waiting,
104    /// The container is being made.
105    Opening,
106    /// The harness is running on a terminal and the person is signing in.
107    Running {
108        /// The terminal the harness draws on.
109        session: TerminalSession,
110        /// The container it runs in, and where its login will be picked up. It is shared
111        /// because whoever clears it away — the page, or the wizard closing — may not be the
112        /// one holding it.
113        container: Arc<LoginContainer>,
114    },
115    /// The harness's window is open on the person's screen and they are signing in there.
116    Window(WindowLogin),
117    /// The harness has stopped and the login is being looked for and stored.
118    Storing,
119    /// A login was found and stored; the number is how many files it took.
120    Stored(usize),
121    /// No login was stored, and the profile says so rather than pretending.
122    Unfinished(Unfinished),
123    /// The engine or the machine refused.
124    Failed(Problem),
125}
126
127impl Login {
128    /// Whether a login was stored. Only a stored login counts; everything else, including a
129    /// terminal that closed by itself, does not.
130    #[must_use]
131    pub fn is_stored(&self) -> bool {
132        matches!(self, Self::Stored(_))
133    }
134
135    /// Whether something is going on that the person should not be interrupted in the middle of.
136    #[must_use]
137    pub fn is_busy(&self) -> bool {
138        matches!(self, Self::Opening | Self::Storing)
139    }
140}
141
142/// A window opened for a login: what was made for it, what the wizard is waiting on, and what came
143/// of the sign-in page the window asked for.
144#[derive(Debug)]
145pub struct WindowLogin {
146    /// Which opening of the window this is, so an answer of an earlier one is told apart.
147    pub run: u64,
148    /// The window, its home and this machine's folders. Shared because whoever clears it away —
149    /// the page, or the wizard closing — may not be the one holding it.
150    pub sign_in: Arc<SignIn>,
151    /// The task that looks for the login while the window is open, so it can be stopped.
152    pub looking: Option<TaskId>,
153    /// The page the window asked to have opened last, and where it was shown.
154    pub page: Option<(String, Page)>,
155    /// The way back of that page's sign-in, by its port.
156    pub back: Option<(u16, WindowBack)>,
157}
158
159/// Where the page a sign-in window asked for was shown.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum Page {
162    /// It is on its way to the person's browser.
163    Asked,
164    /// The person's browser opened it.
165    InBrowser,
166    /// Nothing could show it; the person is given the address instead.
167    Nowhere,
168    /// It was held back, because its sign-in could not come back to this machine.
169    Held,
170}
171
172/// What happened to the way back of a window's sign-in.
173#[derive(Debug)]
174pub enum WindowBack {
175    /// QCode listens for it; dropping the stop gives the port up.
176    Listening(callback::Stop),
177    /// It came back and reached the application.
178    Returned,
179    /// Nothing came back in time.
180    TimedOut,
181    /// Another program holds the port.
182    Taken,
183    /// The system would not listen there, in its own words.
184    Refused(String),
185}
186
187impl WindowLogin {
188    /// A window just opened, with nothing asked of it yet.
189    #[must_use]
190    pub fn new(run: u64, sign_in: Arc<SignIn>) -> Self {
191        Self { run, sign_in, looking: None, page: None, back: None }
192    }
193
194    /// Whether the way back on `port` is being listened for already.
195    #[must_use]
196    pub fn listens_on(&self, port: u16) -> bool {
197        matches!(&self.back, Some((listened, WindowBack::Listening(_))) if *listened == port)
198    }
199
200    /// Takes the end of a listening, when it is the one still held: `id` tells a listening that
201    /// was replaced by a later one apart.
202    pub fn came_back(&mut self, id: u64, ending: callback::Ending) {
203        let Some((port, WindowBack::Listening(stop))) = &self.back else { return };
204        if stop.id() != id {
205            return;
206        }
207        let port = *port;
208        self.back = match ending {
209            callback::Ending::Returned => Some((port, WindowBack::Returned)),
210            callback::Ending::TimedOut => Some((port, WindowBack::TimedOut)),
211            callback::Ending::Stopped => None,
212        };
213    }
214}
215
216/// Everything the wizard has been told so far.
217#[derive(Debug)]
218pub struct Draft {
219    /// The page being shown.
220    pub stage: Stage,
221    /// The name as it has been typed.
222    pub name: String,
223    /// Whether the name has been edited by hand; until it has, choosing a harness renames it.
224    pub renamed: bool,
225    /// The chosen harness.
226    pub harness: HarnessKind,
227    /// The row the template page's picker reads: the ready-made set last chosen while the switches
228    /// still read as it, and Custom once they do not ([`Draft::preset`]).
229    pub template: Template,
230    /// The parts the image carries, each switched on or off on the template page.
231    pub parts: Vec<Extra>,
232    /// The chosen account type.
233    pub account: AccountKind,
234    /// The providers the person has added, as the Providers page last had them, for the account
235    /// page to offer when the chosen harness can be pointed at one of its own.
236    pub providers: Vec<ProviderEntry>,
237    /// The tag of the chosen provider, while `account` is [`AccountKind::Provider`].
238    pub provider_tag: Option<String>,
239    /// What is asked for of the chosen provider: one of its models, or one of its lineups, while
240    /// `account` is [`AccountKind::Provider`].
241    pub provider_pick: Option<Pick>,
242    /// What has been typed into the model list's filter, which is what a provider offering
243    /// hundreds of models is read by.
244    pub model_filter: String,
245    /// How `Assets/` is mounted.
246    pub assets: MountAccess,
247    /// What the container may reach.
248    pub network: NetworkMode,
249    /// The system the image is built on.
250    pub os: Os,
251    /// How far the build has got.
252    pub build: Build,
253    /// Every line the build has printed.
254    pub log: LogBuffer,
255    /// When the build last said something, or when it started: what the silence the page warns
256    /// about is measured from.
257    said: Instant,
258    /// Whether that silence has lasted as long as the rule asks, which is what the page says
259    /// under the sentence about the build. Never true of a build that has ended.
260    stuck: bool,
261    /// How far the login has got.
262    pub login: Login,
263    /// The names already taken, so a clash is caught before anything is built.
264    taken: Vec<String>,
265    /// Whether this draft exists only to sign an existing profile in.
266    only_login: bool,
267    /// Whether this draft exists only to build an existing profile's image again.
268    only_rebuild: bool,
269    /// The profile as it stood when the person asked to change it, while the draft changes one
270    /// that exists: what tells a change of the image, or of the sign-in, from one that is not.
271    editing: Option<Profile>,
272}
273
274/// One row of the account page's list of what a profile runs on: a heading, a gap, or something
275/// the person can choose. The list holds both of the provider's lineups and all of its models,
276/// so a row's place in it is not the place of a model in a list of models.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum PickRow<'a> {
279    /// The heading over the chosen provider's lineups, which is there only when it has any.
280    LineupHeading,
281    /// The heading over the chosen provider's models.
282    ModelHeading,
283    /// An empty row between the two sections.
284    Gap,
285    /// One of the provider's lineups, by the person's own name for it.
286    Lineup(&'a Lineup),
287    /// One of the provider's models, by the name the provider itself gives it.
288    Model(&'a Model),
289}
290
291/// Why a step cannot be left yet.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum Blocked {
294    /// The name is empty, or holds nothing a name can be made of.
295    NameEmpty,
296    /// Another profile already has this name.
297    NameTaken,
298    /// The image has not been built.
299    NoImage,
300    /// The account is a provider of one's own, and no provider and model have both been chosen.
301    NoProvider,
302    /// The chosen system does not run the chosen harness, for this reason.
303    Unsupported(Refusal),
304    /// The containers are to reach no network, and the harness means nothing without it.
305    NeedsNetwork,
306}
307
308impl Draft {
309    /// A new draft for a store whose profiles are called `taken`, offering `providers` on the
310    /// account page for a harness that can be pointed at one.
311    #[must_use]
312    pub fn new(taken: impl IntoIterator<Item = String>, providers: Vec<ProviderEntry>) -> Self {
313        let harness = HarnessKind::ALL[0];
314        let mut draft = Self {
315            stage: Stage::Harness,
316            name: String::new(),
317            renamed: false,
318            harness,
319            template: Template::Recommended,
320            parts: Template::Recommended.parts(harness),
321            account: first_account(harness),
322            providers,
323            provider_tag: None,
324            provider_pick: None,
325            model_filter: String::new(),
326            // The assets folder is where the person keeps what the harness is meant to use and
327            // add to, so a new profile may write there unless the person narrows it.
328            assets: MountAccess::ReadWrite,
329            network: NetworkMode::Full,
330            os: Os::Debian,
331            build: Build::Waiting,
332            log: LogBuffer::new(LOG_LINES),
333            said: Instant::now(),
334            stuck: false,
335            login: Login::Waiting,
336            taken: taken.into_iter().collect(),
337            only_login: false,
338            only_rebuild: false,
339            editing: None,
340        };
341        draft.name = draft.suggested_name();
342        draft
343    }
344
345    /// A draft that only signs `profile` in: the image is already there, so the login page is
346    /// the whole of it. This is how a login that was interrupted is picked up again.
347    #[must_use]
348    pub fn for_login(profile: &Profile) -> Self {
349        Self {
350            stage: Stage::Login,
351            name: profile.name.as_str().to_owned(),
352            renamed: true,
353            harness: profile.harness,
354            template: Self::reading(profile.template, &profile.parts(), profile.harness, profile.os),
355            parts: profile.parts(),
356            account: profile.account,
357            providers: Vec::new(),
358            provider_tag: profile.provider.as_ref().map(|provider| provider.tag.clone()),
359            provider_pick: profile.provider.as_ref().map(|provider| provider.pick.clone()),
360            model_filter: String::new(),
361            assets: profile.assets,
362            network: profile.network,
363            os: profile.os,
364            build: Build::Done,
365            log: LogBuffer::new(LOG_LINES),
366            said: Instant::now(),
367            stuck: false,
368            login: Login::Waiting,
369            taken: Vec::new(),
370            only_login: true,
371            only_rebuild: false,
372            editing: None,
373        }
374    }
375
376    /// A draft that only builds `profile`'s image again: the image page is the whole of it, and
377    /// the build has not started. Nothing of the profile changes but its image.
378    #[must_use]
379    pub fn for_rebuild(profile: &Profile) -> Self {
380        Self {
381            stage: Stage::Image,
382            build: Build::Waiting,
383            only_login: false,
384            only_rebuild: true,
385            ..Self::for_login(profile)
386        }
387    }
388
389    /// A draft that changes `profile`: the whole wizard, from its first page, with everything the
390    /// profile says already chosen and `providers` offered on the account page. The name stays as
391    /// it is, because the profile's image, its login and every workspace's home of it are named
392    /// after it.
393    #[must_use]
394    pub fn for_edit(profile: &Profile, providers: Vec<ProviderEntry>) -> Self {
395        let mut draft = Self {
396            stage: Stage::Harness,
397            build: Build::Waiting,
398            only_login: false,
399            editing: Some(profile.clone()),
400            ..Self::for_login(profile)
401        };
402        draft.offer_providers(providers);
403        draft
404    }
405
406    /// Whether this draft changes a profile that exists.
407    #[must_use]
408    pub fn is_editing(&self) -> bool {
409        self.editing.is_some()
410    }
411
412    /// Whether saving the changes builds the image again: the recipe of the profile as it is now
413    /// is not the recipe it was built from. The harness, the template and its parts, the system,
414    /// and an account whose kind the image is kept to all change the recipe; the network, the
415    /// access to `Assets/` and the provider's model do not, since they belong to the containers.
416    #[must_use]
417    pub fn rebuilds(&self) -> bool {
418        let (Some(before), Some(after)) = (&self.editing, self.profile()) else { return false };
419        recipe::image(before).revision() != recipe::image(&after).revision()
420    }
421
422    /// Whether the changes need a new sign-in: the profile signs in, and either with another kind
423    /// of account or to another harness than the login it has was made for.
424    #[must_use]
425    pub fn signs_in_again(&self) -> bool {
426        let Some(before) = &self.editing else { return false };
427        self.account.needs_login() && (self.account != before.account || self.harness != before.harness)
428    }
429
430    /// Whether the build failed because a step of `template`'s set could not download what it
431    /// installs: the step said so in `words`, or it was graphify's step, which says so in words of
432    /// its own whatever the set ([`recipe::GRAPHIFY_FAILED`]) and is read as the failure of the set
433    /// this draft is of.
434    fn download_failed(&self, words: &str, template: Template) -> bool {
435        if !matches!(self.build, Build::Failed(_)) {
436            return false;
437        }
438        // The set the image was built for, which is the one to name: a draft whose switches were
439        // changed builds a set of its own whatever row the picker last rested on.
440        let ours = self.profile().is_some_and(|profile| profile.template == template);
441        self.log
442            .iter()
443            .any(|line| line.text().contains(words) || (ours && line.text().contains(recipe::GRAPHIFY_FAILED)))
444    }
445
446    /// Whether the build failed because a step of QCode extra could not download what it
447    /// installs, which the step says in words of its own (`recipe::EXTRA_FAILED`) in the log.
448    #[must_use]
449    pub fn extra_download_failed(&self) -> bool {
450        self.download_failed(recipe::EXTRA_FAILED, Template::High)
451    }
452
453    /// Whether the build failed because a step of Quvyta development could not download what it
454    /// installs, which the step says in words of its own (`recipe::DEV_FAILED`) in the log.
455    #[must_use]
456    pub fn dev_download_failed(&self) -> bool {
457        self.download_failed(recipe::DEV_FAILED, Template::QuvytaDev)
458    }
459
460    /// Whether the build failed because a step of QCode recommended could not download what it
461    /// installs, which the step says in words of its own (`recipe::RECOMMENDED_FAILED`) in the log.
462    #[must_use]
463    pub fn recommended_download_failed(&self) -> bool {
464        self.download_failed(recipe::RECOMMENDED_FAILED, Template::Recommended)
465    }
466
467    /// Whether the build failed because a step of oh my opencode slim could not download what it
468    /// installs, which the step says in words of its own (`recipe::SLIM_FAILED`) in the log.
469    #[must_use]
470    pub fn slim_download_failed(&self) -> bool {
471        self.download_failed(recipe::SLIM_FAILED, Template::Slim)
472    }
473
474    /// Whether the build failed because a step of a profile whose parts are its own could not
475    /// download what it installs, which the step says in words of its own
476    /// (`recipe::CUSTOM_FAILED`) in the log.
477    #[must_use]
478    pub fn custom_download_failed(&self) -> bool {
479        self.download_failed(recipe::CUSTOM_FAILED, Template::Custom)
480    }
481
482    /// Whether this draft is only a login for a profile that already exists.
483    #[must_use]
484    pub fn is_only_login(&self) -> bool {
485        self.only_login
486    }
487
488    /// Whether this draft only builds the image of a profile that already exists again.
489    #[must_use]
490    pub fn is_only_rebuild(&self) -> bool {
491        self.only_rebuild
492    }
493
494    /// The name a profile of this harness gets while nobody has typed one.
495    fn suggested_name(&self) -> String {
496        self.harness.record().id.to_owned()
497    }
498
499    /// Chooses a harness. While the name has not been touched it follows the harness, and a
500    /// different harness brings its own first account type: the account page comes after this
501    /// one, and each harness offers its own list, headed by the one most people start with.
502    ///
503    /// The parts follow the harness too. A ready-made set stays that set and gives what it gives
504    /// for the new harness, or QCode recommended where the set is not offered. A set of the
505    /// person's own keeps the parts this harness can have, so moving from Claude Code to opencode
506    /// does not take graphify away.
507    pub fn choose_harness(&mut self, harness: HarnessKind) {
508        let changed = harness != self.harness;
509        self.harness = harness;
510        if !self.renamed {
511            self.name = self.suggested_name();
512        }
513        if changed || !harness.supports(self.account) {
514            self.account = first_account(harness);
515        }
516        match self.template {
517            Template::Custom => {
518                let available = Template::available(harness);
519                self.parts.retain(|part| available.contains(part));
520                self.settle();
521            }
522            set if Template::offered(harness).contains(&set) => self.choose(set),
523            _ => self.choose(Template::Recommended),
524        }
525    }
526
527    /// The parts an image can carry on `os`, in the order the page lists them: what was asked
528    /// for, less what the system has none of.
529    fn keepable(os: Os, parts: Vec<Extra>) -> Vec<Extra> {
530        let parts = if os.chromium().is_none() {
531            parts.into_iter().filter(|part| *part != Extra::Chromium).collect()
532        } else {
533            parts
534        };
535        Extra::ordered(parts)
536    }
537
538    /// The row the picker reads for `parts` of a profile of `harness` on `os` that was written
539    /// under `template`.
540    ///
541    /// A ready-made set reads as itself while the switches are what it switches on, as far as the
542    /// system can carry them: Quvyta development on a system without a Chromium that runs in a
543    /// container is still Quvyta development. Otherwise the first ready-made set the switches are
544    /// exactly, and Custom when they are none. opencode's QCode recommended and QCode extra switch
545    /// on the same parts, so which of the two was chosen is kept, not guessed.
546    fn reading(template: Template, parts: &[Extra], harness: HarnessKind, os: Os) -> Template {
547        let offered = Template::offered(harness);
548        let exactly = |set: Template| Self::keepable(os, set.parts(harness)) == Self::keepable(os, parts.to_vec());
549        if template != Template::Custom && offered.contains(&template) && exactly(template) {
550            return template;
551        }
552        offered.into_iter().filter(|set| *set != Template::Custom).find(|set| exactly(*set)).unwrap_or(Template::Custom)
553    }
554
555    /// Drops a part the chosen system has none of, and reads the picker again from the switches.
556    fn settle(&mut self) {
557        self.parts = Self::keepable(self.os, std::mem::take(&mut self.parts));
558        self.template = Self::reading(self.template, &self.parts, self.harness, self.os);
559    }
560
561    /// The provider the tag `tag` names among the ones the person has added, if there is one.
562    #[must_use]
563    pub(super) fn provider_entry(&self, tag: &str) -> Option<&ProviderEntry> {
564        self.providers.iter().find(|provider| provider.tag.as_str() == tag)
565    }
566
567    /// The models of the chosen provider, or none while no provider is chosen or it offers none
568    /// yet: a provider the person has not asked to list is added with an empty list, and its own
569    /// page is where that is fixed.
570    #[must_use]
571    pub fn provider_models(&self) -> &[Model] {
572        self.provider_tag.as_deref().and_then(|tag| self.provider_entry(tag)).map_or(&[], |entry| &entry.models)
573    }
574
575    /// The lineups of the chosen provider, or none while no provider is chosen. A lineup is the
576    /// person's own order of that provider's models, written on the Providers page; a provider
577    /// that has none is a provider that is simply not given one.
578    #[must_use]
579    pub fn provider_lineups(&self) -> &[Lineup] {
580        self.provider_tag.as_deref().and_then(|tag| self.provider_entry(tag)).map_or(&[], |entry| &entry.lineups)
581    }
582
583    /// The chosen provider's own entry, which is where its lineups and its prices are read from.
584    fn chosen_entry(&self) -> Option<&ProviderEntry> {
585        self.provider_tag.as_deref().and_then(|tag| self.provider_entry(tag))
586    }
587
588    /// The model the account page has chosen, when what it has chosen is a model rather than a
589    /// lineup. The list's rows are models as well as lineups, so this is only what a caller that
590    /// asks about the model has an answer for.
591    #[must_use]
592    pub fn chosen_model(&self) -> Option<&String> {
593        match &self.provider_pick {
594            Some(Pick::Model(model)) => Some(model),
595            Some(Pick::Lineup(_)) | None => None,
596        }
597    }
598
599    /// The rows of the account page's list: the chosen provider's lineups first and under a
600    /// heading of their own, then its models under a heading of their own, with a gap between
601    /// them. A provider with no lineup of its own is not given a heading for one, so its list is
602    /// the models alone as it was.
603    ///
604    /// One filter leaves both, because one box on the page filters both: a person who has heard of
605    /// `qwen3-coder` and of the lineup `coder` is looking for either, and a box that found only
606    /// one of them would read as the other not being there. What the filter leaves out is not
607    /// unchosen: the chosen pick is a name, and it stays what it is until another row is pressed.
608    ///
609    /// Nothing at all when the filter leaves neither, so the list says so in its own words rather
610    /// than showing a heading over nothing.
611    #[must_use]
612    pub fn pick_rows(&self) -> Vec<PickRow<'_>> {
613        let Some(entry) = self.chosen_entry() else { return Vec::new() };
614        let wanted = self.model_filter.trim();
615        let keeps = |text: &str| wanted.is_empty() || fuzzy(wanted, text).is_some();
616        let lineups: Vec<&Lineup> = entry.lineups.iter().filter(|lineup| keeps(lineup.name.as_str())).collect();
617        let models: Vec<&Model> = entry.models.iter().filter(|model| keeps(&model.id)).collect();
618        if lineups.is_empty() && models.is_empty() {
619            return Vec::new();
620        }
621        let mut rows = Vec::new();
622        if !lineups.is_empty() {
623            rows.push(PickRow::LineupHeading);
624            rows.extend(lineups.into_iter().map(PickRow::Lineup));
625            rows.push(PickRow::Gap);
626        }
627        rows.push(PickRow::ModelHeading);
628        rows.extend(models.into_iter().map(PickRow::Model));
629        rows
630    }
631
632    /// The row of the list the page stands on: the one the chosen pick is on, while the filter
633    /// leaves it there. A pick the filter hides is on no row, which is not a row selected.
634    #[must_use]
635    pub fn chosen_row(&self) -> Option<usize> {
636        let pick = self.provider_pick.as_ref()?;
637        let rows = self.pick_rows();
638        rows.iter().position(|row| match (row, pick) {
639            (PickRow::Lineup(lineup), Pick::Lineup(name)) => lineup.name.as_str() == name,
640            (PickRow::Model(model), Pick::Model(id)) => model.id == *id,
641            _ => false,
642        })
643    }
644
645    /// The pick the row at `index` stands for, and `None` for a heading, a gap, or a row that is
646    /// not there: none of those is something to choose, and a person clicking past the end of a
647    /// list expects nothing to change.
648    #[must_use]
649    pub fn pick_at(&self, index: usize) -> Option<Pick> {
650        match self.pick_rows().get(index)? {
651            PickRow::Lineup(lineup) => Some(Pick::Lineup(lineup.name.to_string())),
652            PickRow::Model(model) => Some(Pick::Model(model.id.clone())),
653            PickRow::LineupHeading | PickRow::ModelHeading | PickRow::Gap => None,
654        }
655    }
656
657    /// The steps a request on the chosen pick would spend money on if it landed on one: a lineup's
658    /// own paid steps, or the one model itself when it is a paid one. Empty for a pick nobody has
659    /// made yet, and for a step whose price nobody published, which is not the same as a free one.
660    #[must_use]
661    pub fn paid_steps(&self) -> Vec<String> {
662        let Some(entry) = self.chosen_entry() else { return Vec::new() };
663        match &self.provider_pick {
664            Some(Pick::Model(id)) => match entry.model(id) {
665                Some(model) if model.price == Some(Price::Paid) => vec![id.clone()],
666                _ => Vec::new(),
667            },
668            Some(Pick::Lineup(name)) => entry
669                .lineup(name)
670                .map_or_else(Vec::new, |lineup| lineup.paid(entry).into_iter().map(str::to_owned).collect()),
671            None => Vec::new(),
672        }
673    }
674
675    /// The steps of the chosen pick whose window nobody has measured, so a harness told nothing
676    /// works to a figure of its own and may lose the front of a long prompt without a word. The
677    /// one model of a single-model pick, or the steps of a lineup no step of which is known.
678    #[must_use]
679    pub fn unknown_windows(&self) -> Vec<String> {
680        let Some(entry) = self.chosen_entry() else { return Vec::new() };
681        match &self.provider_pick {
682            Some(Pick::Model(id)) => match entry.model(id) {
683                Some(model) if model.window(entry.kind).is_none() => vec![id.clone()],
684                _ => Vec::new(),
685            },
686            Some(Pick::Lineup(name)) => entry
687                .lineup(name)
688                .map_or_else(Vec::new, |lineup| lineup.unknown_window(entry).into_iter().map(str::to_owned).collect()),
689            None => Vec::new(),
690        }
691    }
692
693    /// The models of the chosen provider that the filter keeps, in the provider's own order.
694    ///
695    /// A service such as OpenRouter offers hundreds of models and a page holds a handful, so the
696    /// list shows what is asked for. What the filter hides is not unchosen: the chosen model is a
697    /// name, and it stays what it is until another one is chosen.
698    #[must_use]
699    pub fn matching_models(&self) -> Vec<&Model> {
700        let models = self.provider_models();
701        let wanted = self.model_filter.trim();
702        if wanted.is_empty() {
703            return models.iter().collect();
704        }
705        models.iter().filter(|model| fuzzy(wanted, &model.id).is_some()).collect()
706    }
707
708    /// Chooses the provider tagged `tag`, and with it the first lineup it is known to offer, or
709    /// the first model it offers when it has no lineup, so that picking a provider that already has
710    /// something to run on chose it for the person rather than leaving a second, empty choice
711    /// behind it. What was asked for in the provider left behind is of no use here, and a filter
712    /// matching none of this provider's models would show an empty list.
713    pub fn choose_provider(&mut self, tag: &str) {
714        self.provider_tag = Some(tag.to_owned());
715        self.provider_pick = self.first_pick(tag);
716        self.model_filter.clear();
717    }
718
719    /// What a provider offers first: its first lineup, or the first of its models when it has no
720    /// lineup. A lineup comes first because it is the order the person wrote on the Providers
721    /// page — the choice they made about how their money is spent — where a model is one of the
722    /// things it is made of.
723    fn first_pick(&self, tag: &str) -> Option<Pick> {
724        let entry = self.provider_entry(tag)?;
725        match entry.lineups.first() {
726            Some(lineup) => Some(Pick::Lineup(lineup.name.to_string())),
727            None => entry.models.first().map(|model| Pick::Model(model.id.clone())),
728        }
729    }
730
731    /// Whether the provider `tag` still offers `pick`: a model it is last seen to have, or a lineup
732    /// of its own. What a profile chose is a name, and a name the file no longer carries cannot
733    /// be kept — the tab would have nothing to ask.
734    fn offers(&self, tag: &str, pick: &Pick) -> bool {
735        self.provider_entry(tag).is_some_and(|entry| match pick {
736            Pick::Model(id) => entry.models.iter().any(|model| model.id == *id),
737            Pick::Lineup(name) => entry.lineup(name).is_some(),
738        })
739    }
740
741    /// Offers `providers` from now on, as the file has them after the person was away on the
742    /// Providers page. A provider account keeps the provider and what it runs on where they are
743    /// still there, and otherwise takes the first of what is offered now, the way picking the
744    /// account does: the page told the person to add one and come back, so the one they added is
745    /// the one they meant.
746    pub fn offer_providers(&mut self, providers: Vec<ProviderEntry>) {
747        self.providers = providers;
748        if self.account != AccountKind::Provider {
749            return;
750        }
751        let kept = self.provider_tag.clone().filter(|tag| self.provider_entry(tag).is_some());
752        match kept.or_else(|| self.providers.first().map(|entry| entry.tag.to_string())) {
753            Some(tag) => {
754                let pick = self.provider_pick.clone();
755                self.choose_provider(&tag);
756                if let Some(pick) = pick.filter(|pick| self.offers(&tag, pick)) {
757                    self.provider_pick = Some(pick);
758                }
759            }
760            None => {
761                self.provider_tag = None;
762                self.provider_pick = None;
763            }
764        }
765    }
766
767    /// Chooses the model `id` of the provider already chosen.
768    pub fn choose_provider_model(&mut self, id: &str) {
769        self.provider_pick = Some(Pick::Model(id.to_owned()));
770    }
771
772    /// Chooses what the row at `index` of the account page's list stands for: one of the chosen
773    /// provider's lineups, or one of its models. A row that is not there, a heading and a gap
774    /// choose nothing, as a person clicking past the end of a list expects.
775    pub fn choose_pick_row(&mut self, index: usize) {
776        if let Some(pick) = self.pick_at(index) {
777            self.provider_pick = Some(pick);
778        }
779    }
780
781    /// The pages this draft goes through. A profile that signs in to nothing has no sign-in
782    /// page: a step that could only say "nothing to do" would still have to be walked through.
783    ///
784    /// A profile being changed has one only when the changes need a new sign-in: the login it
785    /// has is kept otherwise, and asking for it again would only make the person do it twice.
786    #[must_use]
787    pub fn stages(&self) -> &'static [Stage] {
788        let signs_in = self.account.needs_login() && (self.editing.is_none() || self.signs_in_again());
789        if signs_in { &Stage::ALL } else { &Stage::WITHOUT_LOGIN }
790    }
791
792    /// The profile the draft describes, if the name is usable and, for a provider account, both
793    /// a provider and a model have been chosen.
794    ///
795    /// A ready-made set the picker reads is written as itself. The switches of a set of the
796    /// person's own are written in the terms an older QCode builds the same way where there are
797    /// such terms ([`Template::written_as`]), and as Custom only where there are not.
798    #[must_use]
799    pub fn profile(&self) -> Option<Profile> {
800        let provider = match self.account {
801            AccountKind::Provider => {
802                Some(ProviderChoice { tag: self.provider_tag.clone()?, pick: self.provider_pick.clone()? })
803            }
804            _ => None,
805        };
806        let parts = &self.parts;
807        let template = match self.template {
808            Template::Custom => Template::written_as(parts, self.harness),
809            set => set,
810        };
811        Some(Profile {
812            name: SafeName::from_display(&self.name)?,
813            harness: self.harness,
814            template,
815            account: self.account,
816            provider,
817            assets: self.assets,
818            network: self.network,
819            without: template.parts(self.harness).into_iter().filter(|extra| !parts.contains(extra)).collect(),
820            os: self.os,
821        })
822    }
823
824    /// The row the picker reads.
825    #[must_use]
826    pub fn preset(&self) -> Template {
827        self.template
828    }
829
830    /// Every part this profile can have, whatever the picker reads: the list the page shows.
831    #[must_use]
832    pub fn offered(&self) -> Vec<Extra> {
833        Template::available(self.harness)
834    }
835
836    /// Whether `extra` is switched on.
837    #[must_use]
838    pub fn has(&self, extra: Extra) -> bool {
839        self.parts.contains(&extra)
840    }
841
842    /// Whether anything is downloaded into the image beside the harness: the recommended settings
843    /// are written, not downloaded.
844    #[must_use]
845    pub fn adds_anything(&self) -> bool {
846        self.parts.iter().any(|part| *part != Extra::Settings)
847    }
848
849    /// Switches `extra` on or off, and the picker reads Custom unless the switches are now exactly
850    /// a ready-made set. The two teams of agents opencode has exclude each other: each one
851    /// switched on switches the other off, since a container runs one of them, not both. A part the
852    /// chosen system has none of stays off; its switch is disabled.
853    pub fn switch(&mut self, extra: Extra, on: bool) {
854        if on {
855            let other = match extra {
856                Extra::OhMyOpenAgent => Some(Extra::OhMyOpenCodeSlim),
857                Extra::OhMyOpenCodeSlim => Some(Extra::OhMyOpenAgent),
858                _ => None,
859            };
860            self.parts.retain(|part| Some(*part) != other);
861            if !self.parts.contains(&extra) {
862                self.parts.push(extra);
863            }
864        } else {
865            self.parts.retain(|part| *part != extra);
866        }
867        self.settle();
868    }
869
870    /// Chooses the picker's row `template`: a ready-made set switches on every part it gives and
871    /// every other part off. Custom switches every part off, which is the harness as it comes, the
872    /// start of a set of the person's own.
873    pub fn choose(&mut self, template: Template) {
874        self.parts = match template {
875            Template::Custom => Vec::new(),
876            _ => template.parts(self.harness),
877        };
878        self.template = template;
879        self.settle();
880    }
881
882    /// Chooses the system the image is built on. A part the new system has none of is switched
883    /// off: no image can carry it there.
884    pub fn choose_os(&mut self, os: Os) {
885        self.os = os;
886        self.settle();
887    }
888
889    /// Whether the chosen system has no Chromium that runs in a container, which the page says
890    /// beside the switch it cannot move.
891    #[must_use]
892    pub fn chromium_refused(&self) -> bool {
893        self.os.chromium().is_none()
894    }
895
896    /// The name as it will be written, which is not always the name as it was typed.
897    #[must_use]
898    pub fn safe_name(&self) -> Option<SafeName> {
899        SafeName::from_display(&self.name)
900    }
901
902    /// Why the current page cannot be left, if it cannot.
903    #[must_use]
904    pub fn blocked(&self) -> Option<Blocked> {
905        match self.stage {
906            Stage::Harness => match self.safe_name() {
907                None => Some(Blocked::NameEmpty),
908                Some(name) if self.taken.iter().any(|taken| taken == name.as_str()) => Some(Blocked::NameTaken),
909                Some(_) => None,
910            },
911            // A harness the system cannot run is never built there: the page says why and stays
912            // until the person picks another system or goes back for another harness.
913            Stage::System => self.os.refuses(self.harness).map(Blocked::Unsupported),
914            Stage::Account if self.account == AccountKind::Provider && self.profile_provider().is_none() => {
915                Some(Blocked::NoProvider)
916            }
917            // Reached only by a profile file that asks for both; the page offers no such pair.
918            Stage::Permissions if self.network == NetworkMode::None && !self.harness.offered_offline() => {
919                Some(Blocked::NeedsNetwork)
920            }
921            Stage::Image if self.build != Build::Done => Some(Blocked::NoImage),
922            _ => None,
923        }
924    }
925
926    /// The harnesses the first page offers: all of them, less the ones that mean nothing in a
927    /// container with no network while that is what the profile's containers are to get.
928    #[must_use]
929    pub fn harnesses(&self) -> Vec<HarnessKind> {
930        HarnessKind::ALL
931            .into_iter()
932            .filter(|harness| self.network != NetworkMode::None || harness.offered_offline())
933            .collect()
934    }
935
936    /// The network modes the permissions page offers: both, less no network at all for a harness
937    /// that means nothing without it.
938    #[must_use]
939    pub fn networks(&self) -> Vec<NetworkMode> {
940        NetworkMode::ALL
941            .into_iter()
942            .filter(|mode| *mode != NetworkMode::None || self.harness.offered_offline())
943            .collect()
944    }
945
946    /// The provider and model the account page has chosen so far, when both are there.
947    #[must_use]
948    fn profile_provider(&self) -> Option<ProviderChoice> {
949        Some(ProviderChoice { tag: self.provider_tag.clone()?, pick: self.provider_pick.clone()? })
950    }
951
952    /// Whether the wizard should show its buttons as working and ignore them.
953    #[must_use]
954    pub fn is_busy(&self) -> bool {
955        matches!(self.build, Build::Running(_)) || self.login.is_busy()
956    }
957
958    /// The build has started in `task`, so the silence the page warns about begins now: a build
959    /// that has only just begun has said nothing, and nothing yet.
960    pub fn building(&mut self, task: TaskId) {
961        self.build = Build::Running(task);
962        self.said = Instant::now();
963        self.stuck = false;
964    }
965
966    /// Whether the image build is running.
967    #[must_use]
968    pub fn is_building(&self) -> bool {
969        matches!(self.build, Build::Running(_))
970    }
971
972    /// Whether the build has said nothing for longer than `quiet` as of `now`, which is what the
973    /// page says under its own words about the build. A draft that is not building says no
974    /// whatever its silence is: there is no build to be stuck.
975    pub fn build_looked(&mut self, now: Instant, quiet: Duration) {
976        self.stuck = self.is_building() && stalling::quiet_for(self.said, now, quiet);
977    }
978
979    /// Whether the build has been quiet long enough to be said to be stuck, which is never said of
980    /// a build that has ended.
981    #[must_use]
982    pub fn is_stuck(&self) -> bool {
983        self.stuck
984    }
985
986    /// Notes that the build has said something, so that the silence is counted from this moment and
987    /// the warning under the page's own words goes away.
988    pub fn build_said(&mut self) {
989        self.said = Instant::now();
990        self.stuck = false;
991    }
992
993    /// The build has ended, so there is no build left to be stuck: the warning goes with it here
994    /// rather than standing until the next look noticed, since the page stays up to say how the
995    /// build ended.
996    pub fn build_ended(&mut self) {
997        self.stuck = false;
998    }
999
1000    /// Moves to the next page, unless the current one is blocked or something is running.
1001    pub fn advance(&mut self) -> Option<Blocked> {
1002        if self.is_busy() {
1003            return None;
1004        }
1005        if let Some(blocked) = self.blocked() {
1006            return Some(blocked);
1007        }
1008        if let Some(next) = self.stages().get(self.stage.index() + 1).copied() {
1009            self.stage = next;
1010        }
1011        None
1012    }
1013
1014    /// Moves back a page.
1015    pub fn back(&mut self) {
1016        if self.is_busy() {
1017            return;
1018        }
1019        if let Some(previous) = self.stage.index().checked_sub(1).and_then(|index| self.stages().get(index).copied()) {
1020            self.stage = previous;
1021        }
1022    }
1023
1024    /// Goes back to a page that has been finished. Pages ahead of the current one are not
1025    /// reachable this way, and neither is any page while work is running.
1026    pub fn go_to(&mut self, index: usize) {
1027        if self.is_busy() {
1028            return;
1029        }
1030        if let Some(stage) = self.stages().get(index).copied()
1031            && index < self.stage.index()
1032        {
1033            self.stage = stage;
1034        }
1035    }
1036}
1037
1038/// The account type a harness is offered with before anyone chooses: the first of its own list.
1039fn first_account(harness: HarnessKind) -> AccountKind {
1040    harness.record().accounts.first().copied().unwrap_or(AccountKind::Subscription)
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046
1047    #[test]
1048    fn a_new_draft_is_named_after_its_harness_until_it_is_renamed() {
1049        let mut draft = Draft::new([], Vec::new());
1050        assert_eq!(draft.name, "claude-code");
1051        draft.choose_harness(HarnessKind::Codex);
1052        assert_eq!(draft.name, "codex");
1053        draft.renamed = true;
1054        draft.name = "work".to_owned();
1055        draft.choose_harness(HarnessKind::OpenCode);
1056        assert_eq!(draft.name, "work", "a name typed by hand is never overwritten");
1057    }
1058
1059    #[test]
1060    fn a_name_that_is_already_taken_blocks_the_first_page() {
1061        let mut draft = Draft::new(["claude-code".to_owned()], Vec::new());
1062        assert_eq!(draft.blocked(), Some(Blocked::NameTaken));
1063        draft.name = "  ".to_owned();
1064        assert_eq!(draft.blocked(), Some(Blocked::NameEmpty));
1065        draft.name = "Günlük Çalışma".to_owned();
1066        assert_eq!(draft.blocked(), None);
1067        assert_eq!(draft.safe_name().expect("the name folds").as_str(), "gunluk-calisma");
1068    }
1069
1070    #[test]
1071    fn the_image_page_cannot_be_left_before_there_is_an_image() {
1072        let mut draft = Draft::new([], Vec::new());
1073        draft.stage = Stage::Image;
1074        assert_eq!(draft.blocked(), Some(Blocked::NoImage));
1075        assert_eq!(draft.advance(), Some(Blocked::NoImage));
1076        assert_eq!(draft.stage, Stage::Image);
1077        draft.build = Build::Failed(Problem::NoLogin);
1078        assert_eq!(draft.blocked(), Some(Blocked::NoImage), "a failed build is not an image");
1079        draft.build = Build::Stopped;
1080        assert_eq!(draft.blocked(), Some(Blocked::NoImage), "a stopped build is not an image");
1081        draft.build = Build::Done;
1082        assert_eq!(draft.advance(), None);
1083        assert_eq!(draft.stage, Stage::Login);
1084    }
1085
1086    #[test]
1087    fn nothing_moves_while_the_build_is_running() {
1088        let mut draft = Draft::new([], Vec::new());
1089        draft.stage = Stage::Image;
1090        draft.build = Build::Running(qframe::runtime::Task::<()>::new("build", |_| Ok(())).id());
1091        assert!(draft.is_busy());
1092        assert_eq!(draft.advance(), None);
1093        assert_eq!(draft.stage, Stage::Image, "a running build keeps the page");
1094        draft.back();
1095        assert_eq!(draft.stage, Stage::Image);
1096    }
1097
1098    #[test]
1099    fn a_finished_page_can_be_gone_back_to_and_one_ahead_cannot() {
1100        let mut draft = Draft::new([], Vec::new());
1101        draft.stage = Stage::Permissions;
1102        draft.go_to(Stage::Image.index());
1103        assert_eq!(draft.stage, Stage::Permissions, "a page that has not been reached is not chosen");
1104        draft.go_to(Stage::Template.index());
1105        assert_eq!(draft.stage, Stage::Template);
1106    }
1107
1108    #[test]
1109    fn choosing_a_harness_keeps_the_account_type_one_that_harness_can_use() {
1110        let mut draft = Draft::new([], Vec::new());
1111        draft.account = AccountKind::ApiKey;
1112        draft.choose_harness(HarnessKind::GeminiCli);
1113        assert!(draft.harness.supports(draft.account));
1114    }
1115
1116    #[test]
1117    fn opencode_starts_free_and_the_others_never_offer_it() {
1118        let mut draft = Draft::new([], Vec::new());
1119        assert_eq!(draft.account, AccountKind::Subscription, "Claude Code starts with a subscription");
1120        draft.choose_harness(HarnessKind::OpenCode);
1121        assert_eq!(draft.account, AccountKind::Free, "opencode is chosen with its free use");
1122        draft.account = AccountKind::ApiKey;
1123        draft.choose_harness(HarnessKind::OpenCode);
1124        assert_eq!(draft.account, AccountKind::ApiKey, "choosing the same harness again keeps the account");
1125        for harness in [HarnessKind::ClaudeCode, HarnessKind::GeminiCli, HarnessKind::Codex] {
1126            draft.choose_harness(HarnessKind::OpenCode);
1127            draft.choose_harness(harness);
1128            assert_ne!(draft.account, AccountKind::Free, "{harness:?}");
1129        }
1130    }
1131
1132    #[test]
1133    fn a_free_profile_has_no_sign_in_page_and_ends_with_its_image() {
1134        let mut draft = Draft::new([], Vec::new());
1135        draft.choose_harness(HarnessKind::OpenCode);
1136        assert_eq!(draft.stages(), Stage::WITHOUT_LOGIN);
1137        draft.stage = Stage::Image;
1138        draft.build = Build::Done;
1139        assert_eq!(draft.advance(), None);
1140        assert_eq!(draft.stage, Stage::Image, "there is no page after the image");
1141        draft.go_to(Stage::Login.index());
1142        assert_eq!(draft.stage, Stage::Image);
1143        draft.account = AccountKind::Subscription;
1144        assert_eq!(draft.stages(), Stage::ALL);
1145        draft.advance();
1146        assert_eq!(draft.stage, Stage::Login);
1147    }
1148
1149    #[test]
1150    fn a_new_profile_may_write_to_its_assets_unless_narrowed() {
1151        assert_eq!(Draft::new([], Vec::new()).assets, MountAccess::ReadWrite);
1152    }
1153
1154    #[test]
1155    fn a_login_counts_only_once_it_has_been_stored() {
1156        assert!(!Login::Waiting.is_stored());
1157        assert!(!Login::Unfinished(Unfinished::Missing).is_stored());
1158        assert!(!Login::Unfinished(Unfinished::Stopped).is_stored());
1159        assert!(!Login::Failed(Problem::NoLogin).is_stored());
1160        assert!(Login::Stored(1).is_stored());
1161    }
1162
1163    #[test]
1164    fn a_login_only_draft_starts_on_the_login_page_of_a_profile_that_already_has_an_image() {
1165        let mut source = Draft::new([], Vec::new());
1166        source.name = "claude-sub".to_owned();
1167        let profile = source.profile().expect("the name folds");
1168        let draft = Draft::for_login(&profile);
1169        assert!(draft.is_only_login());
1170        assert_eq!(draft.stage, Stage::Login);
1171        assert_eq!(draft.build, Build::Done, "the image is already there");
1172        assert!(!draft.login.is_stored(), "opening the page signs nothing in");
1173        assert_eq!(draft.profile(), Some(profile));
1174    }
1175
1176    #[test]
1177    fn a_draft_becomes_the_profile_it_describes() {
1178        let mut draft = Draft::new([], Vec::new());
1179        draft.name = "Claude Abonelik".to_owned();
1180        draft.choose(Template::Recommended);
1181        draft.assets = MountAccess::ReadWrite;
1182        draft.network = NetworkMode::None;
1183        let profile = draft.profile().expect("the name folds");
1184        assert_eq!(profile.name.as_str(), "claude-abonelik");
1185        assert_eq!(profile.image(), "qcode/profile/claude-abonelik");
1186        assert_eq!(profile.assets, MountAccess::ReadWrite);
1187        assert_eq!(profile.network, NetworkMode::None);
1188    }
1189
1190    /// A provider entry for the tests below, with `models` already known the way the Providers
1191    /// page leaves them once someone has listed them.
1192    fn entry(tag: &str, models: &[&str]) -> ProviderEntry {
1193        let mut entry = ProviderEntry::new(
1194            crate::provider::Tag::parse(tag).expect("a tag"),
1195            crate::provider::ProviderKind::Ollama,
1196            "http://127.0.0.1:11434",
1197        );
1198        entry.models = models.iter().map(|id| crate::provider::Model::new(*id)).collect();
1199        entry
1200    }
1201
1202    /// `entry` with the lineup `lineup` of `steps` in the given order beside it.
1203    fn with_lineup(mut entry: ProviderEntry, lineup: &str, steps: &[&str]) -> ProviderEntry {
1204        entry.lineups = vec![crate::provider::Lineup {
1205            name: crate::provider::Tag::parse(lineup).expect("a name"),
1206            models: steps.iter().map(|step| (*step).to_owned()).collect(),
1207        }];
1208        entry
1209    }
1210
1211    /// The row of the list the account page shows that the model `id` is on, which is not its
1212    /// place among the models: the list holds the provider's lineups and a heading or two as well.
1213    fn row_of_model(draft: &Draft, id: &str) -> usize {
1214        draft
1215            .pick_rows()
1216            .iter()
1217            .position(|row| matches!(row, PickRow::Model(model) if model.id == id))
1218            .unwrap_or_else(|| panic!("{id} has a row"))
1219    }
1220
1221    /// The row of the list that the lineup `name` is on.
1222    fn row_of_lineup(draft: &Draft, name: &str) -> usize {
1223        draft
1224            .pick_rows()
1225            .iter()
1226            .position(|row| matches!(row, PickRow::Lineup(lineup) if lineup.name.as_str() == name))
1227            .unwrap_or_else(|| panic!("{name} has a row"))
1228    }
1229
1230    /// A provider with a lineup chooses it rather than one of its models, and the list is the
1231    /// lineups, a gap, and then the models. A heading and a gap are rows, but nothing to choose.
1232    #[test]
1233    fn a_provider_with_a_lineup_chooses_its_first_one_and_its_list_holds_both_sections() {
1234        let mut draft =
1235            Draft::new([], vec![with_lineup(entry("yol", &["c/three", "d/four"]), "coder", &["a/one", "b/two"])]);
1236        draft.account = AccountKind::Provider;
1237        draft.choose_provider("yol");
1238        assert_eq!(draft.provider_pick, Some(Pick::Lineup("coder".to_owned())), "the lineup comes with the provider");
1239        assert_eq!(
1240            draft.pick_rows(),
1241            [
1242                PickRow::LineupHeading,
1243                PickRow::Lineup(&draft.provider_lineups()[0]),
1244                PickRow::Gap,
1245                PickRow::ModelHeading,
1246                PickRow::Model(&draft.provider_models()[0]),
1247                PickRow::Model(&draft.provider_models()[1]),
1248            ],
1249            "the lineups first, then a gap, then the models"
1250        );
1251        assert_eq!(draft.chosen_row(), Some(1), "the lineup is the row the page stands on");
1252        assert_eq!(draft.choose_provider("yol"), (), "choosing the same provider again");
1253        assert_eq!(draft.pick_at(0), None, "a heading chooses nothing");
1254        assert_eq!(draft.pick_at(2), None, "a gap chooses nothing");
1255        assert_eq!(draft.pick_at(99), None, "a row that is not there chooses nothing");
1256        assert_eq!(draft.pick_at(row_of_model(&draft, "d/four")), Some(Pick::Model("d/four".to_owned())));
1257        assert_eq!(draft.pick_at(row_of_lineup(&draft, "coder")), Some(Pick::Lineup("coder".to_owned())));
1258
1259        draft.choose_pick_row(row_of_model(&draft, "c/three"));
1260        assert_eq!(draft.chosen_row(), Some(row_of_model(&draft, "c/three")), "a model row is stood on as it is");
1261    }
1262
1263    /// A lineup that is a paid step, and a model that is, are what the warning is about; a price
1264    /// nobody published is not a free one and is not named either.
1265    #[test]
1266    fn a_lineup_or_a_model_with_a_step_that_costs_is_what_the_warning_names() {
1267        // A lineup's steps are models of its own provider, so they are in its list too: what they
1268        // cost is what the provider published for them.
1269        let mut entry =
1270            with_lineup(entry("yol", &["a/one", "b/two", "c/three", "d/four"]), "coder", &["a/one", "b/two"]);
1271        for (index, price) in [crate::provider::Price::Free, crate::provider::Price::Paid].into_iter().enumerate() {
1272            entry.models[index].price = Some(price);
1273        }
1274        entry.models[2].price = Some(crate::provider::Price::Paid);
1275        let mut draft = Draft::new([], vec![entry]);
1276        draft.account = AccountKind::Provider;
1277        draft.choose_provider("yol");
1278        assert_eq!(draft.paid_steps(), ["b/two"], "a lineup's own paid steps, in its own order");
1279        draft.choose_pick_row(row_of_model(&draft, "c/three"));
1280        assert_eq!(draft.paid_steps(), ["c/three"], "a single paid model");
1281        draft.choose_pick_row(row_of_model(&draft, "d/four"));
1282        assert_eq!(draft.paid_steps(), Vec::<String>::new(), "a model whose price nobody published is not a free one");
1283    }
1284
1285    #[test]
1286    fn choosing_a_provider_account_page_cannot_be_left_until_a_provider_and_model_are_chosen() {
1287        let mut draft = Draft::new([], vec![entry("ev1", &["qwen3.8"])]);
1288        draft.choose_harness(HarnessKind::ClaudeCode);
1289        draft.account = AccountKind::Provider;
1290        draft.stage = Stage::Account;
1291        assert_eq!(draft.blocked(), Some(Blocked::NoProvider));
1292        draft.choose_provider("ev1");
1293        assert_eq!(draft.provider_tag.as_deref(), Some("ev1"));
1294        assert_eq!(
1295            draft.chosen_model().map(String::as_str),
1296            Some("qwen3.8"),
1297            "the first model is chosen along with it"
1298        );
1299        assert_eq!(draft.blocked(), None);
1300    }
1301
1302    #[test]
1303    fn a_provider_with_no_models_listed_yet_still_blocks_the_page() {
1304        let mut draft = Draft::new([], vec![entry("ev1", &[])]);
1305        draft.account = AccountKind::Provider;
1306        draft.stage = Stage::Account;
1307        draft.choose_provider("ev1");
1308        assert_eq!(draft.provider_pick, None, "nothing to choose from yet");
1309        assert_eq!(draft.blocked(), Some(Blocked::NoProvider));
1310        assert!(draft.provider_models().is_empty());
1311    }
1312
1313    #[test]
1314    fn a_second_model_can_be_chosen_of_the_same_provider() {
1315        let mut draft = Draft::new([], vec![entry("ev1", &["qwen3.8", "qwen3.8-32k"])]);
1316        draft.choose_provider("ev1");
1317        draft.choose_provider_model("qwen3.8-32k");
1318        assert_eq!(draft.chosen_model().map(String::as_str), Some("qwen3.8-32k"));
1319        assert_eq!(draft.provider_models().len(), 2);
1320    }
1321
1322    #[test]
1323    fn the_filter_leaves_the_models_it_matches_and_the_chosen_one_is_chosen_by_its_place_in_them() {
1324        let models: Vec<String> = (0..300).map(|n| format!("vendor/model-{n:03}")).collect();
1325        let names: Vec<&str> = models.iter().map(String::as_str).collect();
1326        let mut draft = Draft::new([], vec![entry("ev1", &names)]);
1327        draft.choose_provider("ev1");
1328        assert_eq!(draft.matching_models().len(), 300, "an empty filter leaves every model");
1329        draft.model_filter = "model-287".to_owned();
1330        let shown: Vec<&str> = draft.matching_models().iter().map(|model| model.id.as_str()).collect();
1331        assert_eq!(shown, ["vendor/model-287"], "only the model asked for is shown");
1332        draft.choose_pick_row(row_of_model(&draft, "vendor/model-287"));
1333        assert_eq!(draft.chosen_model().map(String::as_str), Some("vendor/model-287"));
1334
1335        // What the filter hides is not unchosen: a filter that matches nothing leaves the choice
1336        // standing, so a person who looked at another model and came back has not lost the one
1337        // they had.
1338        draft.model_filter = "nothing here".to_owned();
1339        assert!(draft.matching_models().is_empty());
1340        assert_eq!(
1341            draft.chosen_model().map(String::as_str),
1342            Some("vendor/model-287"),
1343            "the choice is the name, not a row"
1344        );
1345        assert!(draft.pick_rows().is_empty(), "nothing at all is shown, so the list says so");
1346        assert_eq!(draft.chosen_row(), None, "and the choice is on no row");
1347        draft.choose_pick_row(0);
1348        assert_eq!(
1349            draft.chosen_model().map(String::as_str),
1350            Some("vendor/model-287"),
1351            "a row that is not there chooses nothing"
1352        );
1353    }
1354
1355    #[test]
1356    fn another_provider_starts_with_a_filter_of_its_own_and_keeps_the_model_it_also_offers() {
1357        let mut draft = Draft::new([], vec![entry("ev1", &["qwen3.8", "qwen3.8-32k"]), entry("ev2", &["llama"])]);
1358        draft.choose_provider("ev1");
1359        draft.model_filter = "32k".to_owned();
1360        draft.choose_pick_row(row_of_model(&draft, "qwen3.8-32k"));
1361        assert_eq!(draft.chosen_model().map(String::as_str), Some("qwen3.8-32k"));
1362        draft.choose_provider("ev2");
1363        assert_eq!(draft.model_filter, "", "a filter of another provider's models is of no use here");
1364        assert_eq!(draft.chosen_model().map(String::as_str), Some("llama"), "and its first model is chosen with it");
1365        // What the Providers page brings back later keeps the model that is still offered, and
1366        // takes the filter with it.
1367        draft.offer_providers(vec![entry("ev1", &["qwen3.8", "qwen3.8-32k"]), entry("ev2", &["llama", "mistral"])]);
1368        assert_eq!(draft.chosen_model().map(String::as_str), Some("llama"), "a model the new file has is kept");
1369    }
1370
1371    #[test]
1372    fn a_provider_profile_carries_its_tag_and_model_and_an_incomplete_one_makes_no_profile() {
1373        let mut draft = Draft::new([], vec![entry("ev1", &["qwen3.8"])]);
1374        draft.name = "ev-tab".to_owned();
1375        draft.account = AccountKind::Provider;
1376        assert_eq!(draft.profile(), None, "nothing chosen yet");
1377        draft.choose_provider("ev1");
1378        let profile = draft.profile().expect("a provider and a model are both chosen");
1379        let provider = profile.provider.expect("a provider profile carries one");
1380        assert_eq!(provider.tag, "ev1");
1381        assert_eq!(provider.asked(), "qwen3.8");
1382    }
1383
1384    #[test]
1385    fn a_system_that_cannot_run_the_harness_keeps_its_page_and_says_why() {
1386        let mut draft = Draft::new([], Vec::new());
1387        draft.choose_harness(HarnessKind::GeminiCli);
1388        draft.advance();
1389        assert_eq!(draft.stage, Stage::System, "the system is chosen right after the harness");
1390        draft.choose_os(Os::Alpine);
1391        assert_eq!(draft.advance(), Some(Blocked::Unsupported(Refusal::TerminalLibrary)));
1392        assert_eq!(draft.stage, Stage::System, "Gemini CLI is never built on Alpine");
1393        draft.choose_os(Os::Arch);
1394        assert_eq!(draft.advance(), None);
1395        assert_eq!(draft.stage, Stage::Template);
1396        assert_eq!(draft.profile().map(|profile| profile.os), Some(Os::Arch));
1397    }
1398
1399    #[test]
1400    fn every_switch_off_is_written_as_the_harness_as_it_comes_and_a_narrowed_set_as_that_set() {
1401        let mut draft = Draft::new([], Vec::new());
1402        draft.choose(Template::Custom);
1403        assert!(draft.parts.is_empty());
1404        let profile = draft.profile().expect("a profile");
1405        assert_eq!((profile.template, profile.without), (Template::Base, Vec::new()), "what every QCode reads");
1406
1407        // QCode extra with graphify off reads Custom on the page and is written as QCode extra
1408        // without graphify, which every QCode builds the same.
1409        draft.choose(Template::High);
1410        draft.switch(Extra::Graphify, false);
1411        assert_eq!(draft.preset(), Template::Custom);
1412        let profile = draft.profile().expect("a profile");
1413        assert_eq!((profile.template, profile.without.clone()), (Template::High, vec![Extra::Graphify]));
1414        // And opened again, the page reads it as it was left.
1415        assert_eq!(Draft::for_edit(&profile, Vec::new()).preset(), Template::Custom);
1416        draft.switch(Extra::Graphify, true);
1417        assert_eq!(draft.preset(), Template::High, "every switch of QCode extra is on again");
1418
1419        // opencode's QCode extra switches on what QCode recommended does; the row chosen stays.
1420        draft.choose_harness(HarnessKind::OpenCode);
1421        assert_eq!(draft.preset(), Template::High);
1422        assert_eq!(draft.profile().map(|profile| profile.template), Some(Template::High));
1423        assert_eq!(Template::High.parts(HarnessKind::OpenCode), Template::Recommended.parts(HarnessKind::OpenCode));
1424    }
1425
1426    #[test]
1427    fn quvyta_development_on_a_system_without_chromium_is_still_that_set_without_the_browser() {
1428        let mut draft = Draft::new([], Vec::new());
1429        draft.choose_os(Os::Ubuntu);
1430        draft.choose(Template::QuvytaDev);
1431        assert!(!draft.has(Extra::Chromium) && draft.has(Extra::Rust));
1432        assert_eq!(draft.preset(), Template::QuvytaDev);
1433        draft.switch(Extra::Chromium, true);
1434        assert!(!draft.has(Extra::Chromium), "no image can carry it there");
1435        let profile = draft.profile().expect("a profile");
1436        assert_eq!((profile.template, profile.without), (Template::QuvytaDev, vec![Extra::Chromium]));
1437        // Back on Debian the browser is offered again, off until switched on.
1438        draft.choose_os(Os::Debian);
1439        assert_eq!(draft.preset(), Template::Custom);
1440    }
1441
1442    #[test]
1443    fn a_new_draft_is_built_on_debian_and_a_login_draft_keeps_its_profiles_system() {
1444        let mut draft = Draft::new([], Vec::new());
1445        assert_eq!(draft.os, Os::Debian);
1446        draft.choose_os(Os::Ubuntu);
1447        let profile = draft.profile().expect("a profile");
1448        assert_eq!(Draft::for_login(&profile).os, Os::Ubuntu);
1449    }
1450
1451    #[test]
1452    fn every_page_has_a_place_and_the_last_one_leads_nowhere() {
1453        for (index, stage) in Stage::ALL.iter().enumerate() {
1454            assert_eq!(stage.index(), index);
1455            assert_eq!(Stage::at(index), Some(*stage));
1456        }
1457        assert_eq!(Stage::at(Stage::ALL.len()), None);
1458    }
1459}