Skip to main content

mur_common/
research_provider.rs

1//! The search providers `mur deep-research` can be given a key for.
2//!
3//! Lives in mur-common because BOTH ends need the same names and neither can
4//! see the other: `mur-core` owns the `mur deep-research secret` command that
5//! WRITES a key, and `mur-research-gateway` is the standalone binary that
6//! READS it. mur-core does not depend on the gateway crate (and must not —
7//! the gateway is deliberately dependency-light), so a provider list defined
8//! in either one would have to be duplicated in the other, and a config key
9//! spelled `serpapi_api_key_ref` on the write side and `serp_api_key_ref` on
10//! the read side would store a secret that is never found. One enum, both
11//! ends (CLAUDE.md rule 1).
12
13/// A web-search backend the gateway's `search` tool can use instead of
14/// scraping DuckDuckGo's keyless HTML endpoint.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub enum SearchProvider {
17    /// Brave Search API — the default and the only one that predates this
18    /// enum. Free tier covers a personal deep-research user.
19    Brave,
20    /// Tavily — search API built for LLM agents.
21    Tavily,
22    /// SerpApi — Google results via a scraping API.
23    SerpApi,
24    /// Firecrawl — search + page extraction.
25    Firecrawl,
26}
27
28/// The order the gateway tries configured providers in when the operator has
29/// not named one explicitly. Brave is first because it is the documented
30/// default and the only provider any existing install can already have a key
31/// for — adding three more must never silently move an existing user off the
32/// backend they configured.
33pub const PROVIDER_PREFERENCE: [SearchProvider; 4] = [
34    SearchProvider::Brave,
35    SearchProvider::Tavily,
36    SearchProvider::SerpApi,
37    SearchProvider::Firecrawl,
38];
39
40/// Keychain service name every MUR credential is filed under. Matches
41/// `mur-core`'s `sources::credentials::SERVICE`, and is the `mur` in the
42/// documented `keychain:mur/brave` ref.
43pub const KEYCHAIN_SERVICE: &str = "mur";
44
45impl SearchProvider {
46    /// Every provider, in preference order.
47    pub fn all() -> [SearchProvider; 4] {
48        PROVIDER_PREFERENCE
49    }
50
51    /// Lowercase identifier used in the CLI flag (`--brave`), the keychain
52    /// account, and the config key prefix. One slug drives all three so they
53    /// cannot drift apart.
54    pub fn slug(self) -> &'static str {
55        match self {
56            SearchProvider::Brave => "brave",
57            SearchProvider::Tavily => "tavily",
58            SearchProvider::SerpApi => "serpapi",
59            SearchProvider::Firecrawl => "firecrawl",
60        }
61    }
62
63    /// How the provider writes its own name, for anything a human reads.
64    pub fn display_name(self) -> &'static str {
65        match self {
66            SearchProvider::Brave => "Brave Search",
67            SearchProvider::Tavily => "Tavily",
68            SearchProvider::SerpApi => "SerpApi",
69            SearchProvider::Firecrawl => "Firecrawl",
70        }
71    }
72
73    /// `research_gateway.<this>` — the config.yaml key holding a `SecretRef`
74    /// string rather than the secret itself.
75    pub fn config_key_ref(self) -> String {
76        format!("{}_api_key_ref", self.slug())
77    }
78
79    /// `research_gateway.<this>` — the legacy plaintext key. Still read (an
80    /// existing `brave_api_key` must keep working) but never written by
81    /// `mur deep-research secret`.
82    pub fn config_key_plain(self) -> String {
83        format!("{}_api_key", self.slug())
84    }
85
86    /// Environment override, highest precedence of all.
87    pub fn env_var(self) -> String {
88        format!("MUR_RESEARCH_{}_KEY", self.slug().to_ascii_uppercase())
89    }
90
91    /// Keychain account this provider's key is stored under.
92    pub fn keychain_account(self) -> &'static str {
93        self.slug()
94    }
95
96    /// The `SecretRef` string written into config.yaml — the reference is safe
97    /// to commit and log; the key itself never enters the file.
98    pub fn keychain_ref(self) -> String {
99        format!("keychain:{KEYCHAIN_SERVICE}/{}", self.keychain_account())
100    }
101
102    /// Where a user gets a key. Printed by the setup command, because "get an
103    /// API key" without a URL is a scavenger hunt.
104    pub fn signup_url(self) -> &'static str {
105        match self {
106            SearchProvider::Brave => "https://brave.com/search/api/",
107            SearchProvider::Tavily => "https://app.tavily.com/",
108            SearchProvider::SerpApi => "https://serpapi.com/manage-api-key",
109            SearchProvider::Firecrawl => "https://www.firecrawl.dev/app/api-keys",
110        }
111    }
112}
113
114impl std::fmt::Display for SearchProvider {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str(self.slug())
117    }
118}
119
120impl std::str::FromStr for SearchProvider {
121    type Err = String;
122
123    /// Accepts the slug in any case, plus the spellings a user is likely to
124    /// type by hand (`serp-api`, `serp_api`) — a rejected name here costs a
125    /// round trip for no safety gain.
126    fn from_str(s: &str) -> Result<Self, Self::Err> {
127        let normalized = s.trim().to_ascii_lowercase().replace(['-', '_'], "");
128        match normalized.as_str() {
129            "brave" | "bravesearch" => Ok(SearchProvider::Brave),
130            "tavily" => Ok(SearchProvider::Tavily),
131            "serpapi" | "serp" => Ok(SearchProvider::SerpApi),
132            "firecrawl" => Ok(SearchProvider::Firecrawl),
133            _ => Err(format!(
134                "unknown search provider '{s}' — expected one of: {}",
135                SearchProvider::all()
136                    .iter()
137                    .map(|p| p.slug())
138                    .collect::<Vec<_>>()
139                    .join(", ")
140            )),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::str::FromStr;
149
150    /// The whole reason this enum is in mur-common: the writer (mur-core) and
151    /// the reader (the gateway) must derive the SAME strings. Pin them, so a
152    /// rename on one side fails here instead of silently storing a key the
153    /// gateway never looks for.
154    #[test]
155    fn config_keys_and_refs_are_derived_from_one_slug() {
156        assert_eq!(SearchProvider::Brave.config_key_ref(), "brave_api_key_ref");
157        assert_eq!(SearchProvider::Brave.config_key_plain(), "brave_api_key");
158        assert_eq!(SearchProvider::Brave.env_var(), "MUR_RESEARCH_BRAVE_KEY");
159        assert_eq!(SearchProvider::Brave.keychain_ref(), "keychain:mur/brave");
160
161        assert_eq!(
162            SearchProvider::SerpApi.config_key_ref(),
163            "serpapi_api_key_ref"
164        );
165        assert_eq!(
166            SearchProvider::Firecrawl.env_var(),
167            "MUR_RESEARCH_FIRECRAWL_KEY"
168        );
169    }
170
171    /// `brave_api_key_ref` / `MUR_RESEARCH_BRAVE_KEY` / `keychain:mur/brave`
172    /// already exist in shipped configs and in the gateway's operator advice.
173    /// Deriving them from the enum must reproduce them EXACTLY, or this change
174    /// silently orphans every existing Brave key.
175    #[test]
176    fn brave_spellings_match_what_already_ships() {
177        let cfg = SearchProvider::Brave;
178        // As printed by fetcher.rs's search_blocked_error operator advice.
179        assert_eq!(cfg.config_key_ref(), "brave_api_key_ref");
180        assert_eq!(cfg.keychain_ref(), "keychain:mur/brave");
181        // As read by config.rs's ENV_BRAVE_KEY.
182        assert_eq!(cfg.env_var(), "MUR_RESEARCH_BRAVE_KEY");
183    }
184
185    #[test]
186    fn parses_case_and_punctuation_variants() {
187        assert_eq!(
188            SearchProvider::from_str("Brave").unwrap(),
189            SearchProvider::Brave
190        );
191        assert_eq!(
192            SearchProvider::from_str("  TAVILY  ").unwrap(),
193            SearchProvider::Tavily
194        );
195        // A user typing the product name by hand gets all three spellings.
196        for s in ["serpapi", "SerpApi", "serp-api", "serp_api"] {
197            assert_eq!(
198                SearchProvider::from_str(s).unwrap(),
199                SearchProvider::SerpApi,
200                "failed to parse {s}"
201            );
202        }
203    }
204
205    #[test]
206    fn unknown_provider_names_the_valid_choices() {
207        let err = SearchProvider::from_str("google").unwrap_err();
208        assert!(err.contains("google"), "{err}");
209        // The message must list what IS accepted, not just reject.
210        for p in SearchProvider::all() {
211            assert!(err.contains(p.slug()), "{err} is missing {p}");
212        }
213    }
214
215    /// Brave must stay first: it is the only provider an existing install can
216    /// already hold a key for, and auto-selection walks this order.
217    #[test]
218    fn brave_leads_the_preference_order() {
219        assert_eq!(PROVIDER_PREFERENCE[0], SearchProvider::Brave);
220        assert_eq!(SearchProvider::all().len(), 4);
221    }
222
223    #[test]
224    fn slugs_are_unique() {
225        let mut slugs: Vec<_> = SearchProvider::all().iter().map(|p| p.slug()).collect();
226        slugs.sort_unstable();
227        slugs.dedup();
228        assert_eq!(slugs.len(), 4, "two providers share a slug");
229    }
230}