1use std::path::{Path, PathBuf};
20
21use qframe::i18n::I18n;
22use qframe::storage::{Ecosystem, Schema, Settings};
23
24use crate::provider::{Endpoint, Key, KeyError};
25
26pub const APP: &str = "cli";
28
29pub const ADDRESS: &str = "address";
31pub const MODEL: &str = "model";
33pub const KEY_FILE: &str = "key-file";
35pub const KEY_HEADER: &str = "key-header";
37
38pub const DEFAULT_HEADER: &str = "x-api-key";
40
41pub const ADDRESS_VARIABLE: &str = "QCLI_ADDRESS";
43pub const MODEL_VARIABLE: &str = "QCLI_MODEL";
45
46#[must_use]
48pub fn schema() -> Schema {
49 Schema::builtin().text(ADDRESS, "").text(MODEL, "").text(KEY_FILE, "").text(KEY_HEADER, DEFAULT_HEADER)
50}
51
52#[must_use]
54pub fn load() -> Settings {
55 checked(Settings::load_member(&Ecosystem::QUVYTA, APP))
56}
57
58#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct UpdateFolders {
82 pub config: PathBuf,
84 pub state: PathBuf,
86}
87
88impl UpdateFolders {
89 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum Source {
101 #[default]
103 Settings,
104 Profile,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum ProfileError {
114 Missing(&'static str),
116 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
141pub struct Provider {
142 pub address: String,
144 pub model: String,
146 pub key_file: String,
148 pub key_header: String,
150 pub source: Source,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum Missing {
157 Field(&'static str),
159 Key(KeyError),
161}
162
163impl Provider {
164 #[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 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 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 #[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 #[must_use]
232 pub fn key_path(&self) -> PathBuf {
233 expand_home(&self.key_file)
234 }
235
236 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#[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
278pub(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
289pub(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
294fn 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 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}