1use std::fmt;
11use std::path::{Path, PathBuf};
12
13const SHOWN: usize = 4;
15
16const SHOWABLE: usize = 12;
18
19#[derive(Clone, PartialEq, Eq)]
21pub struct Key(String);
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum KeyError {
26 Unreadable {
28 path: PathBuf,
30 reason: String,
32 },
33 NotAKey {
35 path: PathBuf,
37 },
38}
39
40impl fmt::Display for KeyError {
41 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 Self::Unreadable { path, reason } => write!(formatter, "{}: {reason}", path.display()),
44 Self::NotAKey { path } => write!(formatter, "{}", path.display()),
45 }
46 }
47}
48
49impl Key {
50 #[must_use]
54 pub fn new(text: &str) -> Option<Self> {
55 let trimmed = text.trim();
56 if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
58 return None;
59 }
60 Some(Self(trimmed.to_owned()))
61 }
62
63 pub fn from_file(path: &Path) -> Result<Self, KeyError> {
69 let text = std::fs::read_to_string(path)
70 .map_err(|error| KeyError::Unreadable { path: path.to_owned(), reason: error.kind().to_string() })?;
71 Self::new(&text).ok_or_else(|| KeyError::NotAKey { path: path.to_owned() })
72 }
73
74 #[must_use]
77 pub fn last_four(&self) -> Option<String> {
78 let characters: Vec<char> = self.0.chars().collect();
79 (characters.len() >= SHOWABLE).then(|| characters[characters.len() - SHOWN..].iter().collect())
80 }
81
82 pub(crate) fn expose(&self) -> &str {
84 &self.0
85 }
86}
87
88impl fmt::Debug for Key {
89 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92 match self.last_four() {
93 Some(tail) => write!(formatter, "Key(…{tail})"),
94 None => formatter.write_str("Key(…)"),
95 }
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 const MADE_UP: &str = "not-a-real-key-0000-wxyz";
105
106 #[test]
107 fn only_the_last_four_characters_are_ever_shown() {
108 let key = Key::new(MADE_UP).expect("a key");
109 assert_eq!(format!("{key:?}"), "Key(…wxyz)");
110 assert!(!format!("{key:?}").contains("not-a-real"));
111 let short = Key::new("abcdefgh").expect("a key");
112 assert_eq!(format!("{short:?}"), "Key(…)", "four of eight characters is most of the key");
113 }
114
115 #[test]
116 fn a_key_file_gives_its_one_line_and_its_errors_name_only_the_file() {
117 let folder = std::env::temp_dir().join(format!("qcli-key-{}", std::process::id()));
118 std::fs::create_dir_all(&folder).expect("folder");
119 let file = folder.join("key");
120 std::fs::write(&file, format!("{MADE_UP}\n")).expect("write");
121 assert_eq!(Key::from_file(&file).expect("a key").expose(), MADE_UP);
122
123 std::fs::write(&file, format!("{MADE_UP}\nsecond line")).expect("write");
124 let error = Key::from_file(&file).expect_err("two lines are not a key");
125 assert_eq!(error, KeyError::NotAKey { path: file.clone() });
126 assert!(!error.to_string().contains("not-a-real"), "the error does not quote the file");
127
128 let missing = Key::from_file(&folder.join("missing")).expect_err("no file");
129 assert!(matches!(missing, KeyError::Unreadable { .. }));
130 std::fs::remove_dir_all(&folder).expect("clean up");
131 }
132}