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
14use std::path::{Path, PathBuf};
15
16use qframe::i18n::I18n;
17use qframe::storage::{Ecosystem, Preferences, Schema, Settings};
18
19use crate::provider::{Endpoint, Key, KeyError};
20
21/// The application's id in the ecosystem: its settings file is `cli.conf`.
22pub const APP: &str = "cli";
23
24/// The key of the address the Messages API lives under.
25pub const ADDRESS: &str = "address";
26/// The key of the model asked for.
27pub const MODEL: &str = "model";
28/// The key of the path of the file the key is read from.
29pub const KEY_FILE: &str = "key-file";
30/// The key of the header the key goes in.
31pub const KEY_HEADER: &str = "key-header";
32
33/// The header most servers of the Messages shape read the key from.
34pub const DEFAULT_HEADER: &str = "x-api-key";
35
36/// What qcli's own keys may hold. Every value is text; an empty one is the same as none.
37#[must_use]
38pub fn schema() -> Schema {
39    Schema::builtin().text(ADDRESS, "").text(MODEL, "").text(KEY_FILE, "").text(KEY_HEADER, DEFAULT_HEADER)
40}
41
42/// The settings from the ecosystem's folder, checked and healed.
43#[must_use]
44pub fn load() -> Settings {
45    checked(Settings::load_member(&Ecosystem::QUVYTA, APP))
46}
47
48/// [`load`] from `folder` as the ecosystem's folder, so a test never touches the person's own
49/// settings.
50#[must_use]
51pub fn load_in(folder: &Path) -> Settings {
52    checked(Settings::open(folder.join(format!("{APP}.conf"))))
53}
54
55fn checked(settings: Settings) -> Settings {
56    settings.member_of(&Ecosystem::QUVYTA).schema(schema()).self_heal(true)
57}
58
59/// The language, theme and icons as qcli sees them: its own when its file names one, else the
60/// ecosystem's, else what this machine asks for.
61#[must_use]
62pub fn preferences() -> Preferences {
63    Ecosystem::QUVYTA.preferences(APP, &spoken())
64}
65
66/// The languages qcli speaks, for choosing the machine's one before the runtime is built.
67#[must_use]
68pub fn spoken() -> I18n {
69    let mut i18n = I18n::builtin();
70    for &(file, text) in crate::locales() {
71        i18n.add_source(file, text);
72    }
73    i18n
74}
75
76/// Where the ecosystem's update notice reads its switch and remembers when it last asked.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct UpdateFolders {
79    /// The ecosystem's configuration folder, whose shared file holds the switch.
80    pub config: PathBuf,
81    /// qcli's state folder, which remembers when the question was last asked.
82    pub state: PathBuf,
83}
84
85impl UpdateFolders {
86    /// This machine's folders, or `None` without a home folder, where nothing could remember the
87    /// switch or the last question and so nothing is asked.
88    #[must_use]
89    pub fn here() -> Option<Self> {
90        let ecosystem = Ecosystem::QUVYTA;
91        ecosystem.config_dir().zip(ecosystem.state_dir(APP)).map(|(config, state)| Self { config, state })
92    }
93}
94
95/// The provider as the settings describe it. Every field is what the person wrote, trimmed.
96#[derive(Debug, Clone, PartialEq, Eq, Default)]
97pub struct Provider {
98    /// The address the Messages API lives under.
99    pub address: String,
100    /// The model asked for.
101    pub model: String,
102    /// The path of the key file, as written: `~/` stands for the home folder.
103    pub key_file: String,
104    /// The header the key goes in.
105    pub key_header: String,
106}
107
108/// Why the provider cannot be asked yet.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Missing {
111    /// One of the fields is empty; the key of the first one.
112    Field(&'static str),
113    /// The key file gave no key.
114    Key(KeyError),
115}
116
117impl Provider {
118    /// The provider in `settings`.
119    #[must_use]
120    pub fn from_settings(settings: &Settings) -> Self {
121        let text = |key: &str| settings.get::<String>(key).unwrap_or_default().trim().to_owned();
122        let header = text(KEY_HEADER);
123        Self {
124            address: text(ADDRESS),
125            model: text(MODEL),
126            key_file: text(KEY_FILE),
127            key_header: if header.is_empty() { DEFAULT_HEADER.to_owned() } else { header },
128        }
129    }
130
131    /// Writes the provider into `settings`. A header that is the default is not written, the
132    /// way the ecosystem writes no default.
133    pub fn write(&self, settings: &mut Settings) {
134        settings.set(ADDRESS, self.address.trim().to_owned());
135        settings.set(MODEL, self.model.trim().to_owned());
136        settings.set(KEY_FILE, self.key_file.trim().to_owned());
137        let header = self.key_header.trim();
138        if header.is_empty() || header == DEFAULT_HEADER {
139            settings.remove(KEY_HEADER);
140        } else {
141            settings.set(KEY_HEADER, header.to_owned());
142        }
143    }
144
145    /// The first empty field, if any: the provider cannot be asked without all of them.
146    #[must_use]
147    pub fn missing_field(&self) -> Option<&'static str> {
148        [(ADDRESS, &self.address), (MODEL, &self.model), (KEY_FILE, &self.key_file)]
149            .into_iter()
150            .find_map(|(key, value)| value.is_empty().then_some(key))
151    }
152
153    /// The key file's path, with `~/` read as the home folder.
154    #[must_use]
155    pub fn key_path(&self) -> PathBuf {
156        expand_home(&self.key_file)
157    }
158
159    /// The endpoint to ask, with the key read from its file now.
160    ///
161    /// # Errors
162    ///
163    /// When a field is empty or the key file gives no key.
164    pub fn endpoint(&self) -> Result<Endpoint, Missing> {
165        if let Some(field) = self.missing_field() {
166            return Err(Missing::Field(field));
167        }
168        let key = Key::from_file(&self.key_path()).map_err(Missing::Key)?;
169        Ok(Endpoint {
170            address: self.address.clone(),
171            model: self.model.clone(),
172            key_header: self.key_header.clone(),
173            key,
174        })
175    }
176}
177
178/// `path` with a leading `~/` replaced by the home folder.
179fn expand_home(path: &str) -> PathBuf {
180    match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
181        (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
182        _ => PathBuf::from(path),
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn folder(name: &str) -> PathBuf {
191        let folder = std::env::temp_dir().join(format!("qcli-config-{name}-{}", std::process::id()));
192        let _ = std::fs::remove_dir_all(&folder);
193        std::fs::create_dir_all(&folder).expect("folder");
194        folder
195    }
196
197    #[test]
198    fn the_provider_is_written_and_read_back_and_the_default_header_is_not_written() {
199        let folder = folder("round");
200        let mut settings = load_in(&folder);
201        let provider = Provider {
202            address: " http://127.0.0.1:9/anthropic ".into(),
203            model: "some-model".into(),
204            key_file: "~/keys/made-up".into(),
205            key_header: DEFAULT_HEADER.into(),
206        };
207        provider.write(&mut settings);
208        settings.save().expect("saved");
209        let text = std::fs::read_to_string(folder.join("cli.conf")).expect("the file");
210        assert!(text.contains("address = \"http://127.0.0.1:9/anthropic\""), "{text}");
211        assert!(!text.contains(KEY_HEADER), "the default header is not written: {text}");
212
213        let read = Provider::from_settings(&load_in(&folder));
214        assert_eq!(read.address, "http://127.0.0.1:9/anthropic");
215        assert_eq!(read.key_header, DEFAULT_HEADER);
216
217        let mut settings = load_in(&folder);
218        Provider { key_header: "api-key".into(), ..read }.write(&mut settings);
219        settings.save().expect("saved");
220        assert_eq!(Provider::from_settings(&load_in(&folder)).key_header, "api-key");
221        std::fs::remove_dir_all(&folder).expect("clean up");
222    }
223
224    #[test]
225    fn an_endpoint_needs_every_field_and_a_key_in_the_file() {
226        let folder = folder("endpoint");
227        let key_file = folder.join("key");
228        let mut provider = Provider {
229            address: "http://127.0.0.1:9".into(),
230            model: String::new(),
231            key_file: key_file.display().to_string(),
232            key_header: "api-key".into(),
233        };
234        assert_eq!(provider.endpoint().expect_err("no model"), Missing::Field(MODEL));
235        provider.model = "m".into();
236        assert!(matches!(provider.endpoint(), Err(Missing::Key(KeyError::Unreadable { .. }))));
237        std::fs::write(&key_file, "not-a-real-key-0000-wxyz\n").expect("key");
238        let endpoint = provider.endpoint().expect("an endpoint");
239        assert_eq!((endpoint.model.as_str(), endpoint.key_header.as_str()), ("m", "api-key"));
240        std::fs::remove_dir_all(&folder).expect("clean up");
241    }
242
243    #[test]
244    fn a_home_path_is_read_from_the_home_folder() {
245        let home = std::env::var_os("HOME").map(PathBuf::from).expect("a home in tests");
246        assert_eq!(expand_home("~/a/b"), home.join("a/b"));
247        assert_eq!(expand_home("/a/b"), PathBuf::from("/a/b"));
248    }
249}