1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct SourceConfig {
8 #[serde(default = "default_true")]
9 pub enabled: bool,
10 #[serde(default = "default_timeout")]
11 pub timeout: f64,
12 #[serde(default = "default_max_retries")]
13 pub max_retries: u32,
14 #[serde(default)]
15 pub api_key: String,
16}
17
18impl Default for SourceConfig {
19 fn default() -> Self {
20 Self {
21 enabled: true,
22 timeout: 30.0,
23 max_retries: 3,
24 api_key: String::new(),
25 }
26 }
27}
28
29fn default_true() -> bool {
30 true
31}
32
33fn default_timeout() -> f64 {
34 30.0
35}
36
37fn default_max_retries() -> u32 {
38 3
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct EpoConfig {
44 #[serde(default = "default_true")]
45 pub enabled: bool,
46 #[serde(default = "default_timeout")]
47 pub timeout: f64,
48 #[serde(default = "default_max_retries")]
49 pub max_retries: u32,
50 #[serde(default)]
51 pub consumer_key: String,
52 #[serde(default)]
53 pub consumer_secret: String,
54}
55
56impl Default for EpoConfig {
57 fn default() -> Self {
58 Self {
59 enabled: true,
60 timeout: 30.0,
61 max_retries: 3,
62 consumer_key: String::new(),
63 consumer_secret: String::new(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ChatConfig {
71 #[serde(default = "default_model")]
72 pub model: String,
73 #[serde(default = "default_max_tokens")]
74 pub max_tokens: u32,
75 #[serde(default = "default_scoring_concurrency")]
76 pub scoring_concurrency: u32,
77}
78
79impl Default for ChatConfig {
80 fn default() -> Self {
81 Self {
82 model: "claude-sonnet-4-6".to_string(),
83 max_tokens: 4096,
84 scoring_concurrency: 5,
85 }
86 }
87}
88
89fn default_model() -> String {
90 "claude-sonnet-4-6".to_string()
91}
92
93fn default_max_tokens() -> u32 {
94 4096
95}
96
97fn default_scoring_concurrency() -> u32 {
98 5
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct UiConfig {
104 #[serde(default = "default_true")]
108 pub show_institutional_hint: bool,
109 #[serde(default = "default_theme")]
115 pub theme: String,
116}
117
118fn default_theme() -> String {
119 "auto".into()
120}
121
122impl Default for UiConfig {
123 fn default() -> Self {
124 Self {
125 show_institutional_hint: true,
126 theme: default_theme(),
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Config {
134 pub db_path: PathBuf,
135 #[serde(default = "default_sources")]
136 pub default_sources: Vec<String>,
137 #[serde(default)]
138 pub pubmed: SourceConfig,
139 #[serde(default)]
140 pub arxiv: SourceConfig,
141 #[serde(default)]
142 pub openalex: SourceConfig,
143 #[serde(default)]
144 pub inspire: SourceConfig,
145 #[serde(default)]
146 pub patentsview: SourceConfig,
147 #[serde(default)]
148 pub lens: SourceConfig,
149 #[serde(default)]
150 pub epo: EpoConfig,
151 #[serde(default)]
152 pub chat: ChatConfig,
153 #[serde(default)]
154 pub ui: UiConfig,
155}
156
157fn default_sources() -> Vec<String> {
158 vec![
159 "pubmed".into(),
160 "arxiv".into(),
161 "openalex".into(),
162 "inspire".into(),
163 ]
164}
165
166impl Config {
167 pub fn papers_dir(&self) -> PathBuf {
169 self.db_path
170 .parent()
171 .unwrap_or_else(|| Path::new("."))
172 .join("papers")
173 }
174}
175
176impl Default for Config {
177 fn default() -> Self {
178 let workspace = find_workspace_root().unwrap_or_else(|| std::env::current_dir().unwrap());
179 Self {
180 db_path: default_db_path(&workspace),
181 default_sources: default_sources(),
182 pubmed: SourceConfig::default(),
183 arxiv: SourceConfig::default(),
184 openalex: SourceConfig::default(),
185 inspire: SourceConfig::default(),
186 patentsview: SourceConfig::default(),
187 lens: SourceConfig::default(),
188 epo: EpoConfig::default(),
189 chat: ChatConfig::default(),
190 ui: UiConfig::default(),
191 }
192 }
193}
194
195fn find_workspace_root() -> Option<PathBuf> {
197 let output = std::process::Command::new("git")
198 .args(["rev-parse", "--show-toplevel"])
199 .output()
200 .ok()?;
201 if output.status.success() {
202 let path = String::from_utf8(output.stdout).ok()?;
203 Some(PathBuf::from(path.trim()))
204 } else {
205 None
206 }
207}
208
209fn default_db_path(workspace: &Path) -> PathBuf {
213 if let Ok(db) = std::env::var("SCITADEL_DB") {
214 let expanded = if db.starts_with('~') {
215 if let Ok(home) = std::env::var("HOME") {
216 db.replacen('~', &home, 1)
217 } else {
218 db
219 }
220 } else {
221 db
222 };
223 return PathBuf::from(expanded);
224 }
225 workspace.join(".scitadel").join("scitadel.db")
226}
227
228pub fn load_config() -> Config {
232 use crate::credentials::resolve;
233
234 let workspace = find_workspace_root().unwrap_or_else(|| std::env::current_dir().unwrap());
235 let db_path = default_db_path(&workspace);
236
237 let config_path = workspace.join(".scitadel").join("config.toml");
239 let mut config: Config = std::fs::read_to_string(&config_path)
240 .ok()
241 .and_then(|contents| toml::from_str(&contents).ok())
242 .unwrap_or_default();
243
244 config.db_path = db_path;
245
246 config.pubmed.api_key = resolve(
248 "pubmed.api_key",
249 "SCITADEL_PUBMED_API_KEY",
250 &config.pubmed.api_key,
251 )
252 .unwrap_or_default();
253
254 config.openalex.api_key = resolve(
255 "openalex.email",
256 "SCITADEL_OPENALEX_EMAIL",
257 &config.openalex.api_key,
258 )
259 .unwrap_or_default();
260
261 config.patentsview.api_key = resolve(
262 "patentsview.api_key",
263 "SCITADEL_PATENTSVIEW_KEY",
264 &config.patentsview.api_key,
265 )
266 .unwrap_or_default();
267
268 config.lens.api_key = resolve(
269 "lens.api_token",
270 "SCITADEL_LENS_TOKEN",
271 &config.lens.api_key,
272 )
273 .unwrap_or_default();
274
275 config.epo.consumer_key = resolve(
276 "epo.consumer_key",
277 "SCITADEL_EPO_KEY",
278 &config.epo.consumer_key,
279 )
280 .unwrap_or_default();
281
282 config.epo.consumer_secret = resolve(
283 "epo.consumer_secret",
284 "SCITADEL_EPO_SECRET",
285 &config.epo.consumer_secret,
286 )
287 .unwrap_or_default();
288
289 if let Ok(model) = std::env::var("SCITADEL_CHAT_MODEL") {
291 config.chat.model = model;
292 }
293 if let Ok(tokens) = std::env::var("SCITADEL_CHAT_MAX_TOKENS")
294 && let Ok(v) = tokens.parse()
295 {
296 config.chat.max_tokens = v;
297 }
298 if let Ok(conc) = std::env::var("SCITADEL_SCORING_CONCURRENCY")
299 && let Ok(v) = conc.parse()
300 {
301 config.chat.scoring_concurrency = v;
302 }
303
304 config
305}
306
307pub fn load_config_from(path: &Path) -> Result<Config, crate::error::CoreError> {
309 let contents = std::fs::read_to_string(path)?;
310 toml::from_str(&contents).map_err(|e| crate::error::CoreError::Config(e.to_string()))
311}