1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::error::ProfileError;
10
11pub const SCHEMA_VERSION_MAX: u32 = 1;
13
14#[derive(Debug, Clone)]
17pub struct LoadOptions<'a> {
18 pub id: Option<&'a str>,
20 pub explicit_file: Option<&'a Path>,
22 pub extra_profile_dirs: Vec<PathBuf>,
24 pub include_shipped: bool,
26 pub include_user_config: bool,
28}
29
30impl Default for LoadOptions<'_> {
31 fn default() -> Self {
32 Self {
33 id: None,
34 explicit_file: None,
35 extra_profile_dirs: Vec::new(),
36 include_shipped: true,
37 include_user_config: true,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct ResolvedProfile {
45 pub schema_version: u32,
47 pub id: String,
49 pub display_name: Option<String>,
51 pub dialect: Dialect,
53 pub http: Http,
55 pub oauth: Option<OauthPack>,
57 pub access_env: Vec<String>,
59 pub fingerprint: Option<Fingerprint>,
61 pub betas: Betas,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Dialect {
68 pub wire: Option<Wire>,
70 pub stream_events: Vec<String>,
72 pub list_merge: ListMerge,
74 pub tool_type_policy: ToolTypePolicy,
76 pub stream_unknown_policy: StreamUnknownPolicy,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
101#[non_exhaustive]
102#[serde(rename_all = "kebab-case")]
103pub enum Wire {
104 ChatCompletions,
106 Messages,
108 Responses,
110 Gemini,
112 Converse,
114}
115
116impl Wire {
117 pub const NAMES: &'static [&'static str] = &[
119 "chat-completions",
120 "messages",
121 "responses",
122 "gemini",
123 "converse",
124 ];
125
126 #[must_use]
128 pub fn as_str(self) -> &'static str {
129 match self {
130 Self::ChatCompletions => "chat-completions",
131 Self::Messages => "messages",
132 Self::Responses => "responses",
133 Self::Gemini => "gemini",
134 Self::Converse => "converse",
135 }
136 }
137
138 pub(crate) fn parse(s: &str) -> Result<Self, String> {
139 match s {
140 "chat-completions" => Ok(Self::ChatCompletions),
141 "messages" => Ok(Self::Messages),
142 "responses" => Ok(Self::Responses),
143 "gemini" => Ok(Self::Gemini),
144 "converse" => Ok(Self::Converse),
145 other => Err(unknown_kebab("wire", other, Self::NAMES)),
146 }
147 }
148
149 #[must_use]
151 pub fn suggest(s: &str) -> Option<&'static str> {
152 suggest_kebab(s, Self::NAMES)
153 }
154
155 #[must_use]
157 pub fn default_stream_events(self) -> &'static [&'static str] {
158 match self {
159 Self::ChatCompletions => &["chunk", "[DONE]"],
160 Self::Messages => &[
161 "message_start",
162 "content_block_start",
163 "content_block_delta",
164 "content_block_stop",
165 "message_delta",
166 "message_stop",
167 "ping",
168 "error",
169 ],
170 Self::Responses => &[
171 "response.created",
172 "response.in_progress",
173 "response.output_item.added",
174 "response.content_part.added",
175 "response.content_part.done",
176 "response.output_text.delta",
177 "response.output_text.done",
178 "response.reasoning_summary_part.added",
179 "response.reasoning_summary_part.done",
180 "response.reasoning_summary_text.delta",
181 "response.reasoning_summary_text.done",
182 "response.reasoning.delta",
183 "response.reasoning_text.delta",
184 "response.reasoning_text.done",
185 "response.refusal.delta",
186 "response.refusal.done",
187 "response.output_text.annotation.added",
188 "response.audio.delta",
189 "response.audio.done",
190 "response.audio.transcript.delta",
191 "response.audio.transcript.done",
192 "response.function_call_arguments.delta",
193 "response.function_call_arguments.done",
194 "response.custom_tool_call_input.delta",
195 "response.custom_tool_call_input.done",
196 "response.output_item.done",
197 "response.completed",
198 "response.failed",
199 "response.incomplete",
200 "error",
201 "response.web_search_call.in_progress",
202 "response.web_search_call.searching",
203 "response.web_search_call.completed",
204 "response.file_search_call.in_progress",
205 "response.file_search_call.searching",
206 "response.file_search_call.completed",
207 "response.code_interpreter_call.in_progress",
208 "response.code_interpreter_call.interpreting",
209 "response.code_interpreter_call.completed",
210 "response.image_generation_call.in_progress",
211 "response.image_generation_call.generating",
212 "response.image_generation_call.completed",
213 "response.mcp_call.in_progress",
214 "response.mcp_call.completed",
215 "response.mcp_call.failed",
216 "response.mcp_list_tools.in_progress",
217 "response.mcp_list_tools.completed",
218 "response.mcp_list_tools.failed",
219 ],
220 Self::Gemini => &["chunk"],
221 Self::Converse => &[
222 "messageStart",
223 "contentBlockStart",
224 "contentBlockDelta",
225 "contentBlockStop",
226 "messageStop",
227 "metadata",
228 ],
229 }
230 }
231
232 #[must_use]
234 pub fn default_chat_path(self) -> &'static str {
235 match self {
236 Self::ChatCompletions => "/v1/chat/completions",
237 Self::Messages => "/v1/messages",
238 Self::Responses => "/v1/responses",
239 Self::Gemini => "/v1beta/models/{model}:generateContent",
240 Self::Converse => "/model/{model}/converse",
241 }
242 }
243
244 #[must_use]
246 pub fn default_auth_scheme(self) -> AuthScheme {
247 match self {
248 Self::Messages => AuthScheme::XApiKey,
249 Self::Gemini => AuthScheme::Header("x-goog-api-key".into()),
250 Self::ChatCompletions | Self::Responses | Self::Converse => AuthScheme::Bearer,
251 }
252 }
253}
254
255impl<'de> Deserialize<'de> for Wire {
256 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
257 deserialize_kebab(deserializer, Self::parse)
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
263#[serde(rename_all = "kebab-case")]
264pub enum ListMerge {
265 #[default]
267 Union,
268 Replace,
270}
271
272impl ListMerge {
273 pub(crate) fn parse(s: &str) -> Result<Self, String> {
274 match s {
275 "union" => Ok(Self::Union),
276 "replace" | "verbatim" => Ok(Self::Replace),
277 other => Err(unknown_kebab(
278 "list_merge",
279 other,
280 &["union", "replace", "verbatim"],
281 )),
282 }
283 }
284}
285
286impl<'de> Deserialize<'de> for ListMerge {
287 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
288 deserialize_kebab(deserializer, Self::parse)
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
294#[serde(rename_all = "kebab-case")]
295pub enum ToolTypePolicy {
296 Passthrough,
298 FlattenNamespace,
300 #[default]
302 HardError,
303}
304
305impl ToolTypePolicy {
306 pub(crate) fn parse(s: &str) -> Result<Self, String> {
307 match s {
308 "passthrough" => Ok(Self::Passthrough),
309 "flatten-namespace" => Ok(Self::FlattenNamespace),
310 "hard-error" => Ok(Self::HardError),
311 other => Err(unknown_kebab(
312 "tool_type_policy",
313 other,
314 &["passthrough", "flatten-namespace", "hard-error"],
315 )),
316 }
317 }
318}
319
320impl<'de> Deserialize<'de> for ToolTypePolicy {
321 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
322 deserialize_kebab(deserializer, Self::parse)
323 }
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
328#[serde(rename_all = "kebab-case")]
329pub enum StreamUnknownPolicy {
330 #[default]
332 HardError,
333 Passthrough,
335}
336
337impl StreamUnknownPolicy {
338 pub(crate) fn parse(s: &str) -> Result<Self, String> {
339 match s {
340 "hard-error" => Ok(Self::HardError),
341 "passthrough" => Ok(Self::Passthrough),
342 other => Err(unknown_kebab(
343 "stream_unknown_policy",
344 other,
345 &["hard-error", "passthrough"],
346 )),
347 }
348 }
349}
350
351impl<'de> Deserialize<'de> for StreamUnknownPolicy {
352 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
353 deserialize_kebab(deserializer, Self::parse)
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct Http {
360 pub base_url: Option<String>,
362 pub chat_path: Option<String>,
364 pub auth_scheme: Option<AuthScheme>,
366 pub headers: BTreeMap<String, String>,
368 pub header_merge: ListMerge,
370 pub aws_service: Option<String>,
372 pub aws_region: Option<String>,
374 pub gcp_key_env: Option<String>,
376 pub read_timeout_secs: Option<u64>,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum AuthScheme {
383 Bearer,
385 XApiKey,
387 Header(String),
389 None,
391}
392
393impl AuthScheme {
394 pub(crate) fn parse(s: &str) -> Result<Self, ProfileError> {
395 if let Some(name) = s.strip_prefix("header:") {
396 if name.is_empty() {
397 return Err(ProfileError::Parse(
398 "auth_scheme header: requires a header name".into(),
399 ));
400 }
401 return Ok(Self::Header(name.to_string()));
402 }
403 match s {
404 "bearer" => Ok(Self::Bearer),
405 "x-api-key" => Ok(Self::XApiKey),
406 "none" => Ok(Self::None),
407 other => {
408 let listed = "bearer|x-api-key|none|header:<name>";
409 let mut msg = format!("unknown auth_scheme `{other}` ({listed})");
410 if let Some(suggest) = suggest_auth_scheme(other) {
411 msg.push_str(&format!("; did you mean `{suggest}`"));
412 }
413 Err(ProfileError::Parse(msg))
414 }
415 }
416 }
417}
418
419const AUTH_SCHEME_NAMES: &[&str] = &["bearer", "x-api-key", "none", "header:<name>"];
420
421fn suggest_auth_scheme(s: &str) -> Option<&'static str> {
422 suggest_kebab(s, AUTH_SCHEME_NAMES)
423}
424
425fn fold_enum_key(s: &str) -> String {
426 s.to_ascii_lowercase().replace(['_', '-'], "")
427}
428
429pub(crate) fn suggest_kebab<'a>(input: &str, legal: &[&'a str]) -> Option<&'a str> {
431 let folded = fold_enum_key(input);
432 if folded.is_empty() {
433 return None;
434 }
435 let mut best: Option<(&'a str, usize)> = None;
436 let mut prefix = Vec::new();
437 for &opt in legal {
438 let candidate = fold_enum_key(opt);
439 if candidate == folded {
440 return Some(opt);
441 }
442 let dist = edit_distance(&folded, &candidate);
443 if dist > 0 && dist <= 2 {
444 match best {
445 None => best = Some((opt, dist)),
446 Some((_, best_dist)) if dist < best_dist => best = Some((opt, dist)),
447 Some((best_opt, best_dist)) if dist == best_dist && opt.len() > best_opt.len() => {
448 best = Some((opt, dist));
449 }
450 _ => {}
451 }
452 }
453 if candidate.starts_with(&folded)
454 || folded.starts_with(&candidate)
455 || candidate.ends_with(&folded)
456 || folded.ends_with(&candidate)
457 {
458 prefix.push(opt);
459 }
460 }
461 if let Some((opt, _)) = best {
462 return Some(opt);
463 }
464 match prefix.as_slice() {
465 [only] => Some(*only),
466 _ => None,
467 }
468}
469
470fn edit_distance(a: &str, b: &str) -> usize {
471 let a: Vec<char> = a.chars().collect();
472 let b: Vec<char> = b.chars().collect();
473 let mut prev: Vec<usize> = (0..=b.len()).collect();
474 let mut cur = vec![0; b.len() + 1];
475 for (i, ca) in a.iter().enumerate() {
476 cur[0] = i + 1;
477 for (j, cb) in b.iter().enumerate() {
478 let cost = usize::from(ca != cb);
479 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
480 }
481 std::mem::swap(&mut prev, &mut cur);
482 }
483 prev[b.len()]
484}
485
486fn unknown_kebab(field: &str, got: &str, legal: &[&str]) -> String {
487 let listed = legal.join("|");
488 let mut msg = format!("unknown {field} `{got}` ({listed})");
489 if let Some(suggest) = suggest_kebab(got, legal) {
490 msg.push_str(&format!("; did you mean `{suggest}`"));
491 }
492 msg
493}
494
495fn deserialize_kebab<'de, T, D>(
496 deserializer: D,
497 parse: fn(&str) -> Result<T, String>,
498) -> Result<T, D::Error>
499where
500 D: Deserializer<'de>,
501{
502 let s = String::deserialize(deserializer)?;
503 parse(&s).map_err(D::Error::custom)
504}
505
506impl<'de> Deserialize<'de> for AuthScheme {
507 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
508 let s = String::deserialize(deserializer)?;
509 Self::parse(&s).map_err(D::Error::custom)
510 }
511}
512
513impl Serialize for AuthScheme {
514 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
515 let owned: String;
516 let s = match self {
517 Self::Bearer => "bearer",
518 Self::XApiKey => "x-api-key",
519 Self::Header(name) => {
520 owned = format!("header:{name}");
521 owned.as_str()
522 }
523 Self::None => "none",
524 };
525 serializer.serialize_str(s)
526 }
527}
528
529#[derive(Debug, Clone, PartialEq)]
531pub struct OauthPack {
532 pub token_url: String,
534 pub token_url_fallback: Option<String>,
536 pub authorize_url: Option<String>,
538 pub authorize_params: BTreeMap<String, String>,
540 pub device_auth_url: Option<String>,
542 pub client_id: Option<String>,
544 pub redirect_uri: Option<String>,
546 pub scopes: Vec<String>,
548 pub list_merge: ListMerge,
550 pub pkce: Option<bool>,
552 pub refresh_grant: Option<String>,
554 pub refresh_body: BTreeMap<String, String>,
556 pub token_request_format: Option<TokenRequestFormat>,
558 pub token_headers: BTreeMap<String, String>,
560 pub creds_path: Option<String>,
562 pub creds_format: Option<CredsFormat>,
564 pub access_token_ptr: Option<String>,
566 pub refresh_token_ptr: Option<String>,
568 pub expires_ptr: Option<String>,
570 pub expires_unit: Option<ExpiresUnit>,
572 pub access_env: Option<String>,
574 pub login: Option<Login>,
576 pub setup_token_hint: Option<String>,
578 pub keychain_service: Option<String>,
580 pub keychain_accounts: Vec<String>,
582 pub token_response: Option<TokenResponse>,
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct TokenResponse {
589 pub access_token_ptr: Option<String>,
591 pub refresh_token_ptr: Option<String>,
593 pub expires_ptr: Option<String>,
595 pub expires_unit: Option<ExpiresUnit>,
597}
598
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
601#[serde(rename_all = "kebab-case")]
602pub enum TokenRequestFormat {
603 Json,
605 Form,
607}
608
609impl TokenRequestFormat {
610 pub(crate) fn parse(s: &str) -> Result<Self, String> {
611 match s {
612 "json" => Ok(Self::Json),
613 "form" => Ok(Self::Form),
614 other => Err(unknown_kebab(
615 "token_request_format",
616 other,
617 &["json", "form"],
618 )),
619 }
620 }
621}
622
623impl<'de> Deserialize<'de> for TokenRequestFormat {
624 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
625 deserialize_kebab(deserializer, Self::parse)
626 }
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
631#[serde(rename_all = "kebab-case")]
632pub enum CredsFormat {
633 JsonPointer,
635 ClaudeCredentials,
637 OidcAuthJson,
646 CopilotHosts,
652}
653
654impl CredsFormat {
655 pub(crate) fn parse(s: &str) -> Result<Self, String> {
656 match s {
657 "json-pointer" => Ok(Self::JsonPointer),
658 "claude-credentials" => Ok(Self::ClaudeCredentials),
659 "oidc-auth-json" => Ok(Self::OidcAuthJson),
660 "copilot-hosts" => Ok(Self::CopilotHosts),
661 other => Err(unknown_kebab(
662 "creds_format",
663 other,
664 &[
665 "json-pointer",
666 "claude-credentials",
667 "oidc-auth-json",
668 "copilot-hosts",
669 ],
670 )),
671 }
672 }
673}
674
675impl<'de> Deserialize<'de> for CredsFormat {
676 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
677 deserialize_kebab(deserializer, Self::parse)
678 }
679}
680
681#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
683#[serde(rename_all = "kebab-case")]
684pub enum ExpiresUnit {
685 Ms,
687 S,
689}
690
691impl ExpiresUnit {
692 pub(crate) fn parse(s: &str) -> Result<Self, String> {
693 match s {
694 "ms" => Ok(Self::Ms),
695 "s" => Ok(Self::S),
696 other => Err(unknown_kebab("expires_unit", other, &["ms", "s"])),
697 }
698 }
699}
700
701impl<'de> Deserialize<'de> for ExpiresUnit {
702 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
703 deserialize_kebab(deserializer, Self::parse)
704 }
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
709#[serde(rename_all = "kebab-case")]
710pub enum Login {
711 SetupToken,
713 Pkce,
715 Device,
717 None,
719}
720
721impl Login {
722 pub(crate) fn parse(s: &str) -> Result<Self, String> {
723 match s {
724 "setup-token" => Ok(Self::SetupToken),
725 "pkce" => Ok(Self::Pkce),
726 "device" => Ok(Self::Device),
727 "none" => Ok(Self::None),
728 other => Err(unknown_kebab(
729 "login",
730 other,
731 &["setup-token", "pkce", "device", "none"],
732 )),
733 }
734 }
735}
736
737impl<'de> Deserialize<'de> for Login {
738 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
739 deserialize_kebab(deserializer, Self::parse)
740 }
741}
742
743#[derive(Debug, Clone, PartialEq)]
745pub struct Fingerprint {
746 pub user_agent: Option<String>,
748 pub x_app: Option<String>,
750 pub system_prompt_prefix: Option<String>,
752 pub tool_name_case: Option<ToolNameCase>,
754 pub forbidden_body_fields: Vec<String>,
756 pub forbidden_field_policy: ForbiddenFieldPolicy,
758 pub extra_body: BTreeMap<String, serde_json::Value>,
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
764#[serde(rename_all = "kebab-case")]
765pub enum ToolNameCase {
766 AsIs,
768 Snake,
770 Kebab,
772}
773
774impl ToolNameCase {
775 pub(crate) fn parse(s: &str) -> Result<Self, String> {
776 match s {
777 "as-is" => Ok(Self::AsIs),
778 "snake" => Ok(Self::Snake),
779 "kebab" => Ok(Self::Kebab),
780 other => Err(unknown_kebab(
781 "tool_name_case",
782 other,
783 &["as-is", "snake", "kebab"],
784 )),
785 }
786 }
787}
788
789impl<'de> Deserialize<'de> for ToolNameCase {
790 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
791 deserialize_kebab(deserializer, Self::parse)
792 }
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
797#[serde(rename_all = "kebab-case")]
798pub enum ForbiddenFieldPolicy {
799 Strip,
801 #[default]
803 HardError,
804}
805
806impl ForbiddenFieldPolicy {
807 pub(crate) fn parse(s: &str) -> Result<Self, String> {
808 match s {
809 "strip" => Ok(Self::Strip),
810 "hard-error" => Ok(Self::HardError),
811 other => Err(unknown_kebab(
812 "forbidden_field_policy",
813 other,
814 &["strip", "hard-error"],
815 )),
816 }
817 }
818}
819
820impl<'de> Deserialize<'de> for ForbiddenFieldPolicy {
821 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
822 deserialize_kebab(deserializer, Self::parse)
823 }
824}
825
826#[derive(Debug, Clone, PartialEq, Eq)]
828pub struct Betas {
829 pub values: Vec<String>,
831 pub header: String,
833 pub merge: ListMerge,
835}
836
837impl Betas {
838 pub(crate) fn default_for(wire: Option<Wire>, has_oauth: bool) -> Self {
839 let merge = if matches!(wire, Some(Wire::Messages)) && has_oauth {
840 ListMerge::Replace
841 } else {
842 ListMerge::Union
843 };
844 Self {
845 values: Vec::new(),
846 header: "anthropic-beta".into(),
847 merge,
848 }
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855
856 fn unknown_text(input: &str) -> String {
857 AuthScheme::parse(input)
858 .expect_err("must reject")
859 .to_string()
860 }
861
862 #[test]
863 #[allow(unreachable_patterns)] fn hosts_should_wildcard_unknown_wire_dialects() {
865 fn host_spelling(wire: Wire) -> &'static str {
866 match wire {
867 Wire::ChatCompletions => "chat-completions",
868 Wire::Messages => "messages",
869 Wire::Responses => "responses",
870 Wire::Gemini => "gemini",
871 Wire::Converse => "converse",
872 _ => "unknown",
873 }
874 }
875 assert_eq!(host_spelling(Wire::ChatCompletions), "chat-completions");
876 assert_eq!(host_spelling(Wire::Messages), "messages");
877 assert_eq!(host_spelling(Wire::Responses), "responses");
878 assert_eq!(host_spelling(Wire::Gemini), "gemini");
879 assert_eq!(host_spelling(Wire::Converse), "converse");
880 assert!(
881 Wire::NAMES.contains(&"converse"),
882 "NAMES must include converse"
883 );
884 }
885
886 #[test]
887 fn as_str_rustdoc_lists_converse() {
888 let src = include_str!("types.rs");
889 let as_str_doc = src
890 .lines()
891 .find(|line| line.contains("Catalog / file spelling"))
892 .expect("as_str rustdoc lists catalog spellings");
893 assert!(
894 as_str_doc.contains("converse"),
895 "as_str rustdoc must list converse with the other spellings, got: {as_str_doc}"
896 );
897 }
898
899 #[test]
900 fn unknown_auth_scheme_lists_legal_values() {
901 let text = unknown_text("oauth");
902 assert!(text.contains("unknown auth_scheme `oauth`"), "{text}");
903 assert!(
904 text.contains("bearer")
905 && text.contains("x-api-key")
906 && text.contains("none")
907 && text.contains("header:<name>"),
908 "must list legal auth_scheme values, got {text}"
909 );
910 }
911
912 #[test]
913 fn unknown_auth_scheme_suggests_close_matches() {
914 let bearer = unknown_text("Bearer");
915 assert!(
916 bearer.contains("bearer")
917 && bearer.contains("x-api-key")
918 && bearer.contains("none")
919 && bearer.contains("header:<name>"),
920 "must list legal values, got {bearer}"
921 );
922 assert!(
923 bearer.contains("bearer"),
924 "Bearer must point at bearer, got {bearer}"
925 );
926 assert!(
927 bearer.to_ascii_lowercase().contains("did you mean") && bearer.contains("`bearer`"),
928 "Bearer should suggest bearer, got {bearer}"
929 );
930
931 for input in ["api-key", "x_api_key", "X-Api-Key"] {
932 let text = unknown_text(input);
933 assert!(
934 text.contains("x-api-key")
935 && text.contains("bearer")
936 && text.contains("none")
937 && text.contains("header:<name>"),
938 "{input} must list legal values, got {text}"
939 );
940 assert!(
941 text.to_ascii_lowercase().contains("did you mean") && text.contains("`x-api-key`"),
942 "{input} should suggest x-api-key, got {text}"
943 );
944 }
945 }
946}