Skip to main content

wx_uploader/
models.rs

1//! Data models and configuration for the wx-uploader library
2//!
3//! This module contains core data structures used throughout the application,
4//! including configuration, frontmatter parsing, and validation logic.
5
6use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::env;
9
10/// Configuration for the wx-uploader application
11///
12/// Contains all necessary API keys and settings for WeChat and OpenAI integration.
13#[derive(Debug, Clone)]
14pub struct Config {
15    /// WeChat application ID
16    pub wechat_app_id: String,
17    /// WeChat application secret
18    pub wechat_app_secret: String,
19    /// Optional OpenAI API key for cover image generation
20    pub openai_api_key: Option<String>,
21    /// Optional Gemini API key for cover image generation
22    pub gemini_api_key: Option<String>,
23    /// Enable verbose logging
24    pub verbose: bool,
25}
26
27impl Config {
28    /// Creates a new configuration from environment variables
29    ///
30    /// # Required Environment Variables
31    ///
32    /// - `WECHAT_APP_ID`: WeChat application ID
33    /// - `WECHAT_APP_SECRET`: WeChat application secret
34    ///
35    /// # Optional Environment Variables
36    ///
37    /// - `OPENAI_API_KEY`: OpenAI API key for cover image generation
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if required environment variables are not set
42    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, // Default to false, can be overridden by CLI
58        })
59    }
60
61    /// Creates a new configuration with explicit values
62    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    /// Sets the verbose flag
79    pub fn with_verbose(mut self, verbose: bool) -> Self {
80        self.verbose = verbose;
81        self
82    }
83
84    /// Validates the configuration
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if:
89    /// - WeChat app ID is empty
90    /// - WeChat app secret is empty
91    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/// YAML frontmatter structure for markdown files.
105///
106/// This struct represents the frontmatter that can be present at the beginning
107/// of markdown files. It supports common fields like `title` and `published`,
108/// and uses `#[serde(flatten)]` to capture any additional fields.
109///
110/// # Examples
111///
112/// ```yaml
113/// ---
114/// title: "My Article"
115/// published: "draft"
116/// author: "John Doe"
117/// tags: ["rust", "wechat"]
118/// theme: "lapis"
119/// code: "github"
120/// cover: "cover.png"
121/// ---
122/// ```
123#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
124pub struct Frontmatter {
125    /// The title of the article. Optional field that will be omitted from
126    /// serialization if not present.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub title: Option<String>,
129
130    /// Publication status of the article.
131    ///
132    /// Common values:
133    /// - `None` or missing: not uploaded
134    /// - `"draft"`: uploaded as draft to WeChat
135    /// - `"true"`: published (will be skipped in directory mode)
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub published: Option<String>,
138
139    /// Cover image filename for the article.
140    /// If missing, the system will attempt to generate one using AI.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub cover: Option<String>,
143
144    /// Image generation model alias.
145    ///
146    /// Available models: nb2 (default, Gemini Flash), nb (Gemini Pro), gpt (OpenAI)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub model: Option<String>,
149
150    /// Theme for the WeChat article styling.
151    ///
152    /// Available themes: default, lapis, maize, orangeheart, phycat, pie, purple, rainbow
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub theme: Option<String>,
155
156    /// Code highlighter for syntax highlighting.
157    ///
158    /// Available highlighters: github, github-dark, vscode, atom-one-light, atom-one-dark,
159    /// solarized-light, solarized-dark, monokai, dracula, xcode
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub code: Option<String>,
162
163    /// Description of the article.
164    #[serde(default, skip_serializing_if = "String::is_empty")]
165    pub description: String,
166
167    /// Captures any additional fields in the frontmatter that are not
168    /// explicitly defined in this struct.
169    #[serde(flatten)]
170    pub other: serde_yaml::Value,
171}
172
173impl Frontmatter {
174    /// Creates a new empty frontmatter
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Creates a frontmatter with a title
180    pub fn with_title(title: impl Into<String>) -> Self {
181        Self {
182            title: Some(title.into()),
183            ..Default::default()
184        }
185    }
186
187    /// Sets the title
188    pub fn set_title(&mut self, title: impl Into<String>) {
189        self.title = Some(title.into());
190    }
191
192    /// Sets the published status
193    pub fn set_published(&mut self, status: impl Into<String>) {
194        self.published = Some(status.into());
195    }
196
197    /// Sets the cover image
198    pub fn set_cover(&mut self, cover: impl Into<String>) {
199        self.cover = Some(cover.into());
200    }
201
202    /// Sets the theme
203    pub fn set_theme(&mut self, theme: impl Into<String>) {
204        self.theme = Some(theme.into());
205    }
206
207    /// Sets the code highlighter
208    pub fn set_code_highlighter(&mut self, code: impl Into<String>) {
209        self.code = Some(code.into());
210    }
211
212    /// Checks if the article is published
213    pub fn is_published(&self) -> bool {
214        // Check the published field first
215        if matches!(self.published.as_deref(), Some("true") | Some("\"true\"")) {
216            return true;
217        }
218
219        // Check if published is a boolean true in the other field
220        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    /// Checks if the article is a draft
231    pub fn is_draft(&self) -> bool {
232        matches!(self.published.as_deref(), Some("draft"))
233    }
234
235    /// Checks if the article is unpublished
236    pub fn is_unpublished(&self) -> bool {
237        self.published.is_none() || self.published.as_deref() == Some("")
238    }
239
240    /// Returns the effective model alias, defaulting to "nb2"
241    pub fn effective_model(&self) -> &str {
242        self.model.as_deref().unwrap_or("nb2")
243    }
244
245    /// Validates the frontmatter
246    pub fn validate(&self) -> Result<()> {
247        // Validate theme if present
248        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        // Validate code highlighter if present
259        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        // Validate model if present
270        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
284/// Valid themes for WeChat articles
285pub const VALID_THEMES: &[&str] = &[
286    "default",
287    "lapis",
288    "maize",
289    "orangeheart",
290    "phycat",
291    "pie",
292    "purple",
293    "rainbow",
294];
295
296/// Valid image generation models
297pub const VALID_MODELS: &[&str] = &["nb2", "nb", "gpt"];
298
299/// Valid code highlighters for syntax highlighting
300pub 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
313/// Checks if a theme is valid
314pub fn is_valid_theme(theme: &str) -> bool {
315    VALID_THEMES.contains(&theme)
316}
317
318/// Checks if a code highlighter is valid
319pub fn is_valid_code_highlighter(highlighter: &str) -> bool {
320    VALID_CODE_HIGHLIGHTERS.contains(&highlighter)
321}
322
323/// Checks if a model alias is valid
324pub 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        // Set environment variables
384        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        // Clean up
396        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        // Test unpublished
440        assert!(frontmatter.is_unpublished());
441        assert!(!frontmatter.is_draft());
442        assert!(!frontmatter.is_published());
443
444        // Test draft
445        frontmatter.set_published("draft");
446        assert!(frontmatter.is_draft());
447        assert!(!frontmatter.is_published());
448        assert!(!frontmatter.is_unpublished());
449
450        // Test published
451        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        // Valid frontmatter
462        assert!(frontmatter.validate().is_ok());
463
464        // Valid theme and code
465        frontmatter.set_theme("lapis");
466        frontmatter.set_code_highlighter("github");
467        assert!(frontmatter.validate().is_ok());
468
469        // Invalid theme
470        frontmatter.set_theme("invalid_theme");
471        assert!(frontmatter.validate().is_err());
472
473        // Fix theme, invalid code
474        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}