Compare commits

...

11 Commits

Author SHA1 Message Date
xfy
1a56c0cd3f test: gate AppError helper tests behind server feature
Some checks failed
CI / build (push) Has been cancelled
CI / check (push) Has been cancelled
2026-06-16 17:28:43 +08:00
xfy
a06e4a5b9d build: remove uuid js feature and commit Cargo.lock updates 2026-06-16 17:21:22 +08:00
xfy
2dcd300930 build: allow dead_code on CommentCountResponse for WASM builds 2026-06-16 17:12:35 +08:00
xfy
1444ccef86 build: make sha2 and hex optional and gated by server feature 2026-06-16 17:11:15 +08:00
xfy
fa5216c6c5 build: make http optional and gated by server feature 2026-06-16 17:10:52 +08:00
xfy
d246adbb32 build: make rand optional and remove explicit getrandom dependency 2026-06-16 17:10:27 +08:00
xfy
726c6ec4e5 build: make pulldown-cmark optional and gated by server feature 2026-06-16 17:09:39 +08:00
xfy
6489ac604e build: make regex optional and gated by server feature 2026-06-16 17:09:16 +08:00
xfy
837f120621 build: make uuid optional and gated by server feature 2026-06-16 17:08:48 +08:00
xfy
6a98e8767d build: make argon2 optional and gated by server feature 2026-06-16 17:08:19 +08:00
xfy
cafbddb861 refactor: replace server-only dead_code allows with cfg(feature = "server")
Replace #[allow(dead_code)] on server-only helpers with #[cfg(feature = "server")]
to make the server/WASM split explicit and avoid compiling unused server logic
into the WASM frontend.

- Gate password/session/auth/comment helpers and model parsers with server feature
- Gate related imports and tests accordingly
- Remove genuinely unused CreatePostRequest and CreateCommentRequest
- Keep #[allow(dead_code)] for true dead code (stub methods, unused public APIs)
- Use #[cfg(any(target_arch = "wasm32", test))] for THEME_KEY
- Update AGENTS.md note
2026-06-16 16:45:08 +08:00
15 changed files with 56 additions and 74 deletions

View File

@ -71,7 +71,7 @@ Dioxus 0.7 fullstack project with **two independent gates** — the most common
**Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when `server` feature is disabled — do not remove. **Stub pattern**: `src/db/mod.rs` provides a `DummyPool` when `server` feature is disabled — do not remove.
**Dead code allowances**: `src/auth/password.rs` and `src/auth/session.rs` carry `#[allow(dead_code)]` because the compiler sees them as unused in WASM builds where server function bodies are stripped. **Server-only helpers**: `src/auth/password.rs`, `src/auth/session.rs`, `src/api/auth.rs`, `src/api/comments/helpers.rs`, and several model helper methods are gated with `#[cfg(feature = "server")]` because they are only called from server function bodies, which are stripped in WASM builds.
## Dual API Architecture ## Dual API Architecture
@ -165,6 +165,6 @@ Most tests use `#[cfg(all(test, feature = "server"))]` — they only run when th
## Notes ## Notes
- `rand` + `getrandom` with `js` feature are required for Argon2 salt generation in WASM builds. - `rand` is optional and only enabled by the `server` feature; it is not compiled into the WASM frontend.
- `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — `mut` signals are used in `#[cfg(target_arch = "wasm32")]` blocks stripped in server builds. - `#[allow(unused_mut, unused_variables)]` on `Write` component is intentional — `mut` signals are used in `#[cfg(target_arch = "wasm32")]` blocks stripped in server builds.
- Server uses incremental rendering with 300s cache (`IncrementalRendererConfig` in `src/main.rs`). - Server uses incremental rendering with 300s cache (`IncrementalRendererConfig` in `src/main.rs`).

1
Cargo.lock generated
View File

