use crate::options::{
CodeBlockStyle, ConversionOptions, ConversionOptionsUpdate, HeadingStyle, HighlightStyle, LinkStyle,
ListIndentType, NewlineStyle, OutputFormat, PreprocessingOptionsUpdate, PreprocessingPreset, TierStrategy,
UrlEscapeStyle, WhitespaceMode,
};
use rmcp::schemars;
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Default, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ConvertConfig {
pub heading_style: Option<String>,
pub list_indent_type: Option<String>,
pub list_indent_width: Option<usize>,
pub bullets: Option<String>,
pub strong_em_symbol: Option<String>,
pub escape_asterisks: Option<bool>,
pub escape_underscores: Option<bool>,
pub escape_misc: Option<bool>,
pub escape_ascii: Option<bool>,
pub code_language: Option<String>,
pub autolinks: Option<bool>,
pub default_title: Option<bool>,
pub br_in_tables: Option<bool>,
pub compact_tables: Option<bool>,
pub highlight_style: Option<String>,
pub extract_metadata: Option<bool>,
pub whitespace_mode: Option<String>,
pub strip_newlines: Option<bool>,
pub wrap: Option<bool>,
pub wrap_width: Option<usize>,
pub convert_as_inline: Option<bool>,
pub sub_symbol: Option<String>,
pub sup_symbol: Option<String>,
pub newline_style: Option<String>,
pub code_block_style: Option<String>,
pub keep_inline_images_in: Option<Vec<String>>,
pub preprocessing: Option<PreprocessingParams>,
pub encoding: Option<String>,
pub debug: Option<bool>,
pub strip_tags: Option<Vec<String>>,
pub preserve_tags: Option<Vec<String>>,
pub skip_images: Option<bool>,
pub url_escape_style: Option<String>,
pub link_style: Option<String>,
pub output_format: Option<String>,
pub include_document_structure: Option<bool>,
pub extract_images: Option<bool>,
pub max_image_size: Option<u64>,
pub capture_svg: Option<bool>,
pub infer_dimensions: Option<bool>,
pub max_depth: Option<u64>,
pub exclude_selectors: Option<Vec<String>>,
pub tier_strategy: Option<String>,
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Default, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct PreprocessingParams {
pub enabled: Option<bool>,
pub preset: Option<String>,
pub remove_navigation: Option<bool>,
pub remove_forms: Option<bool>,
}
impl From<PreprocessingParams> for PreprocessingOptionsUpdate {
fn from(params: PreprocessingParams) -> Self {
Self {
enabled: params.enabled,
preset: params.preset.map(|s| PreprocessingPreset::parse(&s)),
remove_navigation: params.remove_navigation,
remove_forms: params.remove_forms,
}
}
}
impl From<ConvertConfig> for ConversionOptionsUpdate {
fn from(config: ConvertConfig) -> Self {
Self {
heading_style: config.heading_style.map(|s| HeadingStyle::parse(&s)),
list_indent_type: config.list_indent_type.map(|s| ListIndentType::parse(&s)),
list_indent_width: config.list_indent_width,
bullets: config.bullets,
strong_em_symbol: config.strong_em_symbol.and_then(|s| s.chars().next()),
escape_asterisks: config.escape_asterisks,
escape_underscores: config.escape_underscores,
escape_misc: config.escape_misc,
escape_ascii: config.escape_ascii,
code_language: config.code_language,
autolinks: config.autolinks,
default_title: config.default_title,
br_in_tables: config.br_in_tables,
compact_tables: config.compact_tables,
highlight_style: config.highlight_style.map(|s| HighlightStyle::parse(&s)),
extract_metadata: config.extract_metadata,
whitespace_mode: config.whitespace_mode.map(|s| WhitespaceMode::parse(&s)),
strip_newlines: config.strip_newlines,
wrap: config.wrap,
wrap_width: config.wrap_width,
convert_as_inline: config.convert_as_inline,
sub_symbol: config.sub_symbol,
sup_symbol: config.sup_symbol,
newline_style: config.newline_style.map(|s| NewlineStyle::parse(&s)),
code_block_style: config.code_block_style.map(|s| CodeBlockStyle::parse(&s)),
keep_inline_images_in: config.keep_inline_images_in,
preprocessing: config.preprocessing.map(Into::into),
encoding: config.encoding,
debug: config.debug,
strip_tags: config.strip_tags,
preserve_tags: config.preserve_tags,
skip_images: config.skip_images,
url_escape_style: config.url_escape_style.map(|s| UrlEscapeStyle::parse(&s)),
link_style: config.link_style.map(|s| LinkStyle::parse(&s)),
output_format: config.output_format.map(|s| OutputFormat::parse(&s)),
include_document_structure: config.include_document_structure,
extract_images: config.extract_images,
max_image_size: config.max_image_size,
capture_svg: config.capture_svg,
infer_dimensions: config.infer_dimensions,
max_depth: config.max_depth.map(|d| Some(usize::try_from(d).unwrap_or(usize::MAX))),
exclude_selectors: config.exclude_selectors,
tier_strategy: config.tier_strategy.map(|s| parse_tier_strategy(&s)),
#[cfg(feature = "visitor")]
visitor: None,
}
}
}
impl From<ConvertConfig> for ConversionOptions {
fn from(config: ConvertConfig) -> Self {
Self::from_update(config.into())
}
}
fn parse_tier_strategy(value: &str) -> TierStrategy {
match crate::options::validation::normalize_token(value).as_str() {
"tier2" => TierStrategy::Tier2,
_ => TierStrategy::Auto,
}
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ConvertHtmlParams {
pub html: String,
#[serde(default)]
pub config: Option<ConvertConfig>,
#[serde(default)]
pub json: bool,
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExtractMetadataParams {
pub html: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn json_keys(value: &serde_json::Value) -> BTreeSet<String> {
value
.as_object()
.expect("expected a JSON object")
.keys()
.cloned()
.collect()
}
#[test]
fn mirror_covers_all_core_fields() {
let core = serde_json::to_value(ConversionOptions::default()).unwrap();
let mirror = serde_json::to_value(ConvertConfig::default()).unwrap();
assert_eq!(
json_keys(&core),
json_keys(&mirror),
"ConvertConfig drifted from ConversionOptions — add/remove the mismatched field(s)"
);
}
#[test]
fn preprocessing_mirror_covers_all_core_fields() {
use crate::options::PreprocessingOptions;
let core = serde_json::to_value(PreprocessingOptions::default()).unwrap();
let mirror = serde_json::to_value(PreprocessingParams::default()).unwrap();
assert_eq!(json_keys(&core), json_keys(&mirror));
}
#[test]
fn test_convert_html_params_minimal() {
let params: ConvertHtmlParams = serde_json::from_str(r#"{"html": "<h1>Hi</h1>"}"#).unwrap();
assert_eq!(params.html, "<h1>Hi</h1>");
assert!(params.config.is_none());
assert!(!params.json);
}
#[test]
fn test_enum_strings_parse_into_typed_options() {
let config = ConvertConfig {
heading_style: Some("atxclosed".into()),
output_format: Some("djot".into()),
code_block_style: Some("tildes".into()),
..ConvertConfig::default()
};
let opts: ConversionOptions = config.into();
assert_eq!(opts.heading_style, HeadingStyle::AtxClosed);
assert_eq!(opts.output_format, OutputFormat::Djot);
assert_eq!(opts.code_block_style, CodeBlockStyle::Tildes);
}
#[test]
fn test_unknown_enum_string_falls_back_to_default() {
let config = ConvertConfig {
heading_style: Some("nonsense".into()),
..ConvertConfig::default()
};
let opts: ConversionOptions = config.into();
assert_eq!(opts.heading_style, HeadingStyle::Underlined);
}
#[test]
fn test_partial_config_leaves_other_fields_at_default() {
let config = ConvertConfig {
wrap: Some(true),
wrap_width: Some(100),
..ConvertConfig::default()
};
let opts: ConversionOptions = config.into();
assert!(opts.wrap);
assert_eq!(opts.wrap_width, 100);
assert!(opts.autolinks);
assert_eq!(opts.bullets, "-*+");
assert_eq!(opts.heading_style, HeadingStyle::Atx);
}
#[test]
fn test_strong_em_symbol_takes_first_char() {
let config = ConvertConfig {
strong_em_symbol: Some("_".into()),
..ConvertConfig::default()
};
let opts: ConversionOptions = config.into();
assert_eq!(opts.strong_em_symbol, '_');
}
#[test]
fn test_max_depth_maps_through() {
let config = ConvertConfig {
max_depth: Some(5),
..ConvertConfig::default()
};
let opts: ConversionOptions = config.into();
assert_eq!(opts.max_depth, Some(5));
}
#[test]
fn test_preprocessing_nested_config() {
let config = ConvertConfig {
preprocessing: Some(PreprocessingParams {
preset: Some("aggressive".into()),
remove_forms: Some(false),
..PreprocessingParams::default()
}),
..ConvertConfig::default()
};
let opts: ConversionOptions = config.into();
assert_eq!(opts.preprocessing.preset, PreprocessingPreset::Aggressive);
assert!(!opts.preprocessing.remove_forms);
assert!(opts.preprocessing.enabled);
assert!(opts.preprocessing.remove_navigation);
}
#[test]
fn test_tier_strategy_parses() {
let config = ConvertConfig {
tier_strategy: Some("tier2".into()),
..ConvertConfig::default()
};
let opts: ConversionOptions = config.into();
assert_eq!(opts.tier_strategy, TierStrategy::Tier2);
}
#[test]
fn test_unknown_top_level_field_is_rejected() {
let result: Result<ConvertConfig, _> = serde_json::from_str(r#"{"unknown_field_xyz": true}"#);
assert!(result.is_err());
}
#[test]
fn test_schema_generation_exposes_config_fields() {
let schema = schemars::schema_for!(ConvertHtmlParams);
let json = serde_json::to_value(&schema).unwrap();
let text = serde_json::to_string(&json).unwrap();
assert!(text.contains("heading_style"), "schema must expose heading_style");
assert!(text.contains("output_format"), "schema must expose output_format");
assert!(text.contains("preprocessing"), "schema must expose preprocessing");
}
}