1use serde_json::{Map, Value};
8
9use crate::Kind;
10use crate::msg::{ConnectionField, FieldKind};
11
12pub const CORE_KEYS: &[&str] = &["threads"];
14
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
17pub struct Checked {
18 pub errors: Vec<String>,
19 pub warnings: Vec<String>,
20}
21
22pub fn check(
25 kind: Kind,
26 name: &str,
27 fields: &[ConnectionField],
28 connection: &Map<String, Value>,
29 unresolved: &[String],
30) -> Checked {
31 let mut out = Checked::default();
32 for (key, v) in connection {
33 let Some(f) = fields.iter().find(|f| &f.name == key) else {
34 if CORE_KEYS.contains(&key.as_str()) {
36 continue;
37 }
38 let hint = closest(key, fields.iter().map(|f| f.name.as_str()))
39 .map(|c| format!("; did you mean `{c}`?"))
40 .unwrap_or_default();
41 out.warnings
42 .push(format!("unknown key `{key}` for {kind} `{name}`, ignored{hint}"));
43 continue;
44 };
45 if v.is_null() || unresolved.iter().any(|u| u == key) {
46 continue;
47 }
48 if v.as_str().is_some_and(crate::options::is_template) {
50 continue;
51 }
52 if let Some(e) = check_value(f, v) {
53 out.errors.push(format!("`{key}` {e}"));
54 }
55 }
56 for f in fields.iter().filter(|f| f.required && f.default.is_none()) {
57 let set =
58 connection.get(&f.name).is_some_and(|v| !v.is_null()) || unresolved.iter().any(|u| u == &f.name);
59 if !set {
60 out.errors.push(format!("`{}` is required", f.name));
61 }
62 }
63 out
64}
65
66fn check_value(f: &ConnectionField, v: &Value) -> Option<String> {
67 let ok = match f.kind {
68 None => true,
69 Some(FieldKind::String) => v.is_string() || v.is_number() || v.is_boolean(),
70 Some(FieldKind::Integer) => {
71 v.is_i64() || v.is_u64() || v.as_str().is_some_and(|s| s.trim().parse::<i64>().is_ok())
72 }
73 Some(FieldKind::Boolean) => v.is_boolean(),
74 Some(FieldKind::Duration) => crate::delivery::parse_duration(v).is_ok(),
75 Some(FieldKind::Map) => v.is_object(),
76 Some(FieldKind::Path) => match v.as_str() {
77 Some(p) => {
78 let path = expand_home(p);
79 if !path.exists() {
80 return Some(format!("names a file that doesn't exist ({})", path.display()));
81 }
82 true
83 }
84 None => false,
85 },
86 };
87 if !ok {
88 return Some(match f.kind {
89 Some(FieldKind::Integer) => "must be a whole number".into(),
90 Some(FieldKind::Boolean) => "must be true or false".into(),
91 Some(FieldKind::Duration) => "must be a duration such as `30s` or `2m`, or seconds".into(),
92 Some(FieldKind::Map) => "must be a block of settings".into(),
93 Some(FieldKind::Path) => "must be a file path".into(),
94 _ => "must be a string".into(),
95 });
96 }
97 if !f.choices.is_empty() {
98 let s = match v {
99 Value::String(s) => s.clone(),
100 other => other.to_string(),
101 };
102 if !f.choices.contains(&s) {
103 let shown: Vec<String> = f.choices.iter().map(|c| format!("`{c}`")).collect();
104 return Some(format!("must be one of {}", shown.join(", ")));
105 }
106 }
107 None
108}
109
110fn expand_home(p: &str) -> std::path::PathBuf {
112 match p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
113 Some(rest) => std::env::home_dir().unwrap_or_default().join(rest),
114 None => std::path::PathBuf::from(p),
115 }
116}
117
118pub fn closest<'a>(key: &str, names: impl Iterator<Item = &'a str>) -> Option<&'a str> {
120 names
121 .map(|n| (distance(key, n), n))
122 .filter(|(d, n)| *d <= 2.max(n.len() / 4))
123 .min_by_key(|(d, _)| *d)
124 .map(|(_, n)| n)
125}
126
127fn distance(a: &str, b: &str) -> usize {
129 let b: Vec<char> = b.chars().collect();
130 let mut row: Vec<usize> = (0..=b.len()).collect();
132 for (i, ca) in a.chars().enumerate() {
133 let mut prev = row[0];
134 row[0] = i + 1;
135 for (j, cb) in b.iter().enumerate() {
136 let cur = row[j + 1];
137 row[j + 1] = (prev + usize::from(ca != *cb))
138 .min(row[j] + 1)
139 .min(row[j + 1] + 1);
140 prev = cur;
141 }
142 }
143 row[b.len()]
144}
145
146#[must_use]
149pub fn redact(
150 messages: Vec<String>,
151 fields: &[ConnectionField],
152 connection: &Map<String, Value>,
153) -> Vec<String> {
154 let secrets: Vec<&str> = fields
155 .iter()
156 .filter(|f| f.secret)
157 .filter_map(|f| connection.get(&f.name).and_then(Value::as_str))
158 .filter(|s| s.len() >= 3)
159 .collect();
160 messages
161 .into_iter()
162 .map(|m| secrets.iter().fold(m, |m, s| m.replace(s, "*****")))
163 .collect()
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use serde_json::json;
170
171 fn fields() -> Vec<ConnectionField> {
172 vec![
173 ConnectionField::new("host", "").required(),
174 ConnectionField::new("port", "")
175 .kind(FieldKind::Integer)
176 .default(5432),
177 ConnectionField::new("password", "").secret(),
178 ConnectionField::new("sslmode", "").choices(&["disable", "require"]),
179 ConnectionField::new("key_path", "").kind(FieldKind::Path),
180 ConnectionField::new("timeout", "").kind(FieldKind::Duration),
181 ConnectionField::new("passive", "").kind(FieldKind::Boolean),
182 ]
183 }
184
185 fn run(c: Value, unresolved: &[&str]) -> Checked {
186 let u: Vec<String> = unresolved.iter().map(|s| s.to_string()).collect();
187 check(Kind::Source, "pg", &fields(), c.as_object().unwrap(), &u)
188 }
189
190 #[test]
191 fn required_kind_choices_and_unknown_keys() {
192 let c = run(
193 json!({"port": "abc", "sslmode": "verify", "timeout": "soon", "passive": "yes", "hots": "x"}),
194 &[],
195 );
196 assert_eq!(
197 c.errors,
198 [
199 "`port` must be a whole number",
200 "`sslmode` must be one of `disable`, `require`",
201 "`timeout` must be a duration such as `30s` or `2m`, or seconds",
202 "`passive` must be true or false",
203 "`host` is required",
204 ]
205 );
206 assert_eq!(
207 c.warnings,
208 ["unknown key `hots` for source `pg`, ignored; did you mean `host`?"]
209 );
210 let ok = run(
211 json!({"host": "h", "port": "5432", "timeout": "30s", "passive": true}),
212 &[],
213 );
214 assert_eq!(ok, Checked::default());
215 }
216
217 #[test]
218 fn a_path_must_exist_and_unresolved_values_count_as_set() {
219 let c = run(json!({"host": "h", "key_path": "/no/such/key"}), &[]);
220 assert!(
221 c.errors[0].starts_with("`key_path` names a file that doesn't exist"),
222 "{c:?}"
223 );
224 let c = run(json!({"host": null, "port": null}), &["host", "port"]);
225 assert_eq!(c, Checked::default());
226 }
227
228 #[test]
229 fn secrets_are_redacted() {
230 let c = json!({"password": "hunter22"});
231 let m = redact(
232 vec!["bad password hunter22".into()],
233 &fields(),
234 c.as_object().unwrap(),
235 );
236 assert_eq!(m, ["bad password *****"]);
237 }
238}