1use 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
28const LOG_LINES: usize = 4000;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Stage {
35 Harness,
37 System,
40 Template,
42 Account,
44 Permissions,
46 Image,
48 Login,
50}
51
52impl Stage {
53 pub const ALL: [Self; 7] =
55 [Self::Harness, Self::System, Self::Template, Self::Account, Self::Permissions, Self::Image, Self::Login];
56
57 pub const WITHOUT_LOGIN: [Self; 6] =
60 [Self::Harness, Self::System, Self::Template, Self::Account, Self::Permissions, Self::Image];
61
62 #[must_use]
64 pub fn index(self) -> usize {
65 Self::ALL.iter().position(|stage| *stage == self).unwrap_or(0)
66 }
67
68 #[must_use]
70 pub fn at(index: usize) -> Option<Self> {
71 Self::ALL.get(index).copied()
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum Build {
78 Waiting,
80 Running(TaskId),
82 Done,
84 Failed(Problem),
86 Stopped,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Unfinished {
93 Stopped,
95 Missing,
97}
98
99#[derive(Debug)]
101pub enum Login {
102 Waiting,
104 Opening,
106 Running {
108 session: TerminalSession,
110 container: Arc<LoginContainer>,
114 },
115 Window(WindowLogin),
117 Storing,
119 Stored(usize),
121 Unfinished(Unfinished),
123 Failed(Problem),
125}
126
127impl Login {
128 #[must_use]
131 pub fn is_stored(&self) -> bool {
132 matches!(self, Self::Stored(_))
133 }
134
135 #[must_use]
137 pub fn is_busy(&self) -> bool {
138 matches!(self, Self::Opening | Self::Storing)
139 }
140}
141
142#[derive(Debug)]
145pub struct WindowLogin {
146 pub run: u64,
148 pub sign_in: Arc<SignIn>,
151 pub looking: Option<TaskId>,
153 pub page: Option<(String, Page)>,
155 pub back: Option<(u16, WindowBack)>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum Page {
162 Asked,
164 InBrowser,
166 Nowhere,
168 Held,
170}
171
172#[derive(Debug)]
174pub enum WindowBack {
175 Listening(callback::Stop),
177 Returned,
179 TimedOut,
181 Taken,
183 Refused(String),
185}
186
187impl WindowLogin {
188 #[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 #[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 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#[derive(Debug)]
218pub struct Draft {
219 pub stage: Stage,
221 pub name: String,
223 pub renamed: bool,
225 pub harness: HarnessKind,
227 pub template: Template,
230 pub parts: Vec<Extra>,
232 pub account: AccountKind,
234 pub providers: Vec<ProviderEntry>,
237 pub provider_tag: Option<String>,
239 pub provider_pick: Option<Pick>,
242 pub model_filter: String,
245 pub assets: MountAccess,
247 pub network: NetworkMode,
249 pub os: Os,
251 pub build: Build,
253 pub log: LogBuffer,
255 said: Instant,
258 stuck: bool,
261 pub login: Login,
263 taken: Vec<String>,
265 only_login: bool,
267 only_rebuild: bool,
269 editing: Option<Profile>,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum PickRow<'a> {
279 LineupHeading,
281 ModelHeading,
283 Gap,
285 Lineup(&'a Lineup),
287 Model(&'a Model),
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum Blocked {
294 NameEmpty,
296 NameTaken,
298 NoImage,
300 NoProvider,
302 Unsupported(Refusal),
304 NeedsNetwork,
306}
307
308impl Draft {
309 #[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 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 #[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 #[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 #[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 #[must_use]
408 pub fn is_editing(&self) -> bool {
409 self.editing.is_some()
410 }
411
412 #[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 #[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 fn download_failed(&self, words: &str, template: Template) -> bool {
435 if !matches!(self.build, Build::Failed(_)) {
436 return false;
437 }
438 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 #[must_use]
449 pub fn extra_download_failed(&self) -> bool {
450 self.download_failed(recipe::EXTRA_FAILED, Template::High)
451 }
452
453 #[must_use]
456 pub fn dev_download_failed(&self) -> bool {
457 self.download_failed(recipe::DEV_FAILED, Template::QuvytaDev)
458 }
459
460 #[must_use]
463 pub fn recommended_download_failed(&self) -> bool {
464 self.download_failed(recipe::RECOMMENDED_FAILED, Template::Recommended)
465 }
466
467 #[must_use]
470 pub fn slim_download_failed(&self) -> bool {
471 self.download_failed(recipe::SLIM_FAILED, Template::Slim)
472 }
473
474 #[must_use]
478 pub fn custom_download_failed(&self) -> bool {
479 self.download_failed(recipe::CUSTOM_FAILED, Template::Custom)
480 }
481
482 #[must_use]
484 pub fn is_only_login(&self) -> bool {
485 self.only_login
486 }
487
488 #[must_use]
490 pub fn is_only_rebuild(&self) -> bool {
491 self.only_rebuild
492 }
493
494 fn suggested_name(&self) -> String {
496 self.harness.record().id.to_owned()
497 }
498
499 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 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 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 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 #[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 #[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 #[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 fn chosen_entry(&self) -> Option<&ProviderEntry> {
585 self.provider_tag.as_deref().and_then(|tag| self.provider_entry(tag))
586 }
587
588 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 pub fn choose_provider_model(&mut self, id: &str) {
769 self.provider_pick = Some(Pick::Model(id.to_owned()));
770 }
771
772 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 #[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 #[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 #[must_use]
826 pub fn preset(&self) -> Template {
827 self.template
828 }
829
830 #[must_use]
832 pub fn offered(&self) -> Vec<Extra> {
833 Template::available(self.harness)
834 }
835
836 #[must_use]
838 pub fn has(&self, extra: Extra) -> bool {
839 self.parts.contains(&extra)
840 }
841
842 #[must_use]
845 pub fn adds_anything(&self) -> bool {
846 self.parts.iter().any(|part| *part != Extra::Settings)
847 }
848
849 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 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 pub fn choose_os(&mut self, os: Os) {
885 self.os = os;
886 self.settle();
887 }
888
889 #[must_use]
892 pub fn chromium_refused(&self) -> bool {
893 self.os.chromium().is_none()
894 }
895
896 #[must_use]
898 pub fn safe_name(&self) -> Option<SafeName> {
899 SafeName::from_display(&self.name)
900 }
901
902 #[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 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 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 #[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 #[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 #[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 #[must_use]
954 pub fn is_busy(&self) -> bool {
955 matches!(self.build, Build::Running(_)) || self.login.is_busy()
956 }
957
958 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 #[must_use]
968 pub fn is_building(&self) -> bool {
969 matches!(self.build, Build::Running(_))
970 }
971
972 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 #[must_use]
982 pub fn is_stuck(&self) -> bool {
983 self.stuck
984 }
985
986 pub fn build_said(&mut self) {
989 self.said = Instant::now();
990 self.stuck = false;
991 }
992
993 pub fn build_ended(&mut self) {
997 self.stuck = false;
998 }
999
1000 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 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 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
1038fn 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 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 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 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 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 #[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 #[test]
1266 fn a_lineup_or_a_model_with_a_step_that_costs_is_what_the_warning_names() {
1267 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 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 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 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 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 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 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}