1use std::path::{Path, PathBuf};
15
16use qframe::i18n::I18n;
17use qframe::storage::{Ecosystem, Preferences, Schema, Settings};
18
19use crate::provider::{Endpoint, Key, KeyError};
20
21pub const APP: &str = "cli";
23
24pub const ADDRESS: &str = "address";
26pub const MODEL: &str = "model";
28pub const KEY_FILE: &str = "key-file";
30pub const KEY_HEADER: &str = "key-header";
32
33pub const DEFAULT_HEADER: &str = "x-api-key";
35
36#[must_use]
38pub fn schema() -> Schema {
39 Schema::builtin().text(ADDRESS, "").text(MODEL, "").text(KEY_FILE, "").text(KEY_HEADER, DEFAULT_HEADER)
40}
41
42#[must_use]
44pub fn load() -> Settings {
45 checked(Settings::load_member(&Ecosystem::QUVYTA, APP))
46}
47
48#[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#[must_use]
62pub fn preferences() -> Preferences {
63 Ecosystem::QUVYTA.preferences(APP, &spoken())
64}
65
66#[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#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct UpdateFolders {
79 pub config: PathBuf,
81 pub state: PathBuf,
83}
84
85impl UpdateFolders {
86 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
97pub struct Provider {
98 pub address: String,
100 pub model: String,
102 pub key_file: String,
104 pub key_header: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Missing {
111 Field(&'static str),
113 Key(KeyError),
115}
116
117impl Provider {
118 #[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 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 #[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 #[must_use]
155 pub fn key_path(&self) -> PathBuf {
156 expand_home(&self.key_file)
157 }
158
159 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
178fn 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}