Skip to main content

qcli/
config.rs

1//! Where qcli keeps its settings: `cli.conf` in the Quvyta family's folder, beside the other
2//! applications of the family.
3//!
4//! ```text
5//! ~/.config/quvyta/
6//!     quvyta.conf     the language, theme and icons the family 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::{Family, Preferences, Schema, Settings};
18
19use crate::provider::{Endpoint, Key, KeyError};
20
21/// The application's id in the family: 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 family's folder, checked and healed.
43#[must_use]
44pub fn load() -> Settings {
45    checked(Settings::load_member(&Family::QUVYTA, APP))
46}
47
48/// [`load`] from `folder` as the family'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(&Family::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/// family's, else what this machine asks for.
61#[must_use]
62pub fn preferences() -> Preferences {
63    Family::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/// The provider as the settings describe it. Every field is what the person wrote, trimmed.
77#[derive(Debug, Clone, PartialEq, Eq, Default)]
78pub struct Provider {
79    /// The address the Messages API lives under.
80    pub address: String,
81    /// The model asked for.
82    pub model: String,
83    /// The path of the key file, as written: `~/` stands for the home folder.
84    pub key_file: String,
85    /// The header the key goes in.
86    pub key_header: String,
87}
88
89/// Why the provider cannot be asked yet.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum Missing {
92    /// One of the fields is empty; the key of the first one.
93    Field(&'static str),
94    /// The key file gave no key.
95    Key(KeyError),
96}
97
98impl Provider {
99    /// The provider in `settings`.
100    #[must_use]
101    pub fn from_settings(settings: &Settings) -> Self {
102        let text = |key: &str| settings.get::<String>(key).unwrap_or_default().trim().to_owned();
103        let header = text(KEY_HEADER);
104        Self {
105            address: text(ADDRESS),
106            model: text(MODEL),
107            key_file: text(KEY_FILE),
108            key_header: if header.is_empty() { DEFAULT_HEADER.to_owned() } else { header },
109        }
110    }
111
112    /// Writes the provider into `settings`. A header that is the default is not written, the
113    /// way the family writes no default.
114    pub fn write(&self, settings: &mut Settings) {
115        settings.set(ADDRESS, self.address.trim().to_owned());
116        settings.set(MODEL, self.model.trim().to_owned());
117        settings.set(KEY_FILE, self.key_file.trim().to_owned());
118        let header = self.key_header.trim();
119        if header.is_empty() || header == DEFAULT_HEADER {
120            settings.remove(KEY_HEADER);
121        } else {
122            settings.set(KEY_HEADER, header.to_owned());
123        }
124    }
125
126    /// The first empty field, if any: the provider cannot be asked without all of them.
127    #[must_use]
128    pub fn missing_field(&self) -> Option<&'static str> {
129        [(ADDRESS, &self.address), (MODEL, &self.model), (KEY_FILE, &self.key_file)]
130            .into_iter()
131            .find_map(|(key, value)| value.is_empty().then_some(key))
132    }
133
134    /// The key file's path, with `~/` read as the home folder.
135    #[must_use]
136    pub fn key_path(&self) -> PathBuf {
137        expand_home(&self.key_file)
138    }
139
140    /// The endpoint to ask, with the key read from its file now.
141    ///
142    /// # Errors
143    ///
144    /// When a field is empty or the key file gives no key.
145    pub fn endpoint(&self) -> Result<Endpoint, Missing> {
146        if let Some(field) = self.missing_field() {
147            return Err(Missing::Field(field));
148        }
149        let key = Key::from_file(&self.key_path()).map_err(Missing::Key)?;
150        Ok(Endpoint {
151            address: self.address.clone(),
152            model: self.model.clone(),
153            key_header: self.key_header.clone(),
154            key,
155        })
156    }
157}
158
159/// `path` with a leading `~/` replaced by the home folder.
160fn expand_home(path: &str) -> PathBuf {
161    match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
162        (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
163        _ => PathBuf::from(path),
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn folder(name: &str) -> PathBuf {
172        let folder = std::env::temp_dir().join(format!("qcli-config-{name}-{}", std::process::id()));
173        let _ = std::fs::remove_dir_all(&folder);
174        std::fs::create_dir_all(&folder).expect("folder");
175        folder
176    }
177
178    #[test]
179    fn the_provider_is_written_and_read_back_and_the_default_header_is_not_written() {
180        let folder = folder("round");
181        let mut settings = load_in(&folder);
182        let provider = Provider {
183            address: " http://127.0.0.1:9/anthropic ".into(),
184            model: "some-model".into(),
185            key_file: "~/keys/made-up".into(),
186            key_header: DEFAULT_HEADER.into(),
187        };
188        provider.write(&mut settings);
189        settings.save().expect("saved");
190        let text = std::fs::read_to_string(folder.join("cli.conf")).expect("the file");
191        assert!(text.contains("address = \"http://127.0.0.1:9/anthropic\""), "{text}");
192        assert!(!text.contains(KEY_HEADER), "the default header is not written: {text}");
193
194        let read = Provider::from_settings(&load_in(&folder));
195        assert_eq!(read.address, "http://127.0.0.1:9/anthropic");
196        assert_eq!(read.key_header, DEFAULT_HEADER);
197
198        let mut settings = load_in(&folder);
199        Provider { key_header: "api-key".into(), ..read }.write(&mut settings);
200        settings.save().expect("saved");
201        assert_eq!(Provider::from_settings(&load_in(&folder)).key_header, "api-key");
202        std::fs::remove_dir_all(&folder).expect("clean up");
203    }
204
205    #[test]
206    fn an_endpoint_needs_every_field_and_a_key_in_the_file() {
207        let folder = folder("endpoint");
208        let key_file = folder.join("key");
209        let mut provider = Provider {
210            address: "http://127.0.0.1:9".into(),
211            model: String::new(),
212            key_file: key_file.display().to_string(),
213            key_header: "api-key".into(),
214        };
215        assert_eq!(provider.endpoint().expect_err("no model"), Missing::Field(MODEL));
216        provider.model = "m".into();
217        assert!(matches!(provider.endpoint(), Err(Missing::Key(KeyError::Unreadable { .. }))));
218        std::fs::write(&key_file, "not-a-real-key-0000-wxyz\n").expect("key");
219        let endpoint = provider.endpoint().expect("an endpoint");
220        assert_eq!((endpoint.model.as_str(), endpoint.key_header.as_str()), ("m", "api-key"));
221        std::fs::remove_dir_all(&folder).expect("clean up");
222    }
223
224    #[test]
225    fn a_home_path_is_read_from_the_home_folder() {
226        let home = std::env::var_os("HOME").map(PathBuf::from).expect("a home in tests");
227        assert_eq!(expand_home("~/a/b"), home.join("a/b"));
228        assert_eq!(expand_home("/a/b"), PathBuf::from("/a/b"));
229    }
230}