Skip to main content

qcli/
config.rs

1//! Where qcli keeps its settings: `cli.conf` in the Quvyta ecosystem's folder, beside the
2//! other Quvyta apps.
3//!
4//! ```text
5//! ~/.config/quvyta/
6//!     quvyta.conf     the language, theme and icons every Quvyta app shares
7//!     cli.conf        qcli's settings: the provider it talks to
8//! ```
9//!
10//! The provider is four keys: the address, the model, the file the key is in and the header the
11//! key goes in. The key itself is never in this file, which people copy between machines and
12//! paste into reports; only the path of the file that holds it.
13//!
14//! As a program of a QCode profile, qcli is told its provider by QCode instead, in two
15//! environment variables: the address of QCode's relay inside the container and the model the
16//! profile chose. The relay adds the key outside the container, so on that road qcli reads no key
17//! and sends none, and the provider it was told is never written into this file.
18
19use std::path::{Path, PathBuf};
20
21use qframe::i18n::I18n;
22use qframe::storage::{Ecosystem, Schema, Settings};
23
24use crate::provider::{Endpoint, Key, KeyError};
25
26/// The application's id in the ecosystem: its settings file is `cli.conf`.
27pub const APP: &str = "cli";
28
29/// The key of the address the Messages API lives under.
30pub const ADDRESS: &str = "address";
31/// The key of the model asked for.
32pub const MODEL: &str = "model";
33/// The key of the path of the file the key is read from.
34pub const KEY_FILE: &str = "key-file";
35/// The key of the header the key goes in.
36pub const KEY_HEADER: &str = "key-header";
37
38/// The header most servers of the Messages shape read the key from.
39pub const DEFAULT_HEADER: &str = "x-api-key";
40
41/// The variable a QCode profile names its relay's address in.
42pub const ADDRESS_VARIABLE: &str = "QCLI_ADDRESS";
43/// The variable a QCode profile names its model in.
44pub const MODEL_VARIABLE: &str = "QCLI_MODEL";
45
46/// What qcli's own keys may hold. Every value is text; an empty one is the same as none.
47#[must_use]
48pub fn schema() -> Schema {
49    Schema::builtin().text(ADDRESS, "").text(MODEL, "").text(KEY_FILE, "").text(KEY_HEADER, DEFAULT_HEADER)
50}
51
52/// The settings from the ecosystem's folder, checked and healed.
53#[must_use]
54pub fn load() -> Settings {
55    checked(Settings::load_member(&Ecosystem::QUVYTA, APP))
56}
57
58/// [`load`] from `folder` as the ecosystem's folder, so a test never touches the person's own
59/// settings.
60#[must_use]
61pub fn load_in(folder: &Path) -> Settings {
62    checked(Settings::open(folder.join(format!("{APP}.conf"))))
63}
64
65fn checked(settings: Settings) -> Settings {
66    settings.member_of(&Ecosystem::QUVYTA).schema(schema()).self_heal(true)
67}
68
69/// The languages qcli speaks, for choosing the machine's one before the runtime is built.
70#[must_use]
71pub fn spoken() -> I18n {
72    let mut i18n = I18n::builtin();
73    for &(file, text) in crate::locales() {
74        i18n.add_source(file, text);
75    }
76    i18n
77}
78
79/// Where the ecosystem's update notice reads its switch and remembers when it last asked.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct UpdateFolders {
82    /// The ecosystem's configuration folder, whose shared file holds the switch.
83    pub config: PathBuf,
84    /// qcli's state folder, which remembers when the question was last asked.
85    pub state: PathBuf,
86}
87
88impl UpdateFolders {
89    /// This machine's folders, or `None` without a home folder, where nothing could remember the
90    /// switch or the last question and so nothing is asked.
91    #[must_use]
92    pub fn here() -> Option<Self> {
93        let ecosystem = Ecosystem::QUVYTA;
94        ecosystem.config_dir().zip(ecosystem.state_dir(APP)).map(|(config, state)| Self { config, state })
95    }
96}
97
98/// Where the provider was described.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum Source {
101    /// In `cli.conf`, by the person, with the key in a file of theirs.
102    #[default]
103    Settings,
104    /// By the QCode profile qcli runs in, in [`ADDRESS_VARIABLE`] and [`MODEL_VARIABLE`]. The
105    /// address is QCode's relay on this machine, which adds the key itself, so there is no key
106    /// file and no key; nothing of it is written into the settings, and it is changed in QCode.
107    Profile,
108}
109
110/// Why the provider a QCode profile describes cannot be used. qcli does not open then: a tab that
111/// went on with the settings file instead would talk to a provider the profile never chose.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum ProfileError {
114    /// One variable is set and the other, named here, is not.
115    Missing(&'static str),
116    /// The address is not one of this machine. No key goes with a request on this road, so it is
117    /// for a relay that adds one, never a way to reach a provider elsewhere. The address itself is
118    /// not repeated: it was not written to be shown.
119    Elsewhere,
120}
121
122impl std::fmt::Display for ProfileError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            Self::Missing(name) => write!(
126                f,
127                "{name} is not set: a QCode profile tells qcli both {ADDRESS_VARIABLE} and {MODEL_VARIABLE}, or neither"
128            ),
129            Self::Elsewhere => write!(
130                f,
131                "{ADDRESS_VARIABLE} must be an address on this machine (localhost, 127.x or ::1): it is for the relay of a QCode profile, which adds the key, and no key is sent with it"
132            ),
133        }
134    }
135}
136
137impl std::error::Error for ProfileError {}
138
139/// The provider as the settings describe it. Every field is what the person wrote, trimmed.
140#[derive(Debug, Clone, PartialEq, Eq, Default)]
141pub struct Provider {
142    /// The address the Messages API lives under.
143    pub address: String,
144    /// The model asked for.
145    pub model: String,
146    /// The path of the key file, as written: `~/` stands for the home folder.
147    pub key_file: String,
148    /// The header the key goes in.
149    pub key_header: String,
150    /// Where all this was described.
151    pub source: Source,
152}
153
154/// Why the provider cannot be asked yet.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum Missing {
157    /// One of the fields is empty; the key of the first one.
158    Field(&'static str),
159    /// The key file gave no key.
160    Key(KeyError),
161}
162
163impl Provider {
164    /// The provider in `settings`.
165    #[must_use]
166    pub fn from_settings(settings: &Settings) -> Self {
167        let text = |key: &str| settings.get::<String>(key).unwrap_or_default().trim().to_owned();
168        let header = text(KEY_HEADER);
169        Self {
170            address: text(ADDRESS),
171            model: text(MODEL),
172            key_file: text(KEY_FILE),
173            key_header: if header.is_empty() { DEFAULT_HEADER.to_owned() } else { header },
174            source: Source::Settings,
175        }
176    }
177
178    /// The provider a QCode profile describes in the environment `read` gives, `None` when it
179    /// describes none, which is qcli started anywhere but in a QCode tab.
180    ///
181    /// # Errors
182    ///
183    /// When only one of the two variables is set, or the address is not one of this machine.
184    pub fn from_environment(read: impl Fn(&str) -> Option<String>) -> Result<Option<Self>, ProfileError> {
185        let value = |name: &str| read(name).map(|value| value.trim().to_owned()).filter(|value| !value.is_empty());
186        match (value(ADDRESS_VARIABLE), value(MODEL_VARIABLE)) {
187            (None, None) => Ok(None),
188            (None, Some(_)) => Err(ProfileError::Missing(ADDRESS_VARIABLE)),
189            (Some(_), None) => Err(ProfileError::Missing(MODEL_VARIABLE)),
190            (Some(address), Some(_)) if !on_this_machine(&address) => Err(ProfileError::Elsewhere),
191            (Some(address), Some(model)) => Ok(Some(Self {
192                address,
193                model,
194                key_file: String::new(),
195                key_header: String::new(),
196                source: Source::Profile,
197            })),
198        }
199    }
200
201    /// Writes the provider into `settings`. A header that is the default is not written, the
202    /// way the ecosystem writes no default. A provider a QCode profile described is never
203    /// written: it is the profile's, and the person's own settings stay as they wrote them.
204    pub fn write(&self, settings: &mut Settings) {
205        if self.source == Source::Profile {
206            return;
207        }
208        settings.set(ADDRESS, self.address.trim().to_owned());
209        settings.set(MODEL, self.model.trim().to_owned());
210        settings.set(KEY_FILE, self.key_file.trim().to_owned());
211        let header = self.key_header.trim();
212        if header.is_empty() || header == DEFAULT_HEADER {
213            settings.remove(KEY_HEADER);
214        } else {
215            settings.set(KEY_HEADER, header.to_owned());
216        }
217    }
218
219    /// The first empty field, if any: the provider cannot be asked without all of them. A
220    /// profile's provider has no key file to miss.
221    #[must_use]
222    pub fn missing_field(&self) -> Option<&'static str> {
223        let key_file = (self.source == Source::Settings).then_some((KEY_FILE, &self.key_file));
224        [(ADDRESS, &self.address), (MODEL, &self.model)]
225            .into_iter()
226            .chain(key_file)
227            .find_map(|(key, value)| value.is_empty().then_some(key))
228    }
229
230    /// The key file's path, with `~/` read as the home folder.
231    #[must_use]
232    pub fn key_path(&self) -> PathBuf {
233        expand_home(&self.key_file)
234    }
235
236    /// The endpoint to ask, with the key read from its file now; a profile's provider is asked
237    /// with no key, which its relay adds.
238    ///
239    /// # Errors
240    ///
241    /// When a field is empty or the key file gives no key.
242    pub fn endpoint(&self) -> Result<Endpoint, Missing> {
243        if let Some(field) = self.missing_field() {
244            return Err(Missing::Field(field));
245        }
246        let key = match self.source {
247            Source::Settings => Some(Key::from_file(&self.key_path()).map_err(Missing::Key)?),
248            Source::Profile => None,
249        };
250        Ok(Endpoint {
251            address: self.address.clone(),
252            model: self.model.clone(),
253            key_header: self.key_header.clone(),
254            key,
255            silence: Endpoint::SILENCE,
256        })
257    }
258}
259
260/// Whether `address` is an `http://` or `https://` address of this machine: `localhost`, `127.x`
261/// or `::1`. An address that names a user before its host is not, whatever the host: it is not
262/// what a relay is given, and it makes the host easy to misread.
263#[must_use]
264pub fn on_this_machine(address: &str) -> bool {
265    let address = address.trim();
266    let scheme = |name: &str| address.get(..name.len()).is_some_and(|start| start.eq_ignore_ascii_case(name));
267    let rest = if scheme("http://") {
268        &address[7..]
269    } else if scheme("https://") {
270        &address[8..]
271    } else {
272        return false;
273    };
274    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
275    !authority.contains('@') && loopback(host(authority))
276}
277
278/// The host of an address's authority (`host`, `host:port`, `[v6]:port`), with any user part
279/// before an `@` left out.
280pub(crate) fn host(authority: &str) -> &str {
281    let host = authority.rsplit_once('@').map_or(authority, |(_, host)| host);
282    if let Some(bracketed) = host.strip_prefix('[') {
283        bracketed.split(']').next().unwrap_or_default()
284    } else {
285        host.split(':').next().unwrap_or_default()
286    }
287}
288
289/// Whether `host` is this machine: `localhost`, or a loopback address.
290pub(crate) fn loopback(host: &str) -> bool {
291    host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
292}
293
294/// `path` with a leading `~/` replaced by the home folder.
295///
296/// Without a home folder there is nothing to replace it with, so the path is used as it was
297/// written and the key file under it is then reported as the system reports it.
298fn expand_home(path: &str) -> PathBuf {
299    expand_home_in(path, std::env::var_os("HOME"))
300}
301
302fn expand_home_in(path: &str, home: Option<std::ffi::OsString>) -> PathBuf {
303    match (path.strip_prefix("~/"), home) {
304        (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
305        _ => PathBuf::from(path),
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn folder(name: &str) -> PathBuf {
314        let folder = std::env::temp_dir().join(format!("qcli-config-{name}-{}", std::process::id()));
315        let _ = std::fs::remove_dir_all(&folder);
316        std::fs::create_dir_all(&folder).expect("folder");
317        folder
318    }
319
320    #[test]
321    fn the_provider_is_written_and_read_back_and_the_default_header_is_not_written() {
322        let folder = folder("round");
323        let mut settings = load_in(&folder);
324        let provider = Provider {
325            address: " http://127.0.0.1:9/anthropic ".into(),
326            model: "some-model".into(),
327            key_file: "~/keys/made-up".into(),
328            key_header: DEFAULT_HEADER.into(),
329            source: Source::Settings,
330        };
331        provider.write(&mut settings);
332        settings.save().expect("saved");
333        let text = std::fs::read_to_string(folder.join("cli.conf")).expect("the file");
334        assert!(text.contains("address = \"http://127.0.0.1:9/anthropic\""), "{text}");
335        assert!(!text.contains(KEY_HEADER), "the default header is not written: {text}");
336
337        let read = Provider::from_settings(&load_in(&folder));
338        assert_eq!(read.address, "http://127.0.0.1:9/anthropic");
339        assert_eq!(read.key_header, DEFAULT_HEADER);
340
341        let mut settings = load_in(&folder);
342        Provider { key_header: "api-key".into(), ..read }.write(&mut settings);
343        settings.save().expect("saved");
344        assert_eq!(Provider::from_settings(&load_in(&folder)).key_header, "api-key");
345        std::fs::remove_dir_all(&folder).expect("clean up");
346    }
347
348    #[test]
349    fn an_endpoint_needs_every_field_and_a_key_in_the_file() {
350        let folder = folder("endpoint");
351        let key_file = folder.join("key");
352        let mut provider = Provider {
353            address: "http://127.0.0.1:9".into(),
354            model: String::new(),
355            key_file: key_file.display().to_string(),
356            key_header: "api-key".into(),
357            source: Source::Settings,
358        };
359        assert_eq!(provider.endpoint().expect_err("no model"), Missing::Field(MODEL));
360        provider.model = "m".into();
361        assert!(matches!(provider.endpoint(), Err(Missing::Key(KeyError::Unreadable { .. }))));
362        std::fs::write(&key_file, "not-a-real-key-0000-wxyz\n").expect("key");
363        let endpoint = provider.endpoint().expect("an endpoint");
364        assert_eq!((endpoint.model.as_str(), endpoint.key_header.as_str()), ("m", "api-key"));
365        std::fs::remove_dir_all(&folder).expect("clean up");
366    }
367
368    #[test]
369    fn a_home_path_is_read_from_the_home_folder() {
370        let home = std::env::var_os("HOME").map(PathBuf::from).expect("a home in tests");
371        assert_eq!(expand_home("~/a/b"), home.join("a/b"));
372        assert_eq!(expand_home("/a/b"), PathBuf::from("/a/b"));
373    }
374
375    #[test]
376    fn a_home_path_with_no_home_folder_is_the_path_as_it_was_written() {
377        // Nothing to stand for `~`, so the path is left as it is and the key file under it is
378        // then reported as the system reports it, never as a key.
379        let path = expand_home_in("~/.config/quvyta/key", None);
380        assert_eq!(path, PathBuf::from("~/.config/quvyta/key"));
381        let provider = Provider {
382            address: "http://127.0.0.1:9".into(),
383            model: "m".into(),
384            key_file: "~/.config/quvyta/key".into(),
385            key_header: DEFAULT_HEADER.into(),
386            source: Source::Settings,
387        };
388        assert!(matches!(provider.endpoint(), Err(Missing::Key(KeyError::Unreadable { .. }))));
389    }
390
391    #[test]
392    fn a_settings_file_that_is_a_folder_leaves_the_provider_unset_and_says_why() {
393        let folder = folder("a-folder");
394        std::fs::create_dir(folder.join(format!("{APP}.conf"))).expect("the settings file is a folder");
395        let settings = load_in(&folder);
396        let said: Vec<_> = settings.diagnostics().iter().map(|problem| problem.message.clone()).collect();
397        assert!(
398            said.iter().any(|line| line.contains("cli.conf") && line.contains("os error 21")),
399            "the file is named and what the system said is quoted: {said:?}"
400        );
401        assert!(
402            std::fs::metadata(folder.join(format!("{APP}.conf"))).expect("still there").is_dir(),
403            "nothing was written over it"
404        );
405        assert_eq!(Provider::from_settings(&settings).missing_field(), Some(ADDRESS), "the form opens for the address");
406        std::fs::remove_dir_all(&folder).expect("clean up");
407    }
408
409    #[test]
410    fn an_unknown_key_is_dropped_and_a_value_of_the_wrong_type_is_replaced_by_the_default() {
411        let folder = folder("healed");
412        let file = folder.join(format!("{APP}.conf"));
413        std::fs::write(&file, "address = 5\nmodel = \"kept\"\nkey-file = \"~/k\"\ncolour = \"green\"\n")
414            .expect("write");
415        let settings = load_in(&folder);
416        let provider = Provider::from_settings(&settings);
417        assert_eq!(
418            (provider.address.as_str(), provider.model.as_str(), provider.key_file.as_str()),
419            ("", "kept", "~/k")
420        );
421        let healed = std::fs::read_to_string(&file).expect("the healed file");
422        assert!(healed.contains("model = \"kept\""), "what was right is kept: {healed}");
423        assert!(healed.contains("address = \"\""), "what was wrong became the default: {healed}");
424        assert!(!healed.contains("colour"), "what qcli has no use for is gone: {healed}");
425        assert!(load_in(&folder).diagnostics().is_empty(), "the file is whole now, so nothing is said about it");
426        std::fs::remove_dir_all(&folder).expect("clean up");
427    }
428
429    #[test]
430    fn a_key_file_that_is_a_folder_or_is_not_there_never_becomes_an_endpoint() {
431        let folder = folder("bad-key-path");
432        std::fs::create_dir(folder.join("a-folder")).expect("folder");
433        let mut provider = Provider {
434            address: "http://127.0.0.1:9".into(),
435            model: "m".into(),
436            key_file: folder.join("a-folder").display().to_string(),
437            key_header: DEFAULT_HEADER.into(),
438            source: Source::Settings,
439        };
440        let Err(Missing::Key(KeyError::Unreadable { path, .. })) = provider.endpoint() else {
441            panic!("a folder is not a key file, got {:?}", provider.endpoint());
442        };
443        assert_eq!(path, folder.join("a-folder"), "the file it could not read is named");
444
445        provider.key_file = folder.join("not-there").display().to_string();
446        assert!(
447            matches!(provider.endpoint(), Err(Missing::Key(KeyError::Unreadable { .. }))),
448            "a file that is not there is named too"
449        );
450        std::fs::remove_dir_all(&folder).expect("clean up");
451    }
452
453    fn environment(address: Option<&'static str>, model: Option<&'static str>) -> impl Fn(&str) -> Option<String> {
454        move |name| match name {
455            ADDRESS_VARIABLE => address.map(str::to_owned),
456            MODEL_VARIABLE => model.map(str::to_owned),
457            _ => None,
458        }
459    }
460
461    #[test]
462    fn a_profile_that_names_both_variables_gives_a_provider_with_no_key_file() {
463        let read = |name: &str| match name {
464            ADDRESS_VARIABLE => Some("http://127.0.0.1:41417".to_owned()),
465            MODEL_VARIABLE => Some("relay-model-7".to_owned()),
466            _ => None,
467        };
468        let provider = Provider::from_environment(read).expect("no error").expect("a provider");
469        assert_eq!(provider.source, Source::Profile);
470        assert_eq!(provider.address, "http://127.0.0.1:41417");
471        assert_eq!(provider.model, "relay-model-7");
472        assert_eq!(provider.missing_field(), None, "a profile's provider never misses the key file");
473        let endpoint = provider.endpoint().expect("an endpoint with no key read");
474        assert!(endpoint.key.is_none(), "no key goes with a profile's request");
475        assert_eq!(endpoint.model, "relay-model-7");
476
477        assert_eq!(Provider::from_environment(environment(None, None)), Ok(None), "neither set is no profile");
478        assert_eq!(Provider::from_environment(environment(Some("  "), Some(" "))), Ok(None), "spaces are not set");
479    }
480
481    #[test]
482    fn only_one_of_the_two_variables_stops_qcli_and_names_the_missing_one() {
483        let only_address = Provider::from_environment(environment(Some("http://127.0.0.1:41417"), None));
484        assert_eq!(only_address, Err(ProfileError::Missing(MODEL_VARIABLE)));
485        let only_model = Provider::from_environment(environment(None, Some("relay-model-7")));
486        assert_eq!(only_model, Err(ProfileError::Missing(ADDRESS_VARIABLE)));
487
488        let said = only_address.expect_err("missing").to_string();
489        assert!(said.contains(MODEL_VARIABLE), "the missing name is said: {said}");
490        let said = only_model.expect_err("missing").to_string();
491        assert!(said.contains(ADDRESS_VARIABLE), "the missing name is said: {said}");
492    }
493
494    #[test]
495    fn an_address_off_this_machine_is_refused_without_being_repeated() {
496        let refused = [
497            "https://api.example.com/anthropic",
498            "http://127.0.0.1@evil.example.com",
499            "http://localhost.evil.example:41417",
500            "ftp://127.0.0.1",
501            "127.0.0.1:41417",
502            "http://10.0.0.5:41417",
503        ];
504        for address in refused {
505            let error = Provider::from_environment(environment(Some(address), Some("m"))).expect_err(address);
506            assert_eq!(error, ProfileError::Elsewhere, "{address}");
507            let said = error.to_string();
508            for word in ["example", "evil", "10.0.0.5"] {
509                assert!(!said.contains(word), "{address}: the address is not repeated: {said}");
510            }
511        }
512        let local = ["http://localhost:41417", "http://[::1]:41417/", "https://127.0.0.2", "HTTP://127.0.0.1:41417"];
513        for address in local {
514            let result = Provider::from_environment(environment(Some(address), Some("m")));
515            assert!(matches!(result, Ok(Some(_))), "{address} is this machine: {result:?}");
516        }
517    }
518
519    #[test]
520    fn a_profile_provider_is_never_written_into_the_settings() {
521        let folder = folder("profile-write");
522        let mut settings = load_in(&folder);
523        Provider {
524            address: "http://127.0.0.1:9/mine".into(),
525            model: "mine-model".into(),
526            key_file: "~/keys/mine".into(),
527            key_header: DEFAULT_HEADER.into(),
528            source: Source::Settings,
529        }
530        .write(&mut settings);
531        settings.save().expect("saved");
532
533        let profile = Provider::from_environment(environment(Some("http://127.0.0.1:41417"), Some("relay-model-7")))
534            .expect("no error")
535            .expect("a provider");
536        let mut settings = load_in(&folder);
537        profile.write(&mut settings);
538        settings.save().expect("saved");
539
540        let read = Provider::from_settings(&load_in(&folder));
541        assert_eq!(read.address, "http://127.0.0.1:9/mine", "the person's address stays");
542        assert_eq!(read.model, "mine-model", "the profile's model is not written");
543        std::fs::remove_dir_all(&folder).expect("clean up");
544    }
545}