1use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::env;
9
10#[derive(Debug, Clone)]
14pub struct Config {
15 pub wechat_app_id: String,
17 pub wechat_app_secret: String,
19 pub openai_api_key: Option<String>,
21 pub gemini_api_key: Option<String>,
23 pub verbose: bool,
25}
26
27impl Config {
28 pub fn from_env() -> Result<Self> {
43 let wechat_app_id =
44 env::var("WECHAT_APP_ID").map_err(|_| Error::missing_env_var("WECHAT_APP_ID"))?;
45
46 let wechat_app_secret = env::var("WECHAT_APP_SECRET")
47 .map_err(|_| Error::missing_env_var("WECHAT_APP_SECRET"))?;
48
49 let openai_api_key = env::var("OPENAI_API_KEY").ok();
50 let gemini_api_key = env::var("GEMINI_API_KEY").ok();
51
52 Ok(Self {
53 wechat_app_id,
54 wechat_app_secret,
55 openai_api_key,
56 gemini_api_key,
57 verbose: false, })
59 }
60
61 pub fn new(
63 wechat_app_id: String,
64 wechat_app_secret: String,
65 openai_api_key: Option<String>,
66 gemini_api_key: Option<String>,
67 verbose: bool,
68 ) -> Self {
69 Self {
70 wechat_app_id,
71 wechat_app_secret,
72 openai_api_key,
73 gemini_api_key,
74 verbose,
75 }
76 }
77
78 pub fn with_verbose(mut self, verbose: bool) -> Self {
80 self.verbose = verbose;
81 self
82 }
83
84 pub fn validate(&self) -> Result<()> {
92 if self.wechat_app_id.trim().is_empty() {
93 return Err(Error::config("WeChat app ID cannot be empty"));
94 }
95
96 if self.wechat_app_secret.trim().is_empty() {
97 return Err(Error::config("WeChat app secret cannot be empty"));
98 }
99
100 Ok(())
101 }
102}
103
104#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
124pub struct Frontmatter {
125 #[serde(skip_serializing_if = "Option::is_none")]
128 pub title: Option<String>,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
137 pub published: Option<String>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
142 pub cover: Option<String>,
143
144 #[serde(skip_serializing_if = "Option::is_none")]
148 pub model: Option<String>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
154 pub theme: Option<String>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
161 pub code: Option<String>,
162
163 #[serde(default, skip_serializing_if = "String::is_empty")]
165 pub description: String,
166
167 #[serde(flatten)]
170 pub other: serde_yaml::Value,
171}
172
173impl Frontmatter {
174 pub fn new() -> Self {
176 Self::default()
177 }
178
179 pub fn with_title(title: impl Into<String>) -> Self {
181 Self {
182 title: Some(title.into()),
183 ..Default::default()
184 }
185 }
186
187 pub fn set_title(&mut self, title: impl Into<String>) {
189 self.title = Some(title.into());
190 }
191
192 pub fn set_published(&mut self, status: impl Into<String>) {
194 self.published = Some(status.into());
195 }
196
197 pub fn set_cover(&mut self, cover: impl Into<String>) {
199 self.cover = Some(cover.into());
200 }
201
202 pub fn set_theme(&mut self, theme: impl Into<String>) {
204 self.theme = Some(theme.into());
205 }
206
207 pub fn set_code_highlighter(&mut self, code: impl Into<String>) {
209 self.code = Some(code.into());
210 }
211
212 pub fn is_published(&self) -> bool {
214 if matches!(self.published.as_deref(), Some("true") | Some("\"true\"")) {
216 return true;
217 }
218
219 if let serde_yaml::Value::Mapping(map) = &self.other
221 && let Some(serde_yaml::Value::Bool(true)) =
222 map.get(serde_yaml::Value::String("published".to_string()))
223 {
224 return true;
225 }
226
227 false
228 }
229
230 pub fn is_draft(&self) -> bool {
232 matches!(self.published.as_deref(), Some("draft"))
233 }
234
235 pub fn is_unpublished(&self) -> bool {
237 self.published.is_none() || self.published.as_deref() == Some("")
238 }
239
240 pub fn effective_model(&self) -> &str {
242 self.model.as_deref().unwrap_or("nb2")
243 }
244
245 pub fn validate(&self) -> Result<()> {
247 if let Some(theme) = &self.theme
249 && !is_valid_theme(theme)
250 {
251 return Err(Error::config(format!(
252 "Invalid theme '{}'. Available themes: {}",
253 theme,
254 VALID_THEMES.join(", ")
255 )));
256 }
257
258 if let Some(code) = &self.code
260 && !is_valid_code_highlighter(code)
261 {
262 return Err(Error::config(format!(
263 "Invalid code highlighter '{}'. Available highlighters: {}",
264 code,
265 VALID_CODE_HIGHLIGHTERS.join(", ")
266 )));
267 }
268
269 if let Some(model) = &self.model
271 && !is_valid_model(model)
272 {
273 return Err(Error::config(format!(
274 "Invalid model '{}'. Available models: {}",
275 model,
276 VALID_MODELS.join(", ")
277 )));
278 }
279
280 Ok(())
281 }
282}
283
284pub const VALID_THEMES: &[&str] = &[
286 "default",
287 "lapis",
288 "maize",
289 "orangeheart",
290 "phycat",
291 "pie",
292 "purple",
293 "rainbow",
294];
295
296pub const VALID_MODELS: &[&str] = &["nb2", "nb", "gpt"];
298
299pub const VALID_CODE_HIGHLIGHTERS: &[&str] = &[
301 "github",
302 "github-dark",
303 "vscode",
304 "atom-one-light",
305 "atom-one-dark",
306 "solarized-light",
307 "solarized-dark",
308 "monokai",
309 "dracula",
310 "xcode",
311];
312
313pub fn is_valid_theme(theme: &str) -> bool {
315 VALID_THEMES.contains(&theme)
316}
317
318pub fn is_valid_code_highlighter(highlighter: &str) -> bool {
320 VALID_CODE_HIGHLIGHTERS.contains(&highlighter)
321}
322
323pub fn is_valid_model(model: &str) -> bool {
325 VALID_MODELS.contains(&model)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use std::env;
332
333 #[test]
334 fn test_config_creation() {
335 let config = Config::new(
336 "test_app_id".to_string(),
337 "test_secret".to_string(),
338 Some("test_openai_key".to_string()),
339 None,
340 true,
341 );
342
343 assert_eq!(config.wechat_app_id, "test_app_id");
344 assert_eq!(config.wechat_app_secret, "test_secret");
345 assert_eq!(config.openai_api_key, Some("test_openai_key".to_string()));
346 assert!(config.verbose);
347 }
348
349 #[test]
350 fn test_config_with_verbose() {
351 let config = Config::new(
352 "test_app_id".to_string(),
353 "test_secret".to_string(),
354 None,
355 None,
356 false,
357 )
358 .with_verbose(true);
359
360 assert!(config.verbose);
361 }
362
363 #[test]
364 fn test_config_validation() {
365 let valid_config = Config::new(
366 "app_id".to_string(),
367 "secret".to_string(),
368 None,
369 None,
370 false,
371 );
372 assert!(valid_config.validate().is_ok());
373
374 let empty_app_id = Config::new("".to_string(), "secret".to_string(), None, None, false);
375 assert!(empty_app_id.validate().is_err());
376
377 let empty_secret = Config::new("app_id".to_string(), "".to_string(), None, None, false);
378 assert!(empty_secret.validate().is_err());
379 }
380
381 #[test]
382 fn test_config_from_env() {
383 unsafe {
385 env::set_var("WECHAT_APP_ID", "test_id");
386 env::set_var("WECHAT_APP_SECRET", "test_secret");
387 env::set_var("OPENAI_API_KEY", "test_openai");
388 }
389
390 let config = Config::from_env().unwrap();
391 assert_eq!(config.wechat_app_id, "test_id");
392 assert_eq!(config.wechat_app_secret, "test_secret");
393 assert_eq!(config.openai_api_key, Some("test_openai".to_string()));
394
395 unsafe {
397 env::remove_var("WECHAT_APP_ID");
398 env::remove_var("WECHAT_APP_SECRET");
399 env::remove_var("OPENAI_API_KEY");
400 }
401 }
402
403 #[test]
404 fn test_frontmatter_creation() {
405 let frontmatter = Frontmatter::new();
406 assert_eq!(frontmatter.title, None);
407 assert_eq!(frontmatter.published, None);
408 assert_eq!(frontmatter.cover, None);
409
410 let frontmatter = Frontmatter::with_title("Test Article");
411 assert_eq!(frontmatter.title, Some("Test Article".to_string()));
412 }
413
414 #[test]
415 fn test_frontmatter_methods() {
416 let mut frontmatter = Frontmatter::new();
417
418 frontmatter.set_title("My Article");
419 frontmatter.set_published("draft");
420 frontmatter.set_cover("cover.png");
421 frontmatter.set_theme("lapis");
422 frontmatter.set_code_highlighter("github");
423
424 assert_eq!(frontmatter.title, Some("My Article".to_string()));
425 assert_eq!(frontmatter.published, Some("draft".to_string()));
426 assert_eq!(frontmatter.cover, Some("cover.png".to_string()));
427 assert_eq!(frontmatter.theme, Some("lapis".to_string()));
428 assert_eq!(frontmatter.code, Some("github".to_string()));
429
430 assert!(frontmatter.is_draft());
431 assert!(!frontmatter.is_published());
432 assert!(!frontmatter.is_unpublished());
433 }
434
435 #[test]
436 fn test_frontmatter_status_checks() {
437 let mut frontmatter = Frontmatter::new();
438
439 assert!(frontmatter.is_unpublished());
441 assert!(!frontmatter.is_draft());
442 assert!(!frontmatter.is_published());
443
444 frontmatter.set_published("draft");
446 assert!(frontmatter.is_draft());
447 assert!(!frontmatter.is_published());
448 assert!(!frontmatter.is_unpublished());
449
450 frontmatter.set_published("true");
452 assert!(frontmatter.is_published());
453 assert!(!frontmatter.is_draft());
454 assert!(!frontmatter.is_unpublished());
455 }
456
457 #[test]
458 fn test_frontmatter_validation() {
459 let mut frontmatter = Frontmatter::new();
460
461 assert!(frontmatter.validate().is_ok());
463
464 frontmatter.set_theme("lapis");
466 frontmatter.set_code_highlighter("github");
467 assert!(frontmatter.validate().is_ok());
468
469 frontmatter.set_theme("invalid_theme");
471 assert!(frontmatter.validate().is_err());
472
473 frontmatter.set_theme("lapis");
475 frontmatter.set_code_highlighter("invalid_highlighter");
476 assert!(frontmatter.validate().is_err());
477 }
478
479 #[test]
480 fn test_theme_validation() {
481 assert!(is_valid_theme("lapis"));
482 assert!(is_valid_theme("default"));
483 assert!(!is_valid_theme("invalid"));
484 assert!(!is_valid_theme(""));
485 }
486
487 #[test]
488 fn test_code_highlighter_validation() {
489 assert!(is_valid_code_highlighter("github"));
490 assert!(is_valid_code_highlighter("monokai"));
491 assert!(!is_valid_code_highlighter("invalid"));
492 assert!(!is_valid_code_highlighter(""));
493 }
494
495 #[test]
496 fn test_frontmatter_serialization() {
497 let frontmatter = Frontmatter {
498 title: Some("Test Article".to_string()),
499 published: Some("draft".to_string()),
500 description: "Test Article".to_string(),
501 cover: Some("cover.png".to_string()),
502 model: None,
503 theme: Some("lapis".to_string()),
504 code: Some("github".to_string()),
505 other: serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
506 };
507
508 let yaml = serde_yaml::to_string(&frontmatter).unwrap();
509 assert!(yaml.contains("title: Test Article"));
510 assert!(yaml.contains("published: draft"));
511 assert!(yaml.contains("cover: cover.png"));
512 assert!(yaml.contains("theme: lapis"));
513 assert!(yaml.contains("code: github"));
514
515 let deserialized: Frontmatter = serde_yaml::from_str(&yaml).unwrap();
516 assert_eq!(frontmatter, deserialized);
517 }
518}