use tracing::{debug, info};
use crate::auth::TokenManager;
use crate::error::{Result, WeChatError};
use crate::http::WeChatHttpClient;
use crate::markdown::{MarkdownContent, MarkdownParser};
use crate::theme::ThemeManager;
use crate::upload::{Article, DraftInfo, DraftManager, ImageUploader};
use crate::utils;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct UploadOptions {
pub theme: String,
pub title: Option<String>,
pub author: Option<String>,
pub cover_image: Option<String>,
pub show_cover: bool,
pub enable_comments: bool,
pub fans_only_comments: bool,
pub source_url: Option<String>,
}
impl Default for UploadOptions {
fn default() -> Self {
Self {
theme: "default".to_string(),
title: None,
author: None,
cover_image: None,
show_cover: true,
enable_comments: false,
fans_only_comments: false,
source_url: None,
}
}
}
impl UploadOptions {
pub fn with_theme(theme: impl Into<String>) -> Self {
Self {
theme: theme.into(),
..Default::default()
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
pub fn cover_image(mut self, path: impl Into<String>) -> Self {
self.cover_image = Some(path.into());
self
}
pub fn show_cover(mut self, show: bool) -> Self {
self.show_cover = show;
self
}
pub fn comments(mut self, enable: bool, fans_only: bool) -> Self {
self.enable_comments = enable;
self.fans_only_comments = fans_only;
self
}
pub fn source_url(mut self, url: impl Into<String>) -> Self {
self.source_url = Some(url.into());
self
}
}
#[derive(Debug)]
pub struct WeChatClient {
http_client: Arc<WeChatHttpClient>,
token_manager: Arc<TokenManager>,
image_uploader: ImageUploader,
draft_manager: DraftManager,
markdown_parser: MarkdownParser,
theme_manager: ThemeManager,
}
impl WeChatClient {
pub async fn new(app_id: impl Into<String>, app_secret: impl Into<String>) -> Result<Self> {
let app_id = app_id.into();
let app_secret = app_secret.into();
utils::validate_app_credentials(&app_id, &app_secret).map_err(WeChatError::config_error)?;
let http_client = Arc::new(WeChatHttpClient::new()?);
let token_manager = Arc::new(TokenManager::new(
app_id,
app_secret,
Arc::clone(&http_client),
));
let image_uploader =
ImageUploader::new(Arc::clone(&http_client), Arc::clone(&token_manager));
let draft_manager = DraftManager::new(Arc::clone(&http_client), Arc::clone(&token_manager));
let markdown_parser = MarkdownParser::new();
let theme_manager = ThemeManager::new();
Ok(Self {
http_client,
token_manager,
image_uploader,
draft_manager,
markdown_parser,
theme_manager,
})
}
pub async fn upload(&self, markdown_path: &str) -> Result<String> {
let options = UploadOptions::default();
self.upload_with_options(markdown_path, options).await
}
pub async fn upload_with_options(
&self,
markdown_path: &str,
options: UploadOptions,
) -> Result<String> {
let markdown_path = Path::new(markdown_path);
self.validate_upload_input(markdown_path, &options).await?;
info!("Starting upload process for: {}", markdown_path.display());
let mut content = self.parse_markdown_file(markdown_path).await?;
debug!("Found {} images in content", content.images.len());
let base_dir = utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
let upload_results = self
.image_uploader
.upload_images(content.images.clone(), base_dir)
.await?;
info!("Completed uploading {} images", upload_results.len());
let url_mapping = self.draft_manager.create_url_mapping(&upload_results);
content.replace_image_urls(&url_mapping)?;
let cover_path = options
.cover_image
.as_ref()
.or(content.cover.as_ref())
.expect("Cover image should be available from validation");
info!("Starting to upload cover image: {}", cover_path);
let cover_media_id = Some(self.upload_cover_image(cover_path, base_dir).await?);
info!("Completed uploading cover image");
let theme = content
.theme
.as_ref()
.or(Some(&options.theme))
.map(|t| t.as_str())
.unwrap_or("default");
if !self.theme_manager.has_theme(theme) {
return Err(WeChatError::ThemeNotFound {
theme: theme.to_string(),
});
}
let html_content = self.render_content(&content, theme, &options)?;
let article = self.create_article(&content, &options, html_content, cover_media_id);
let draft_id = self.draft_manager.create_draft(vec![article]).await?;
info!("Successfully created draft with ID: {draft_id}");
Ok(draft_id)
}
pub async fn get_draft(&self, media_id: &str) -> Result<DraftInfo> {
self.draft_manager.get_draft(media_id).await
}
pub async fn update_draft(&self, media_id: &str, markdown_path: &str) -> Result<()> {
let options = UploadOptions::default();
self.update_draft_with_options(media_id, markdown_path, options)
.await
}
pub async fn update_draft_with_options(
&self,
media_id: &str,
markdown_path: &str,
options: UploadOptions,
) -> Result<()> {
let markdown_path = Path::new(markdown_path);
self.validate_upload_input(markdown_path, &options).await?;
info!(
"Updating draft {} with: {}",
media_id,
markdown_path.display()
);
let mut content = self.parse_markdown_file(markdown_path).await?;
let base_dir = utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
let upload_results = self
.image_uploader
.upload_images(content.images.clone(), base_dir)
.await?;
let url_mapping = self.draft_manager.create_url_mapping(&upload_results);
content.replace_image_urls(&url_mapping)?;
let cover_path = options
.cover_image
.as_ref()
.or(content.cover.as_ref())
.expect("Cover image should be available from validation");
let cover_media_id = Some(self.upload_cover_image(cover_path, base_dir).await?);
let theme = content
.theme
.as_ref()
.or(Some(&options.theme))
.map(|t| t.as_str())
.unwrap_or("default");
if !self.theme_manager.has_theme(theme) {
return Err(WeChatError::ThemeNotFound {
theme: theme.to_string(),
});
}
let html_content = self.render_content(&content, theme, &options)?;
let article = self.create_article(&content, &options, html_content, cover_media_id);
self.draft_manager
.update_draft(media_id, vec![article])
.await?;
info!("Successfully updated draft: {media_id}");
Ok(())
}
pub async fn delete_draft(&self, media_id: &str) -> Result<()> {
self.draft_manager.delete_draft(media_id).await
}
pub async fn list_drafts(&self, offset: u32, count: u32) -> Result<Vec<DraftInfo>> {
self.draft_manager.list_drafts(offset, count).await
}
pub async fn upload_image(&self, image_path: &str) -> Result<String> {
let image_path = Path::new(image_path);
if !utils::file_exists(image_path).await {
return Err(WeChatError::FileNotFound {
path: image_path.display().to_string(),
});
}
if !utils::is_image_file(image_path) {
return Err(WeChatError::config_error(
"File is not a supported image format",
));
}
let image_ref = crate::markdown::ImageRef::new(
"Uploaded image".to_string(),
image_path.display().to_string(),
(0, 0),
);
let base_dir = utils::get_base_directory(image_path).unwrap_or_else(|| Path::new("."));
let results = self
.image_uploader
.upload_images(vec![image_ref], base_dir)
.await?;
Ok(results.into_iter().next().unwrap().url)
}
pub async fn create_draft(&self, articles: Vec<Article>) -> Result<String> {
self.draft_manager.create_draft(articles).await
}
pub fn available_themes(&self) -> Vec<&String> {
self.theme_manager.available_themes()
}
pub fn has_theme(&self, theme: &str) -> bool {
self.theme_manager.has_theme(theme)
}
pub async fn get_token_info(&self) -> Option<crate::auth::TokenInfo> {
self.token_manager.get_token_info().await
}
pub async fn refresh_token(&self) -> Result<String> {
self.token_manager.force_refresh().await
}
pub fn http_client(&self) -> &WeChatHttpClient {
&self.http_client
}
async fn validate_upload_input(
&self,
markdown_path: &Path,
options: &UploadOptions,
) -> Result<()> {
if !utils::file_exists(markdown_path).await {
return Err(WeChatError::FileNotFound {
path: markdown_path.display().to_string(),
});
}
if !utils::is_markdown_file(markdown_path) {
return Err(WeChatError::config_error(
"File is not a markdown file (.md or .markdown)",
));
}
let content = self.parse_markdown_file(markdown_path).await?;
let has_cover_option = options.cover_image.is_some();
let has_cover_frontmatter = content.cover.is_some();
if !has_cover_option && !has_cover_frontmatter {
return Err(WeChatError::config_error(
"Cover image is required. Please provide via --cover-image option or 'cover:' in frontmatter",
));
}
if let Some(cover_path) = &options.cover_image {
let base_dir =
utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
let resolved_cover_path = if Path::new(cover_path).is_absolute() {
PathBuf::from(cover_path)
} else {
base_dir.join(cover_path)
};
if !utils::file_exists(&resolved_cover_path).await {
return Err(WeChatError::FileNotFound {
path: resolved_cover_path.display().to_string(),
});
}
if !utils::is_image_file(&resolved_cover_path) {
return Err(WeChatError::config_error(
"Cover file is not a supported image format",
));
}
}
if let Some(cover_path) = &content.cover {
let base_dir =
utils::get_base_directory(markdown_path).unwrap_or_else(|| Path::new("."));
let resolved_cover_path = if Path::new(cover_path).is_absolute() {
PathBuf::from(cover_path)
} else {
base_dir.join(cover_path)
};
if !utils::file_exists(&resolved_cover_path).await {
return Err(WeChatError::FileNotFound {
path: resolved_cover_path.display().to_string(),
});
}
if !utils::is_image_file(&resolved_cover_path) {
return Err(WeChatError::config_error(
"Cover file specified in frontmatter is not a supported image format",
));
}
}
Ok(())
}
async fn parse_markdown_file(&self, path: &Path) -> Result<MarkdownContent> {
self.markdown_parser.parse_file(path).await
}
async fn upload_cover_image(&self, cover_path: &str, base_dir: &Path) -> Result<String> {
let cover_path = if Path::new(cover_path).is_absolute() {
PathBuf::from(cover_path)
} else {
base_dir.join(cover_path)
};
self.image_uploader.upload_cover_material(&cover_path).await
}
fn render_content(
&self,
content: &MarkdownContent,
theme: &str,
options: &UploadOptions,
) -> Result<String> {
let mut metadata = content.metadata.clone();
if let Some(title) = content.title.as_ref() {
metadata.insert("title".to_string(), title.clone());
}
if let Some(author) = content.author.as_ref() {
metadata.insert("author".to_string(), author.clone());
}
if let Some(title) = &options.title {
metadata.insert("title".to_string(), title.clone());
}
if let Some(author) = &options.author {
metadata.insert("author".to_string(), author.clone());
}
self.theme_manager.render(
&content.content,
theme,
content.code.as_deref().unwrap_or("vscode"),
&metadata,
)
}
fn create_article(
&self,
content: &MarkdownContent,
options: &UploadOptions,
html_content: String,
cover_media_id: Option<String>,
) -> Article {
let title = options
.title
.clone()
.or_else(|| content.title.clone())
.unwrap_or_else(|| "Untitled".to_string());
let author = options
.author
.clone()
.or_else(|| content.author.clone())
.unwrap_or_else(|| "Anonymous".to_string());
let digest = content
.description
.clone()
.unwrap_or_else(|| content.get_summary(120));
let mut article = Article::new(title, author, html_content)
.with_digest(digest)
.with_show_cover(options.show_cover)
.with_comments(options.enable_comments, options.fans_only_comments);
if let Some(media_id) = cover_media_id {
article = article.with_cover_image(media_id);
}
if let Some(source_url) = &options.source_url {
article = article.with_source_url(source_url.clone());
}
article
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_upload_options_builder() {
let options = UploadOptions::with_theme("github")
.title("Test Title")
.author("Test Author")
.cover_image("cover.jpg")
.show_cover(false)
.comments(true, true)
.source_url("https://example.com");
assert_eq!(options.theme, "github");
assert_eq!(options.title, Some("Test Title".to_string()));
assert_eq!(options.author, Some("Test Author".to_string()));
assert_eq!(options.cover_image, Some("cover.jpg".to_string()));
assert!(!options.show_cover);
assert!(options.enable_comments);
assert!(options.fans_only_comments);
assert_eq!(options.source_url, Some("https://example.com".to_string()));
}
#[test]
fn test_upload_options_default() {
let options = UploadOptions::default();
assert_eq!(options.theme, "default");
assert_eq!(options.title, None);
assert_eq!(options.author, None);
assert_eq!(options.cover_image, None);
assert!(options.show_cover);
assert!(!options.enable_comments);
assert!(!options.fans_only_comments);
assert_eq!(options.source_url, None);
}
#[tokio::test]
async fn test_client_creation_with_invalid_credentials() {
let result = WeChatClient::new("invalid", "12345678901234567890123456789012").await;
assert!(result.is_err());
let result = WeChatClient::new("wx1234567890123456", "short").await;
assert!(result.is_err());
let result = WeChatClient::new("", "").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_client_creation_with_valid_credentials() {
let result =
WeChatClient::new("wx1234567890123456", "12345678901234567890123456789012").await;
assert!(result.is_ok());
let client = result.unwrap();
assert!(client.available_themes().len() >= 4);
assert!(client.has_theme("default"));
assert!(client.has_theme("lapis"));
assert!(client.has_theme("maize"));
assert!(client.has_theme("orangeheart"));
}
#[tokio::test]
async fn test_cover_requirement_validation() {
use tempfile::Builder;
let client = WeChatClient::new("wx1234567890123456", "12345678901234567890123456789012")
.await
.unwrap();
let temp_file = Builder::new().suffix(".md").tempfile().unwrap();
let markdown_without_cover = r#"---
title: Test Article
author: Test Author
---
# Content
Some article content here.
"#;
tokio::fs::write(temp_file.path(), markdown_without_cover)
.await
.unwrap();
let options = UploadOptions::with_theme("default");
let result = client
.validate_upload_input(temp_file.path(), &options)
.await;
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Cover image is required"));
let temp_file2 = Builder::new().suffix(".md").tempfile().unwrap();
let markdown_with_cover = r#"---
title: Test Article
author: Test Author
cover: ../fixtures/images/02-cover.png
---
# Content
Some article content here.
"#;
tokio::fs::write(temp_file2.path(), markdown_with_cover)
.await
.unwrap();
let options2 = UploadOptions::with_theme("default");
let result2 = client
.validate_upload_input(temp_file2.path(), &options2)
.await;
assert!(result2.is_err());
assert!(result2.unwrap_err().to_string().contains("02-cover.png"));
let temp_file3 = Builder::new().suffix(".md").tempfile().unwrap();
let markdown_no_frontmatter_cover = r#"---
title: Test Article
author: Test Author
---
# Content
Some article content here.
"#;
tokio::fs::write(temp_file3.path(), markdown_no_frontmatter_cover)
.await
.unwrap();
let options3 =
UploadOptions::with_theme("default").cover_image("../fixtures/images/02-cover.png");
let result3 = client
.validate_upload_input(temp_file3.path(), &options3)
.await;
assert!(result3.is_err());
assert!(result3.unwrap_err().to_string().contains("02-cover.png"));
}
#[tokio::test]
async fn test_fixture_file_parsing() {
let client = WeChatClient::new("wx1234567890123456", "12345678901234567890123456789012")
.await
.unwrap();
let content = client
.parse_markdown_file(std::path::Path::new("fixtures/example.md"))
.await
.unwrap();
assert_eq!(content.author, Some("陈小天".to_string()));
assert_eq!(
content.description,
Some("为了这壶醋,我包了这顿饺子(写了几千行 Rust,做了个工具)".to_string())
);
assert_eq!(content.cover, Some("images/02-cover.png".to_string()));
let options = UploadOptions::with_theme("default");
let result = client
.validate_upload_input(std::path::Path::new("fixtures/example.md"), &options)
.await;
assert!(
result.is_ok(),
"Validation should pass for fixture file with cover in frontmatter"
);
}
}