Skip to main content

crossref_lib/
config.rs

1use std::path::PathBuf;
2
3use colored::Colorize;
4use serde::{Deserialize, Serialize};
5
6use crate::error::{CrossrefError, Result};
7
8/// Runtime configuration resolved from (in priority order):
9/// 1. CLI flags / function arguments
10/// 2. Environment variables (`CROSSREF_EMAIL`, `CROSSREF_PROXY`, …)
11/// 3. Config file (`~/.config/crossref.toml` or `--config` path)
12/// 4. Built-in defaults
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Config {
15    /// Email for Crossref polite-pool access (required for well-behaved usage).
16    pub email: Option<String>,
17    /// EZproxy host, e.g. `doi-org.ezproxy.cityu.edu.hk`.
18    pub proxy: Option<String>,
19    /// Default number of rows returned by `crossref search`.
20    pub default_rows: u32,
21    /// Cache time-to-live in days.
22    pub cache_ttl_days: u32,
23    /// Override the default XDG cache directory.
24    pub cache_dir: Option<String>,
25    /// Default output format (table, json, yaml, bibtex, fzf).
26    /// Overridden by CLI `--format` flag.
27    pub default_format: Option<String>,
28    /// Fuzzy finder program for interactive selection (default: fzf).
29    pub fuzzy_finder_cmd: Option<String>,
30}
31
32impl Default for Config {
33    fn default() -> Self {
34        Self {
35            email: None,
36            // 默认代理为香港城市大学 EZproxy
37            proxy: Some("doi-org.ezproxy.cityu.edu.hk".to_string()),
38            default_rows: 10,
39            cache_ttl_days: 30,
40            cache_dir: None,
41            default_format: None,
42            fuzzy_finder_cmd: None,
43        }
44    }
45}
46
47impl Config {
48    /// Load configuration by merging (CLI override > env vars > config file > defaults).
49    /// `email_override` and `config_path` come from CLI flags.
50    pub fn load(email_override: Option<&str>, config_path: Option<&str>) -> Result<Self> {
51        let path = resolve_config_path(config_path)?;
52
53        let mut cfg: Config = if path.exists() {
54            confy::load_path(&path).map_err(|e| CrossrefError::Config(e.to_string()))?
55        } else {
56            Config::default()
57        };
58
59        // Environment variables override file values
60        if let Ok(email) = std::env::var("CROSSREF_EMAIL") {
61            if !email.is_empty() {
62                cfg.email = Some(email);
63            }
64        }
65        if let Ok(proxy) = std::env::var("CROSSREF_PROXY") {
66            if !proxy.is_empty() {
67                cfg.proxy = Some(proxy);
68            }
69        }
70        if let Ok(rows) = std::env::var("CROSSREF_ROWS") {
71            if let Ok(n) = rows.parse::<u32>() {
72                cfg.default_rows = n;
73            }
74        }
75        if let Ok(ttl) = std::env::var("CROSSREF_CACHE_TTL_DAYS") {
76            if let Ok(n) = ttl.parse::<u32>() {
77                cfg.cache_ttl_days = n;
78            }
79        }
80        if let Ok(fmt) = std::env::var("CROSSREF_DEFAULT_FORMAT") {
81            if !fmt.is_empty() {
82                cfg.default_format = Some(fmt);
83            }
84        }
85        if let Ok(cmd) = std::env::var("CROSSREF_FUZZY_FINDER") {
86            if !cmd.is_empty() {
87                cfg.fuzzy_finder_cmd = Some(cmd);
88            }
89        }
90
91        // CLI flag is highest priority
92        if let Some(email) = email_override {
93            cfg.email = Some(email.to_string());
94        }
95
96        Ok(cfg)
97    }
98
99    /// Like [`Config::load`] but also handles first-run guidance for the CLI binary.
100    /// Returns `None` when guidance was printed and the caller should exit.
101    pub fn load_with_guidance(
102        email_override: Option<&str>,
103        config_path: Option<&str>,
104    ) -> Result<Option<Self>> {
105        let path = resolve_config_path(config_path)?;
106
107        // Check whether a usable email is available before loading the full config
108        let email_from_env = std::env::var("CROSSREF_EMAIL")
109            .ok()
110            .filter(|s| !s.is_empty());
111        let has_email = email_override.is_some() || email_from_env.is_some();
112
113        if !has_email && !path.exists() {
114            create_default_config(&path)?;
115            print_first_run_guidance(&path);
116            return Ok(None);
117        }
118
119        let cfg = Self::load(email_override, config_path)?;
120
121        if cfg.email.as_deref().map(|e| e.is_empty()).unwrap_or(true)
122            && !has_email
123        {
124            print_first_run_guidance(&path);
125            return Ok(None);
126        }
127
128        Ok(Some(cfg))
129    }
130
131    /// Returns `true` when a polite email address is configured.
132    pub fn has_email(&self) -> bool {
133        self.email.as_deref().map(|e| !e.is_empty()).unwrap_or(false)
134    }
135
136    /// Returns the configured fuzzy finder command, defaulting to `"fzf"`.
137    pub fn fuzzy_finder(&self) -> &str {
138        self.fuzzy_finder_cmd.as_deref().unwrap_or("fzf")
139    }
140}
141
142/// Returns the resolved path to the config file.
143pub fn resolve_config_path(override_path: Option<&str>) -> Result<PathBuf> {
144    if let Some(p) = override_path {
145        return Ok(PathBuf::from(p));
146    }
147    let dir = dirs::config_dir()
148        .ok_or_else(|| CrossrefError::Config("cannot determine config directory".to_string()))?;
149    Ok(dir.join("crossref.toml"))
150}
151
152/// Write a default config file with bilingual comments to `path`.
153pub fn create_default_config(path: &PathBuf) -> Result<()> {
154    if let Some(parent) = path.parent() {
155        std::fs::create_dir_all(parent)?;
156    }
157
158    let now = chrono::Local::now().format("%Y-%m-%d").to_string();
159    let content = format!(
160        r#"# crossref-rs Default Configuration File
161# Auto-generated on {now}
162# 自动生成于 {now}
163
164# [REQUIRED] Email for Crossref API polite-pool access.
165# 请填写您的真实邮箱地址,以避免被 Crossref 限速。
166# Replace with your real email to avoid rate limiting.
167email = "your.name@example.com"
168
169# EZproxy host (commonly used by users in Hong Kong / CityU).
170# 香港城市大学 EZproxy 地址(如不需要可留空或注释掉)。
171proxy = "doi-org.ezproxy.cityu.edu.hk"
172
173# Default number of search results returned per query.
174# 搜索时默认返回的结果数量。
175default_rows = 10
176
177# Cache expiration in days (0 to disable caching).
178# 缓存过期天数(设置为 0 可禁用缓存)。
179cache_ttl_days = 30
180
181# Optional: custom cache directory path.
182# 可选:自定义缓存目录路径。
183# cache_dir = "/path/to/cache"
184
185# Default output format (table, json, yaml, bibtex, fzf).
186# 默认输出格式。设置为 "fzf" 可输出适用于模糊查找的纯文本行。
187# default_format = "table"
188
189# Fuzzy finder program for interactive selection (default: fzf).
190# 模糊查找程序,默认使用 fzf,也可设置为 skim 等其他程序。
191# fuzzy_finder_cmd = "fzf"
192"#
193    );
194
195    std::fs::write(path, content)?;
196    Ok(())
197}
198
199/// Print the first-run guidance box to stderr.
200pub fn print_first_run_guidance(path: &std::path::Path) {
201    let path_str = path.display().to_string();
202    let width = 64usize;
203    let inner = width - 2;
204
205    let top    = format!("╔{}╗", "═".repeat(width));
206    let sep    = format!("╟{}╢", "─".repeat(width));
207    let bot    = format!("╚{}╝", "═".repeat(width));
208
209    let title  = center_pad("crossref-rs First-Run Setup", inner);
210    let blank  = pad("", inner);
211
212    let line1  = pad("A default configuration file has been created for you at:", inner);
213    let line2  = pad(&format!("  {path_str}"), inner);
214    let line3  = pad("Please open it now and set your email address:", inner);
215    let line4  = pad(r#"  email = "your.real.email@example.com""#, inner);
216    let line5  = pad("Alternatively, set via environment variable (quick setup):", inner);
217    let line6  = pad("  • Bash / Zsh   : export CROSSREF_EMAIL=you@example.com", inner);
218    let line7  = pad("  • Fish         : set -gx CROSSREF_EMAIL you@example.com", inner);
219    let line8  = pad(r#"  • Nushell      : $env.CROSSREF_EMAIL = "you@example.com""#, inner);
220    let line9  = pad(r#"  • PowerShell   : $env:CROSSREF_EMAIL = "you@example.com""#, inner);
221    let line10 = pad("After editing, re-run your command.", inner);
222
223    let box_str = [
224        top.bright_cyan().to_string(),
225        row(&title),
226        row(&sep),
227        row(&blank),
228        row(&line1),
229        row(&line2),
230        row(&blank),
231        row(&line3),
232        row(&line4),
233        row(&blank),
234        row(&line5),
235        row(&line6),
236        row(&line7),
237        row(&line8),
238        row(&line9),
239        row(&blank),
240        row(&line10),
241        row(&blank),
242        bot.bright_cyan().to_string(),
243    ]
244    .join("\n");
245
246    eprintln!("{box_str}");
247}
248
249fn pad(s: &str, width: usize) -> String {
250    format!("{s:<width$}")
251}
252
253fn center_pad(s: &str, width: usize) -> String {
254    let len = s.len();
255    if len >= width {
256        return s.to_string();
257    }
258    let total_pad = width - len;
259    let left = total_pad / 2;
260    let right = total_pad - left;
261    format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
262}
263
264fn row(content: &str) -> String {
265    format!("║{content}║")
266}