use crate::error::Result;
use crate::upload::{Article, DraftInfo};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use std::collections::HashMap;
#[async_trait]
pub trait TokenProvider: Send + Sync {
async fn get_token(&self) -> Result<String>;
async fn refresh_token(&self) -> Result<String>;
async fn is_token_expired(&self) -> bool;
async fn token_expires_at(&self) -> Option<DateTime<Utc>>;
}
#[async_trait]
pub trait ContentUploader: Send + Sync {
async fn upload_image(&self, image_path: &str) -> Result<String>;
async fn upload_article(&self, article: &Article) -> Result<String>;
async fn update_draft(&self, draft_id: &str, article: &Article) -> Result<()>;
async fn get_draft(&self, draft_id: &str) -> Result<DraftInfo>;
async fn list_drafts(&self, offset: u32, count: u32) -> Result<Vec<DraftInfo>>;
}
#[async_trait]
pub trait ImageProcessor: Send + Sync {
async fn validate_image(&self, path: &str) -> Result<()>;
async fn get_image_info(&self, path: &str) -> Result<ImageInfo>;
async fn resize_if_needed(
&self,
path: &str,
max_width: u32,
max_height: u32,
) -> Result<Vec<u8>>;
async fn compress_image(&self, data: &[u8], quality: u8) -> Result<Vec<u8>>;
}
pub trait ContentRenderer: Send + Sync {
fn render_content(
&self,
markdown: &str,
theme: &str,
code_theme: &str,
metadata: &HashMap<String, String>,
) -> Result<String>;
fn available_themes(&self) -> Vec<String>;
fn has_theme(&self, theme: &str) -> bool;
fn validate_theme(&self, theme: &str) -> Result<()>;
}
#[async_trait]
pub trait Cache<K, V>: Send + Sync
where
K: Send + Sync,
V: Send + Sync + Clone,
{
async fn get(&self, key: &K) -> Option<V>;
async fn set(&self, key: K, value: V);
async fn remove(&self, key: &K);
async fn clear(&self);
async fn stats(&self) -> CacheStats;
}
#[async_trait]
pub trait HttpClient: Send + Sync {
async fn get_with_token(&self, endpoint: &str, token: &str) -> Result<reqwest::Response>;
async fn post_json_with_token<T: serde::Serialize + Send + Sync>(
&self,
endpoint: &str,
token: &str,
body: &T,
) -> Result<reqwest::Response>;
async fn upload_file(
&self,
endpoint: &str,
token: &str,
field_name: &str,
file_data: Vec<u8>,
filename: &str,
) -> Result<reqwest::Response>;
async fn download_with_limit(&self, url: &str, max_size: u64) -> Result<Vec<u8>>;
}
pub trait MarkdownProcessor: Send + Sync {
fn parse_content(&self, content: &str) -> Result<ParsedMarkdown>;
fn extract_images(&self, content: &str) -> Result<Vec<ImageReference>>;
fn replace_image_urls(&self, content: &str, url_map: &HashMap<String, String>) -> String;
fn validate_markdown(&self, content: &str) -> Result<()>;
}
#[derive(Debug, Clone)]
pub struct ImageInfo {
pub width: u32,
pub height: u32,
pub format: String,
pub file_size: u64,
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub entries: usize,
pub hit_rate: f64,
}
#[derive(Debug, Clone)]
pub struct ParsedMarkdown {
pub content: String,
pub metadata: HashMap<String, String>,
pub title: Option<String>,
pub author: Option<String>,
pub cover: Option<String>,
pub theme: Option<String>,
pub code_theme: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ImageReference {
pub alt_text: String,
pub url: String,
pub title: Option<String>,
}
impl CacheStats {
pub fn new(hits: u64, misses: u64, entries: usize) -> Self {
let total = hits + misses;
let hit_rate = if total > 0 {
hits as f64 / total as f64
} else {
0.0
};
Self {
hits,
misses,
entries,
hit_rate,
}
}
pub fn record_hit(&mut self) {
self.hits += 1;
self.update_hit_rate();
}
pub fn record_miss(&mut self) {
self.misses += 1;
self.update_hit_rate();
}
fn update_hit_rate(&mut self) {
let total = self.hits + self.misses;
self.hit_rate = if total > 0 {
self.hits as f64 / total as f64
} else {
0.0
};
}
}
impl Default for CacheStats {
fn default() -> Self {
Self::new(0, 0, 0)
}
}
impl ParsedMarkdown {
pub fn new(content: String, metadata: HashMap<String, String>) -> Self {
let title = metadata.get("title").cloned();
let author = metadata.get("author").cloned();
let cover = metadata.get("cover").cloned();
let theme = metadata.get("theme").cloned();
let code_theme = metadata.get("code").cloned();
Self {
content,
metadata,
title,
author,
cover,
theme,
code_theme,
}
}
pub fn get_metadata(&self, key: &str) -> Option<&String> {
self.metadata.get(key)
}
pub fn has_required_fields(&self) -> bool {
self.title.is_some() && self.cover.is_some()
}
}
impl ImageReference {
pub fn new(alt_text: String, url: String, title: Option<String>) -> Self {
Self {
alt_text,
url,
title,
}
}
pub fn is_local(&self) -> bool {
!self.url.starts_with("http://") && !self.url.starts_with("https://")
}
pub fn extension(&self) -> Option<&str> {
std::path::Path::new(&self.url)
.extension()
.and_then(|ext| ext.to_str())
}
}
pub trait Validate {
type Error;
fn validate(&self) -> std::result::Result<(), Self::Error>;
}
pub trait Configurable {
type Config;
fn configure(&mut self, config: &Self::Config) -> Result<()>;
fn get_config(&self) -> &Self::Config;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_stats() {
let mut stats = CacheStats::default();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
assert_eq!(stats.hit_rate, 0.0);
stats.record_hit();
stats.record_hit();
stats.record_miss();
assert_eq!(stats.hits, 2);
assert_eq!(stats.misses, 1);
assert!((stats.hit_rate - 2.0 / 3.0).abs() < 0.001);
}
#[test]
fn test_parsed_markdown() {
let mut metadata = HashMap::new();
metadata.insert("title".to_string(), "Test Title".to_string());
metadata.insert("author".to_string(), "Test Author".to_string());
metadata.insert("cover".to_string(), "cover.jpg".to_string());
let parsed = ParsedMarkdown::new("# Content".to_string(), metadata);
assert_eq!(parsed.title, Some("Test Title".to_string()));
assert_eq!(parsed.author, Some("Test Author".to_string()));
assert_eq!(parsed.cover, Some("cover.jpg".to_string()));
assert!(parsed.has_required_fields());
assert_eq!(
parsed.get_metadata("title"),
Some(&"Test Title".to_string())
);
}
#[test]
fn test_image_reference() {
let img_ref = ImageReference::new(
"Alt text".to_string(),
"images/test.jpg".to_string(),
Some("Title".to_string()),
);
assert!(img_ref.is_local());
assert_eq!(img_ref.extension(), Some("jpg"));
let url_ref = ImageReference::new(
"Alt text".to_string(),
"https://example.com/image.png".to_string(),
None,
);
assert!(!url_ref.is_local());
assert_eq!(url_ref.extension(), Some("png"));
}
}