1use std::path::PathBuf;
2
3use colored::Colorize;
4use serde::{Deserialize, Serialize};
5
6use crate::error::{CrossrefError, Result};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Config {
15 pub email: Option<String>,
17 pub proxy: Option<String>,
19 pub default_rows: u32,
21 pub cache_ttl_days: u32,
23 pub cache_dir: Option<String>,
25}
26
27impl Default for Config {
28 fn default() -> Self {
29 Self {
30 email: None,
31 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 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 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 if let Some(email) = email_override {
76 cfg.email = Some(email.to_string());
77 }
78
79 Ok(cfg)
80 }
81
82 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 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 pub fn has_email(&self) -> bool {
116 self.email.as_deref().map(|e| !e.is_empty()).unwrap_or(false)
117 }
118}
119
120pub 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
130pub 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
169pub 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}