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