Skip to main content

qcode/ui/providers/
draft.rs

1//! The add-a-provider form's own state, and everything that can stop a provider being added.
2//!
3//! Nothing here touches the disk or the network, and nothing here is a sentence: every problem
4//! is a value the view turns into words from the language files, so the same check reads the
5//! same way in every language and is tested without a screen.
6//!
7//! The key is the one field that is never read back. It is typed into a password field, it goes
8//! straight into a [`Key`](crate::provider::Key) when the provider is added, and the draft holds
9//! it as text only for as long as the person is still typing it.
10
11use crate::provider::{Key, ProviderEntry, ProviderKind, Tag, TagError, ask};
12
13/// What stopped a provider being added, apart from its wording.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Problem {
16    /// The tag cannot be a tag, and why.
17    Tag(TagError),
18    /// A provider of this tag is already there.
19    Taken(String),
20    /// No address was written.
21    NoBase,
22    /// An address QCode would not know how to reach.
23    BaseNotAnAddress(String),
24    /// An address that starts like one but that no request could be sent to, such as two run
25    /// together when a person typed theirs after the one that was offered.
26    BaseUnusable(String),
27    /// This kind is reached with a key and none was pasted.
28    NoKey(ProviderKind),
29}
30
31impl Problem {
32    /// The field the problem belongs beside.
33    #[must_use]
34    pub fn field(&self) -> &'static str {
35        match self {
36            Self::Tag(_) | Self::Taken(_) => TAG_FIELD,
37            Self::NoBase | Self::BaseNotAnAddress(_) | Self::BaseUnusable(_) => BASE_FIELD,
38            Self::NoKey(_) => KEY_FIELD,
39        }
40    }
41}
42
43/// The name of the tag field, which the focus and the error summary use.
44pub const TAG_FIELD: &str = "provider-tag";
45
46/// The name of the address field.
47pub const BASE_FIELD: &str = "provider-base";
48
49/// The name of the key field.
50pub const KEY_FIELD: &str = "provider-key";
51
52/// A provider being written down.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Draft {
55    /// The tag as it has been typed.
56    pub tag: String,
57    /// Which kind it is.
58    pub kind: ProviderKind,
59    /// The address as it has been typed.
60    pub base: String,
61    /// The key as it is being typed. Never shown back: the field it lives in is a password
62    /// field, and once the provider is added this is gone.
63    pub key: String,
64    /// What stopped it last time the person asked for it to be added, or nothing while they
65    /// have not asked yet. A form does not complain about a field nobody has finished.
66    pub problem: Option<Problem>,
67}
68
69impl Draft {
70    /// A new form, on the kind the page offers first, with that kind's usual address already in
71    /// the field so the common case is one word of typing.
72    #[must_use]
73    pub fn new() -> Self {
74        let kind = ProviderKind::ALL[0];
75        Self { tag: String::new(), kind, base: kind.suggested_base().to_owned(), key: String::new(), problem: None }
76    }
77
78    /// Chooses a kind, and moves the address along with it when the person has not changed the
79    /// one that was offered. A person who typed their own address keeps it. A ready-made kind
80    /// brings its own tag too, on the same terms: only into a field the person has not written.
81    pub fn pick_kind(&mut self, kind: ProviderKind) {
82        if self.offered_base() {
83            self.base = kind.suggested_base().to_owned();
84        }
85        let tag = self.tag.trim();
86        if tag.is_empty() || self.kind.suggested_tag() == Some(tag) {
87            self.tag = kind.suggested_tag().unwrap_or_default().to_owned();
88        }
89        self.kind = kind;
90        self.problem = None;
91    }
92
93    /// Chooses where a ready-made provider answers, the `index`th of its kind's regions. The
94    /// address is the region's whatever was in the field: picking a region is saying which
95    /// address, and nothing else does that.
96    pub fn pick_region(&mut self, index: usize) {
97        if let Some(region) = self.kind.regions().get(index) {
98            self.base = region.base.to_owned();
99            self.problem = None;
100        }
101    }
102
103    /// Which of the kind's regions the address is, or `None` when the person wrote one of their
104    /// own.
105    #[must_use]
106    pub fn region(&self) -> Option<usize> {
107        let base = crate::provider::trim_base(&self.base);
108        self.kind.regions().iter().position(|region| region.base == base)
109    }
110
111    /// Whether the address is one QCode offered rather than one the person wrote.
112    fn offered_base(&self) -> bool {
113        self.base == self.kind.suggested_base() || self.region().is_some()
114    }
115
116    /// The provider this form describes, or the first reason it is not one.
117    ///
118    /// `taken` says whether a tag is already in the list, which only the list knows.
119    ///
120    /// # Errors
121    ///
122    /// [`Problem`] says what stopped it and which field it belongs beside.
123    pub fn build(&self, taken: impl Fn(&str) -> bool) -> Result<ProviderEntry, Problem> {
124        let tag = Tag::parse(self.tag.trim()).map_err(Problem::Tag)?;
125        if taken(tag.as_str()) {
126            return Err(Problem::Taken(tag.as_str().to_owned()));
127        }
128        let base = self.base.trim();
129        if base.is_empty() {
130            return Err(Problem::NoBase);
131        }
132        // Only an address with a scheme can be reached, and a person pasting one from a browser
133        // has it. Saying so now is better than a connection that fails for a reason nobody can
134        // see in the address.
135        if !(base.starts_with("http://") || base.starts_with("https://")) {
136            return Err(Problem::BaseNotAnAddress(base.to_owned()));
137        }
138        let key = Key::new(&self.key);
139        if self.kind.needs_key() && key.is_none() {
140            return Err(Problem::NoKey(self.kind));
141        }
142        let mut entry = ProviderEntry::new(tag, self.kind, base);
143        // Judged on an address a request really goes to, so what is refused here is exactly what
144        // would have failed there.
145        if !ask::can_be_sent_to(&entry.messages_address()) {
146            return Err(Problem::BaseUnusable(base.to_owned()));
147        }
148        entry.key = key;
149        Ok(entry)
150    }
151}
152
153impl Default for Draft {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn filled(tag: &str, kind: ProviderKind, base: &str, key: &str) -> Draft {
164        Draft { tag: tag.to_owned(), kind, base: base.to_owned(), key: key.to_owned(), problem: None }
165    }
166
167    #[test]
168    fn a_filled_form_becomes_a_provider() {
169        let draft = filled("ev", ProviderKind::Ollama, " http://192.168.122.1:11434/ ", "");
170        let entry = draft.build(|_| false).expect("the form is filled");
171        assert_eq!(entry.tag.as_str(), "ev");
172        assert_eq!(entry.base, "http://192.168.122.1:11434", "the address is tidied as it is taken");
173        assert_eq!(entry.key, None);
174    }
175
176    #[test]
177    fn a_tag_that_is_already_there_is_refused_beside_the_tag_field() {
178        let draft = filled("ev", ProviderKind::Ollama, "http://h:1", "");
179        let problem = draft.build(|tag| tag == "ev").expect_err("the tag is taken");
180        assert_eq!(problem, Problem::Taken("ev".to_owned()));
181        assert_eq!(problem.field(), TAG_FIELD);
182    }
183
184    #[test]
185    fn a_tag_that_could_not_stand_in_front_of_a_model_is_refused_with_its_reason() {
186        let draft = filled("ev/makine", ProviderKind::Ollama, "http://h:1", "");
187        let problem = draft.build(|_| false).expect_err("a slash is not a tag");
188        assert_eq!(problem, Problem::Tag(TagError::Illegal { position: 3, character: '/' }));
189        assert_eq!(problem.field(), TAG_FIELD);
190    }
191
192    #[test]
193    fn an_address_with_no_scheme_is_named_now_rather_than_failing_for_no_visible_reason_later() {
194        let draft = filled("ev", ProviderKind::Ollama, "192.168.122.1:11434", "");
195        let problem = draft.build(|_| false).expect_err("that cannot be reached");
196        assert_eq!(problem, Problem::BaseNotAnAddress("192.168.122.1:11434".to_owned()));
197        assert_eq!(problem.field(), BASE_FIELD);
198        assert_eq!(filled("ev", ProviderKind::Ollama, "  ", "").build(|_| false), Err(Problem::NoBase));
199    }
200
201    #[test]
202    fn two_addresses_run_together_are_refused_by_the_parser_the_requests_use() {
203        let joined = "http://127.0.0.1:11434http://192.168.122.1:11434";
204        let problem =
205            filled("ev", ProviderKind::Ollama, joined, "").build(|_| false).expect_err("no request goes there");
206        assert_eq!(problem, Problem::BaseUnusable(joined.to_owned()));
207        assert_eq!(problem.field(), BASE_FIELD);
208        for broken in ["http://", "http://ev makine:11434", "https://[::1"] {
209            assert!(
210                matches!(
211                    filled("ev", ProviderKind::Ollama, broken, "").build(|_| false),
212                    Err(Problem::BaseUnusable(_))
213                ),
214                "{broken}"
215            );
216        }
217        assert!(filled("ev", ProviderKind::Ollama, "http://[::1]:11434", "").build(|_| false).is_ok());
218    }
219
220    #[test]
221    fn a_kind_that_is_reached_with_a_key_is_not_added_without_one() {
222        let without = filled("yol", ProviderKind::OpenRouter, "https://openrouter.ai", "");
223        assert_eq!(without.build(|_| false), Err(Problem::NoKey(ProviderKind::OpenRouter)));
224        assert_eq!(Problem::NoKey(ProviderKind::OpenRouter).field(), KEY_FIELD);
225        let with = filled("yol", ProviderKind::OpenRouter, "https://openrouter.ai", "not-a-real-key-0000-wxyz");
226        let entry = with.build(|_| false).expect("the key is there");
227        assert_eq!(entry.key.expect("a key").last_four().as_deref(), Some("wxyz"));
228    }
229
230    #[test]
231    fn changing_the_kind_moves_an_address_nobody_typed_and_leaves_one_they_did() {
232        let mut draft = Draft::new();
233        assert_eq!(draft.kind, ProviderKind::Ollama);
234        draft.pick_kind(ProviderKind::OpenRouter);
235        assert_eq!(draft.base, ProviderKind::OpenRouter.suggested_base(), "the offer follows the kind");
236
237        let mut own = Draft::new();
238        own.base = "http://192.168.122.1:11434".to_owned();
239        own.pick_kind(ProviderKind::OpenRouter);
240        assert_eq!(own.base, "http://192.168.122.1:11434", "an address the person typed is theirs");
241    }
242}