1use std::path::PathBuf;
38
39#[derive(Clone)]
44pub struct Config {
45 pub zotero_sqlite_path: PathBuf,
47 pub zotero_storage_path: PathBuf,
49 pub bbt_migrated_path: PathBuf,
51 pub zotero_api_key: Option<String>,
53 pub zotero_library_id: String,
55 pub zotero_library_type: String,
57 pub bbt_url: String,
59 pub log_level: LogLevel,
61 pub writes_enabled: bool,
64 pub resolver: paper_resolver::ResolverConfig,
67 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 pub fn from_env() -> Self {
84 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(), 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 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
134fn 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 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 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 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 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}