@ -5309,7 +5309,6 @@ dependencies = [
"deadpool-postgres", "deadpool-postgres",
"dioxus", "dioxus",
"dotenvy", "dotenvy",
"getrandom 0.2.17",
"governor", "governor",
"hex", "hex",
"http", "http",

View File

@ -9,22 +9,21 @@ serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "time", "sync"], optional = true } tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "time", "sync"], optional = true }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"], optional = true } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"], optional = true }
deadpool-postgres = { version = "0.14", optional = true } deadpool-postgres = { version = "0.14", optional = true }
argon2 = "0.5" argon2 = { version = "0.5", optional = true }
uuid = { version = "1", features = ["v4", "js"] } uuid = { version = "1", features = ["v4"], optional = true }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
regex = "1.12" regex = { version = "1.12", optional = true }
pulldown-cmark = "0.13" pulldown-cmark = { version = "0.13", optional = true }
dotenvy = { version = "0.15", optional = true } dotenvy = { version = "0.15", optional = true }
tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] } tracing = { version = "0.1", optional = true, features = ["release_max_level_info"] }
tracing-subscriber = { version = "0.3", optional = true } tracing-subscriber = { version = "0.3", optional = true }
tower-http = { version = "0.6", optional = true } tower-http = { version = "0.6", optional = true }
rand = { version = "0.8", features = ["getrandom"] } rand = { version = "0.8", features = ["getrandom"], optional = true }
getrandom = { version = "0.2", features = ["js"] } http = { version = "1", optional = true }
http = "1"
axum = { version = "0.8", optional = true, features = ["multipart"] } axum = { version = "0.8", optional = true, features = ["multipart"] }
serde_json = "1.0" serde_json = "1.0"
sha2 = "0.10" sha2 = { version = "0.10", optional = true }
hex = "0.4" hex = { version = "0.4", optional = true }
lol_html = { version = "2", optional = true } lol_html = { version = "2", optional = true }
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "default-fancy", "html", "parsing", "dump-load", "yaml-load"], optional = true } syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "default-fancy", "html", "parsing", "dump-load", "yaml-load"], optional = true }
# NOTE: WebP decoder is intentionally excluded from the image crate. # NOTE: WebP decoder is intentionally excluded from the image crate.
@ -60,6 +59,14 @@ server = [
"dep:tokio", "dep:tokio",
"dep:tokio-postgres", "dep:tokio-postgres",
"dep:deadpool-postgres", "dep:deadpool-postgres",
"dep:argon2",
"dep:uuid",
"dep:regex",
"dep:pulldown-cmark",
"dep:rand",
"dep:http",
"dep:sha2",
"dep:hex",
"dep:dotenvy", "dep:dotenvy",
"dep:tracing", "dep:tracing",
"dep:tracing-subscriber", "dep:tracing-subscriber",

View File

@ -23,7 +23,7 @@ use crate::db::pool::get_conn;
use crate::models::user::{User, UserRole}; use crate::models::user::{User, UserRole};
use crate::models::user::PublicUser; use crate::models::user::PublicUser;
#[allow(dead_code)] #[cfg(feature = "server")]
fn validate_username(username: &str) -> Result<(), String> { fn validate_username(username: &str) -> Result<(), String> {
if username.len() < 3 || username.len() > 50 { if username.len() < 3 || username.len() > 50 {
return Err("用户名长度必须在 3-50 字符之间".to_string()); return Err("用户名长度必须在 3-50 字符之间".to_string());
@ -34,7 +34,7 @@ fn validate_username(username: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
#[allow(dead_code)] #[cfg(feature = "server")]
fn validate_email(email: &str) -> Result<(), String> { fn validate_email(email: &str) -> Result<(), String> {
let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
if !re.is_match(email) { if !re.is_match(email) {
@ -43,7 +43,7 @@ fn validate_email(email: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
#[allow(dead_code)] #[cfg(feature = "server")]
fn validate_password(password: &str) -> Result<(), String> { fn validate_password(password: &str) -> Result<(), String> {
if password.len() < 8 { if password.len() < 8 {
return Err("密码长度至少 8 位".to_string()); return Err("密码长度至少 8 位".to_string());
@ -387,7 +387,7 @@ pub async fn get_current_admin_user() -> Result<User, AppError> {
Ok(user) Ok(user)
} }
#[cfg(test)] #[cfg(all(test, feature = "server"))]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -1,7 +1,6 @@
//! 评论模块的辅助函数:数据转换、校验、哈希与头像生成。 //! 评论模块的辅助函数:数据转换、校验、哈希与头像生成。
//! //!
//! 大部分工具函数仅在 `feature = "server"` 启用的服务端构建中使用; //! 所有工具函数仅在 `feature = "server"` 启用的服务端构建中使用。
//! 校验函数同时在前端构建中保留签名,避免编译器提示未使用。
#![allow(clippy::unused_unit, deprecated)] #![allow(clippy::unused_unit, deprecated)]
@ -87,7 +86,7 @@ pub fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
} }
/// 校验评论作者昵称:非空且不超过 50 字符。 /// 校验评论作者昵称:非空且不超过 50 字符。
#[allow(dead_code)] #[cfg(feature = "server")]
pub fn validate_comment_name(name: &str) -> Result<(), String> { pub fn validate_comment_name(name: &str) -> Result<(), String> {
let trimmed = name.trim(); let trimmed = name.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@ -100,7 +99,7 @@ pub fn validate_comment_name(name: &str) -> Result<(), String> {
} }
/// 校验评论作者邮箱格式。 /// 校验评论作者邮箱格式。
#[allow(dead_code)] #[cfg(feature = "server")]
pub fn validate_comment_email(email: &str) -> Result<(), String> { pub fn validate_comment_email(email: &str) -> Result<(), String> {
let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap(); let re = regex::Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
if !re.is_match(email.trim()) { if !re.is_match(email.trim()) {
@ -110,7 +109,7 @@ pub fn validate_comment_email(email: &str) -> Result<(), String> {
} }
/// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头且不超过 200 字符。 /// 校验评论作者网址:为空时允许,非空时必须以 http:// 或 https:// 开头且不超过 200 字符。
#[allow(dead_code)] #[cfg(feature = "server")]
pub fn validate_comment_url(url: &str) -> Result<(), String> { pub fn validate_comment_url(url: &str) -> Result<(), String> {
let trimmed = url.trim(); let trimmed = url.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@ -127,7 +126,7 @@ pub fn validate_comment_url(url: &str) -> Result<(), String> {
} }
/// 校验评论内容:非空且不超过 10000 字符。 /// 校验评论内容:非空且不超过 10000 字符。
#[allow(dead_code)] #[cfg(feature = "server")]
pub fn validate_comment_content(content: &str) -> Result<(), String> { pub fn validate_comment_content(content: &str) -> Result<(), String> {
let trimmed = content.trim(); let trimmed = content.trim();
if trimmed.is_empty() { if trimmed.is_empty() {

View File

@ -3,24 +3,6 @@
use crate::models::comment::{AdminComment, PublicComment}; use crate::models::comment::{AdminComment, PublicComment};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// 创建评论请求体(客户端使用)。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct CreateCommentRequest {
/// 目标文章 id。
pub post_id: i32,
/// 父评论 id顶层评论为 None。
pub parent_id: Option<i64>,
/// 评论者昵称。
pub author_name: String,
/// 评论者邮箱。
pub author_email: String,
/// 评论者个人网址。
pub author_url: Option<String>,
/// 评论 Markdown 原文。
pub content_md: String,
}
/// 创建/审核/删除评论的统一响应结构。 /// 创建/审核/删除评论的统一响应结构。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentResponse { pub struct CommentResponse {
@ -51,6 +33,9 @@ pub struct CommentTreeResponse {
} }
/// 评论计数响应。 /// 评论计数响应。
///
/// 此类型仅在服务端函数体中构造;保留 `#[allow(dead_code)]` 以避免 WASM 构建中
/// 因函数体被剥离而产生的未使用警告。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct CommentCountResponse { pub struct CommentCountResponse {
@ -59,6 +44,9 @@ pub struct CommentCountResponse {
} }
/// 待审核评论列表响应。 /// 待审核评论列表响应。
///
/// 当前前端未直接调用 `get_pending_comments`,此类型仅在服务端函数体中构造;
/// 保留 `#[allow(dead_code)]` 以避免 WASM 构建中因函数体被剥离而产生的未使用警告。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct PendingCommentsResponse { pub struct PendingCommentsResponse {

View File

@ -61,7 +61,7 @@ impl From<AppError> for ServerFnError {
} }
} }
#[cfg(test)] #[cfg(all(test, feature = "server"))]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -2,26 +2,6 @@
use crate::models::post::{Post, PostStats, Tag}; use crate::models::post::{Post, PostStats, Tag};
/// 创建/更新文章请求体(客户端使用)。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[allow(dead_code)]
pub struct CreatePostRequest {
/// 文章标题。
pub title: String,
/// 自定义 slug为空时由标题自动生成。
pub slug: Option<String>,
/// 文章摘要,为空时自动从正文提取。
pub summary: Option<String>,
/// Markdown 格式正文。
pub content_md: String,
/// 文章状态(如 draft / published
pub status: String,
/// 标签列表。
pub tags: Vec<String>,
/// 封面图 URL。
pub cover_image: Option<String>,
}
/// 创建/更新/删除文章的统一响应结构。 /// 创建/更新/删除文章的统一响应结构。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CreatePostResponse { pub struct CreatePostResponse {

View File

@ -1,15 +1,17 @@
//! 密码哈希与校验Argon2 //! 密码哈希与校验Argon2
//! //!
//! 使用随机 salt 生成 PHC 字符串格式哈希,并通过 Argon2 验证。 //! 使用随机 salt 生成 PHC 字符串格式哈希,并通过 Argon2 验证。
//! `#[allow(dead_code)]` 用于避免 WASM 构建中服务端函数体被剥离后的未使用警告 //! 仅在 `feature = "server"` 启用的服务端构建中使用
#[cfg(feature = "server")]
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Argon2,
}; };
#[cfg(feature = "server")]
use rand::rngs::OsRng; use rand::rngs::OsRng;
#[allow(dead_code)] #[cfg(feature = "server")]
/// 使用 Argon2 对明文密码进行哈希。 /// 使用 Argon2 对明文密码进行哈希。
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
@ -18,7 +20,7 @@ pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Er
Ok(password_hash.to_string()) Ok(password_hash.to_string())
} }
#[allow(dead_code)] #[cfg(feature = "server")]
/// 校验明文密码是否与已存储的哈希匹配。 /// 校验明文密码是否与已存储的哈希匹配。
pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> { pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
let parsed_hash = PasswordHash::new(hash)?; let parsed_hash = PasswordHash::new(hash)?;
@ -30,7 +32,7 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::passw
} }
} }
#[cfg(test)] #[cfg(all(test, feature = "server"))]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -4,17 +4,20 @@
//! Cookie 包含 HttpOnly、SameSite=Lax 与可选 Secure 标志。 //! Cookie 包含 HttpOnly、SameSite=Lax 与可选 Secure 标志。
//! 服务端上下文解析函数仅在 `feature = "server"` 时可用。 //! 服务端上下文解析函数仅在 `feature = "server"` 时可用。
#[cfg(feature = "server")]
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
#[cfg(feature = "server")]
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
#[cfg(feature = "server")]
use uuid::Uuid; use uuid::Uuid;
#[allow(dead_code)] #[cfg(feature = "server")]
/// 生成新的随机会话 tokenUUID 格式)。 /// 生成新的随机会话 tokenUUID 格式)。
pub fn generate_token() -> String { pub fn generate_token() -> String {
Uuid::new_v4().to_string() Uuid::new_v4().to_string()
} }
#[allow(dead_code)] #[cfg(feature = "server")]
/// 使用 SHA-256 对 token 进行哈希,用于数据库存储。 /// 使用 SHA-256 对 token 进行哈希,用于数据库存储。
pub fn hash_token(token: &str) -> String { pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
@ -22,7 +25,7 @@ pub fn hash_token(token: &str) -> String {
hex::encode(hasher.finalize()) hex::encode(hasher.finalize())
} }
#[allow(dead_code)] #[cfg(feature = "server")]
/// 返回默认会话过期时间(当前时间 + 30 天)。 /// 返回默认会话过期时间(当前时间 + 30 天)。
pub fn default_expiry() -> DateTime<Utc> { pub fn default_expiry() -> DateTime<Utc> {
Utc::now() + Duration::days(30) Utc::now() + Duration::days(30)

View File

@ -33,7 +33,7 @@ impl CommentStatus {
} }
/// 将 CommentStatus 序列化为小写字符串。 /// 将 CommentStatus 序列化为小写字符串。
#[allow(dead_code)] #[cfg(test)]
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
Self::Pending => "pending", Self::Pending => "pending",
@ -145,6 +145,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
#[cfg(feature = "server")]
fn comment_status_from_str() { fn comment_status_from_str() {
assert_eq!(CommentStatus::from_str("pending"), CommentStatus::Pending); assert_eq!(CommentStatus::from_str("pending"), CommentStatus::Pending);
assert_eq!(CommentStatus::from_str("approved"), CommentStatus::Approved); assert_eq!(CommentStatus::from_str("approved"), CommentStatus::Approved);
@ -153,6 +154,7 @@ mod tests {
} }
#[test] #[test]
#[cfg(feature = "server")]
fn comment_status_from_str_unknown_defaults_to_pending() { fn comment_status_from_str_unknown_defaults_to_pending() {
assert_eq!(CommentStatus::from_str("unknown"), CommentStatus::Pending); assert_eq!(CommentStatus::from_str("unknown"), CommentStatus::Pending);
assert_eq!(CommentStatus::from_str(""), CommentStatus::Pending); assert_eq!(CommentStatus::from_str(""), CommentStatus::Pending);

View File

@ -17,7 +17,6 @@ pub enum PostStatus {
impl PostStatus { impl PostStatus {
/// 将状态序列化为数据库或 API 使用的小写字符串。 /// 将状态序列化为数据库或 API 使用的小写字符串。
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> &'static str {
match self { match self {
PostStatus::Draft => "draft", PostStatus::Draft => "draft",
@ -26,7 +25,7 @@ impl PostStatus {
} }
/// 将字符串解析为 PostStatus无法识别时返回 None。 /// 将字符串解析为 PostStatus无法识别时返回 None。
#[allow(dead_code)] #[cfg(feature = "server")]
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
match s { match s {
"draft" => Some(PostStatus::Draft), "draft" => Some(PostStatus::Draft),
@ -175,6 +174,7 @@ mod tests {
} }
#[test] #[test]
#[cfg(feature = "server")]
fn post_status_from_str() { fn post_status_from_str() {
assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft)); assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
assert_eq!( assert_eq!(
@ -192,6 +192,7 @@ mod tests {
} }
#[test] #[test]
#[cfg(feature = "server")]
fn post_status_roundtrip() { fn post_status_roundtrip() {
for status in [PostStatus::Draft, PostStatus::Published] { for status in [PostStatus::Draft, PostStatus::Published] {
assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone())); assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));

View File

@ -17,7 +17,7 @@ pub enum UserRole {
impl UserRole { impl UserRole {
/// 将数据库中的角色字符串解析为 UserRole无法识别时返回 None。 /// 将数据库中的角色字符串解析为 UserRole无法识别时返回 None。
#[allow(dead_code)] #[cfg(feature = "server")]
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
match s { match s {
"admin" => Some(UserRole::Admin), "admin" => Some(UserRole::Admin),
@ -89,6 +89,7 @@ mod tests {
} }
#[test] #[test]
#[cfg(feature = "server")]
fn user_role_from_str() { fn user_role_from_str() {
assert_eq!(UserRole::from_str("admin"), Some(UserRole::Admin)); assert_eq!(UserRole::from_str("admin"), Some(UserRole::Admin));
assert_eq!(UserRole::from_str("blocked"), Some(UserRole::Blocked)); assert_eq!(UserRole::from_str("blocked"), Some(UserRole::Blocked));

View File

@ -8,7 +8,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
/// localStorage 中存储主题值的键名。 /// localStorage 中存储主题值的键名。
#[allow(dead_code)] #[cfg(any(target_arch = "wasm32", test))]
const THEME_KEY: &str = "yggdrasil-theme"; const THEME_KEY: &str = "yggdrasil-theme";
/// 应用主题枚举。 /// 应用主题枚举。

View File

@ -78,7 +78,7 @@ pub fn auto_summary(md: &str) -> String {
plain.chars().take(200).collect() plain.chars().take(200).collect()
} }
#[cfg(test)] #[cfg(all(test, feature = "server"))]
mod tests { mod tests {
use super::*; use super::*;