Skip to main content

scitadel_core/
config.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5/// Per-source adapter configuration.
6#[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/// EPO OPS adapter configuration (requires consumer key + secret pair).
42#[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/// Configuration for Claude-based scoring.
69#[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/// UI/UX preferences (TUI-only today; extensible for future surfaces).
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct UiConfig {
104    /// When a download lands on a paywalled publisher page, show the live URL
105    /// plus a note that an institutional IP range (e.g. university VPN) may
106    /// grant access. Disable for headless / CI runs.
107    #[serde(default = "default_true")]
108    pub show_institutional_hint: bool,
109    /// Active TUI theme (#137). Accepts: `auto`, `dark`, `light`,
110    /// `dalton-dark`, `dalton-bright`. `auto` = read terminal
111    /// background via COLORFGBG → OSC 11 → fall back to dark.
112    /// Resolution order: CLI flag > `SCITADEL_THEME` env > this
113    /// config key > `auto`.
114    #[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/// Top-level application configuration.
132#[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    /// Directory for downloaded paper files, relative to the database location.
168    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
195/// Find the workspace root by looking for a git repo from cwd upward.
196fn 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
209/// Resolve the default database path.
210///
211/// Priority: `SCITADEL_DB` env var > workspace `.scitadel/scitadel.db` > cwd.
212fn 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
228/// Load configuration from keychain, environment variables, and optional TOML file.
229///
230/// Resolution priority per credential: keychain → env var → config.toml → empty default.
231pub 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    // Try loading TOML config file as base
238    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    // Resolve credentials: keychain → env → config.toml value
247    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    // Chat config from env (no keychain needed)
290    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
307/// Load config from a specific TOML file path.
308pub 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}