biblion/
config.rs

1//! Configuration for the Zotero MCP server.
2//!
3//! # Design
4//!
5//! The server needs to know where three things live:
6//!
7//! 1. **`zotero.sqlite`** — Zotero's main database (71MB, ~7000 refs).
8//!    This is where all item metadata, collections, tags, creators, and
9//!    attachments are stored. We open it read-only.
10//!
11//! 2. **`better-bibtex.migrated`** — BBT's citekey mapping database.
12//!    Contains a `citationkey` table mapping `(itemID, itemKey) → citekey`.
13//!    This lets us resolve "demilloHintsTestData1978" to Zotero item key
14//!    "9MS26VH5" without calling the BBT JSON-RPC API.
15//!
16//! 3. **Zotero storage** — directory where PDFs live, organized as
17//!    `storage/{2-char}/{8-char}/filename.pdf`.
18//!
19//! For write operations, we also need the Zotero Web API key and the
20//! BBT JSON-RPC URL (for BibTeX export only).
21//!
22//! # Environment variables
23//!
24//! Same variable names as the Python server for drop-in replacement:
25//!
26//! | Variable | Default |
27//! |----------|---------|
28//! | `ZOTERO_SQLITE_PATH` | `~/Zotero/zotero.sqlite` |
29//! | `ZOTERO_STORAGE_PATH` | `~/Zotero/storage` |
30//! | `BBT_MIGRATED_PATH` | `~/Zotero/better-bibtex.migrated` |
31//! | `ZOTERO_API_KEY` | (none — writes disabled) |
32//! | `ZOTERO_LIBRARY_ID` | (none — required for writes) |
33//! | `ZOTERO_LIBRARY_TYPE` | `user` |
34//! | `BBT_URL` | `http://localhost:23119/better-bibtex/json-rpc` |
35//! | `ZOTERO_MCP_LOG` | `info` |
36
37use std::path::PathBuf;
38
39/// Server configuration, loaded from environment variables.
40///
41/// All paths have sensible defaults for a standard macOS Zotero installation.
42/// The API key is optional — without it, write tools return a clear error.
43#[derive(Clone)]
44pub struct Config {
45    /// Path to Zotero's main SQLite database.
46    pub zotero_sqlite_path: PathBuf,
47    /// Path to the Zotero storage directory (where PDFs live).
48    pub zotero_storage_path: PathBuf,
49    /// Path to BBT's migrated citekey database.
50    pub bbt_migrated_path: PathBuf,
51    /// Zotero Web API key (required for write operations).
52    pub zotero_api_key: Option<String>,
53    /// Zotero library ID (default: personal library).
54    pub zotero_library_id: String,
55    /// Zotero library type ("user" or "group").
56    pub zotero_library_type: String,
57    /// BBT JSON-RPC URL (only needed for BibTeX/bibliography export).
58    pub bbt_url: String,
59    /// Log level for stderr diagnostics.
60    pub log_level: LogLevel,
61    /// Whether write tools are enabled (default: false for safety).
62    /// Set ZOTERO_MCP_ENABLE_WRITES=true to enable.
63    pub writes_enabled: bool,
64    /// Paper resolver configuration (sources, timeouts, etc.).
65    /// Loaded from TOML config file if present, otherwise defaults.
66    pub resolver: paper_resolver::ResolverConfig,
67    /// Override Zotero API base URL (for testing). None = use default.
68    pub zotero_api_base_url: Option<String>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum LogLevel {
73    Quiet,
74    Info,
75    Debug,
76}
77
78impl Config {
79    /// Load configuration from environment variables.
80    ///
81    /// Reads `.env` file if present (via dotenvy), then environment variables.
82    /// All variables have sensible defaults except `ZOTERO_API_KEY`.
83    pub fn from_env() -> Self {
84        // Load .env file if present (ignore errors — it's optional)
85        let _ = dotenvy::dotenv();
86
87        let home = std::env::var("HOME").expect("HOME environment variable must be set");
88
89        Self {
90            zotero_sqlite_path: env_path(
91                "ZOTERO_SQLITE_PATH",
92                &format!("{home}/Zotero/zotero.sqlite"),
93            ),
94            zotero_storage_path: env_path("ZOTERO_STORAGE_PATH", &format!("{home}/Zotero/storage")),
95            bbt_migrated_path: env_path(
96                "BBT_MIGRATED_PATH",
97                &format!("{home}/Zotero/better-bibtex.migrated"),
98            ),
99            zotero_api_key: std::env::var("ZOTERO_API_KEY")
100                .ok()
101                .filter(|s| !s.is_empty()),
102            zotero_library_id: std::env::var("ZOTERO_LIBRARY_ID").unwrap_or_default(), // Must be set for write operations
103            zotero_library_type: std::env::var("ZOTERO_LIBRARY_TYPE")
104                .unwrap_or_else(|_| "user".into()),
105            bbt_url: std::env::var("BBT_URL")
106                .unwrap_or_else(|_| "http://localhost:23119/better-bibtex/json-rpc".into()),
107            log_level: match std::env::var("ZOTERO_MCP_LOG").unwrap_or_default().as_str() {
108                "debug" => LogLevel::Debug,
109                "quiet" | "silent" => LogLevel::Quiet,
110                _ => LogLevel::Info,
111            },
112            writes_enabled: std::env::var("ZOTERO_MCP_ENABLE_WRITES")
113                .map(|v| v == "true" || v == "1")
114                .unwrap_or(false),
115            resolver: load_resolver_config(),
116            zotero_api_base_url: std::env::var("ZOTERO_API_BASE_URL")
117                .ok()
118                .filter(|s| !s.is_empty()),
119        }
120    }
121
122    /// Whether write operations are available (API key is configured).
123    pub fn has_write_access(&self) -> bool {
124        self.zotero_api_key.is_some()
125    }
126}
127
128fn env_path(var: &str, default: &str) -> PathBuf {
129    std::env::var(var)
130        .map(PathBuf::from)
131        .unwrap_or_else(|_| PathBuf::from(default))
132}
133
134/// Load resolver config from TOML file if present, otherwise defaults.
135///
136/// Looks for config at:
137/// 1. `$ZOTERO_MCP_CONFIG` (if set)
138/// 2. `~/.config/biblion/config.toml`
139fn load_resolver_config() -> paper_resolver::ResolverConfig {
140    let config_path = std::env::var("ZOTERO_MCP_CONFIG")
141        .map(PathBuf::from)
142        .unwrap_or_else(|_| {
143            let home = std::env::var("HOME").unwrap_or_default();
144            PathBuf::from(format!("{home}/.config/biblion/config.toml"))
145        });
146
147    if !config_path.exists() {
148        return paper_resolver::ResolverConfig::default();
149    }
150
151    let content = match std::fs::read_to_string(&config_path) {
152        Ok(c) => c,
153        Err(e) => {
154            eprintln!(
155                "[biblion] Warning: cannot read config file {}: {e}",
156                config_path.display()
157            );
158            return paper_resolver::ResolverConfig::default();
159        }
160    };
161
162    let table: toml::Table = match content.parse() {
163        Ok(t) => t,
164        Err(e) => {
165            eprintln!(
166                "[biblion] Warning: invalid TOML in {}: {e}",
167                config_path.display()
168            );
169            return paper_resolver::ResolverConfig::default();
170        }
171    };
172
173    let mut config = paper_resolver::ResolverConfig::default();
174
175    if let Some(resolver) = table.get("resolver").and_then(|v| v.as_table()) {
176        if let Some(email) = resolver.get("email").and_then(|v| v.as_str()) {
177            config.email = email.into();
178        }
179        if let Some(ua) = resolver.get("user_agent").and_then(|v| v.as_str()) {
180            config.user_agent = ua.into();
181        }
182        if let Some(timeout) = resolver.get("timeout_secs").and_then(|v| v.as_integer()) {
183            config.timeout_secs = timeout as u64;
184        }
185
186        // Source configuration — order in TOML = priority
187        if let Some(sources) = resolver.get("sources").and_then(|v| v.as_array()) {
188            config.sources = sources
189                .iter()
190                .filter_map(|s| {
191                    let name = s.get("name")?.as_str()?.to_string();
192                    // Validate source name
193                    if !paper_resolver::SOURCE_NAMES.contains(&name.as_str()) {
194                        eprintln!(
195                            "[biblion] Warning: unknown source '{}' in config (valid: {:?})",
196                            name,
197                            paper_resolver::SOURCE_NAMES
198                        );
199                        return None;
200                    }
201                    let enabled = s.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
202                    Some(paper_resolver::SourceEntry::new(name, enabled))
203                })
204                .collect();
205        }
206
207        // Extra blocked domains
208        if let Some(blocked) = resolver.get("blocked_domains").and_then(|v| v.as_table())
209            && let Some(extra) = blocked.get("extra").and_then(|v| v.as_array())
210        {
211            config.extra_blocked_domains = extra
212                .iter()
213                .filter_map(|v| v.as_str().map(String::from))
214                .collect();
215        }
216    }
217
218    config
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn default_config_has_sensible_paths() {
227        // Don't pollute the actual env — just test the structure
228        let config = Config {
229            zotero_sqlite_path: PathBuf::from("/Users/test/Zotero/zotero.sqlite"),
230            zotero_storage_path: PathBuf::from("/Users/test/Zotero/storage"),
231            bbt_migrated_path: PathBuf::from("/Users/test/Zotero/better-bibtex.migrated"),
232            zotero_api_key: None,
233            zotero_library_id: "12345".into(),
234            zotero_library_type: "user".into(),
235            bbt_url: "http://localhost:23119/better-bibtex/json-rpc".into(),
236            log_level: LogLevel::Info,
237            writes_enabled: false,
238            resolver: paper_resolver::ResolverConfig::default(),
239            zotero_api_base_url: None,
240        };
241        assert!(!config.has_write_access());
242        assert!(
243            config
244                .zotero_sqlite_path
245                .to_str()
246                .unwrap()
247                .ends_with("zotero.sqlite")
248        );
249    }
250
251    #[test]
252    fn config_with_api_key_has_write_access() {
253        let config = Config {
254            zotero_sqlite_path: PathBuf::from("/tmp/z.sqlite"),
255            zotero_storage_path: PathBuf::from("/tmp/storage"),
256            bbt_migrated_path: PathBuf::from("/tmp/bbt.migrated"),
257            zotero_api_key: Some("test-key".into()),
258            zotero_library_id: "1".into(),
259            zotero_library_type: "user".into(),
260            bbt_url: "http://localhost:23119/better-bibtex/json-rpc".into(),
261            log_level: LogLevel::Quiet,
262            writes_enabled: false,
263            resolver: paper_resolver::ResolverConfig::default(),
264            zotero_api_base_url: None,
265        };
266        assert!(config.has_write_access());
267    }
268}