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>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidEnumValue {
pub field: &'static str,
pub value: String,
pub accepted: &'static [&'static str],
}
impl std::fmt::Display for InvalidEnumValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid value \"{}\" for `{}`: expected one of {}",
self.value,
self.field,
self.accepted.join(", ")
)
}
}
impl std::error::Error for InvalidEnumValue {}
const HEADING_STYLE_VALUES: &[&str] = &["atx", "atxclosed", "underlined"];
const LIST_INDENT_TYPE_VALUES: &[&str] = &["spaces", "tabs"];
const HIGHLIGHT_STYLE_VALUES: &[&str] = &["doubleequal", "html", "bold", "none"];
const WHITESPACE_MODE_VALUES: &[&str] = &["normalized", "strict"];
const NEWLINE_STYLE_VALUES: &[&str] = &["spaces", "backslash"];
const CODE_BLOCK_STYLE_VALUES: &[&str] = &["indented", "backticks", "tildes"];
const URL_ESCAPE_STYLE_VALUES: &[&str] = &["angle", "percent"];
const LINK_STYLE_VALUES: &[&str] = &["inline", "reference"];
const OUTPUT_FORMAT_VALUES: &[&str] = &["markdown", "djot", "plain", "plaintext", "text"];
const TIER_STRATEGY_VALUES: &[&str] = &["auto", "tier2"];
const PREPROCESSING_PRESET_VALUES: &[&str] = &["minimal", "standard", "aggressive"];
fn validate_enum_value(field: &'static str, raw: &str, accepted: &'static [&str]) -> Result<(), InvalidEnumValue> {
let normalized = crate::options::validation::normalize_token(raw);
if accepted.contains(&normalized.as_str()) {
Ok(())
} else {
Err(InvalidEnumValue {
field,
value: raw.to_string(),
accepted,
})
}
}
fn validated_enum<T>(
field: &'static str,
raw: Option<String>,
accepted: &'static [&str],
parser: impl FnOnce(&str) -> T,
) -> Result<Option<T>, InvalidEnumValue> {
match raw {
Some(raw) => {
validate_enum_value(field, &raw, accepted)?;
Ok(Some(parser(&raw)))
}
None => Ok(None),
}
}
impl TryFrom<PreprocessingParams> for PreprocessingOptionsUpdate {
type Error = InvalidEnumValue;
fn try_from(params: PreprocessingParams) -> Result<Self, Self::Error> {
Ok(Self {
enabled: params.enabled,
preset: validated_enum(
"preprocessing.preset",
params.preset,
PREPROCESSING_PRESET_VALUES,
PreprocessingPreset::parse,
)?,
remove_navigation: params.remove_navigation,
remove_forms: params.remove_forms,
})
}
}
impl TryFrom<ConvertConfig> for ConversionOptionsUpdate {
type Error = InvalidEnumValue;
fn try_from(config: ConvertConfig) -> Result<Self, Self::Error> {
let preprocessing = config.preprocessing.map(TryInto::try_into).transpose()?;
Ok(Self {
heading_style: validated_enum(
"heading_style",
config.heading_style,
HEADING_STYLE_VALUES,
HeadingStyle::parse,
)?,
list_indent_type: validated_enum(
"list_indent_type",
config.list_indent_type,
LIST_INDENT_TYPE_VALUES,
ListIndentType::parse,
)?,
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: validated_enum(
"highlight_style",
config.highlight_style,
HIGHLIGHT_STYLE_VALUES,
HighlightStyle::parse,
)?,
extract_metadata: config.extract_metadata,
whitespace_mode: validated_enum(
"whitespace_mode",
config.whitespace_mode,
WHITESPACE_MODE_VALUES,
WhitespaceMode::parse,
)?,
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: validated_enum(
"newline_style",
config.newline_style,
NEWLINE_STYLE_VALUES,
NewlineStyle::parse,
)?,
code_block_style: validated_enum(
"code_block_style",
config.code_block_style,
CODE_BLOCK_STYLE_VALUES,
CodeBlockStyle::parse,
)?,
keep_inline_images_in: config.keep_inline_images_in,
preprocessing,
encoding: config.encoding,
debug: config.debug,
strip_tags: config.strip_tags,
preserve_tags: config.preserve_tags,
skip_images: config.skip_images,
url_escape_style: validated_enum(
"url_escape_style",
config.url_escape_style,
URL_ESCAPE_STYLE_VALUES,
UrlEscapeStyle::parse,
)?,
link_style: validated_enum("link_style", config.link_style, LINK_STYLE_VALUES, LinkStyle::parse)?,
output_format: validated_enum(
"output_format",
config.output_format,
OUTPUT_FORMAT_VALUES,
OutputFormat::parse,
)?,
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(|requested| Some(clamp_max_depth(requested))),
exclude_selectors: config.exclude_selectors,
tier_strategy: validated_enum(
"tier_strategy",
config.tier_strategy,
TIER_STRATEGY_VALUES,
parse_tier_strategy,
)?,
#[cfg(feature = "visitor")]
visitor: None,
})
}
}
impl TryFrom<ConvertConfig> for ConversionOptions {
type Error = InvalidEnumValue;
fn try_from(config: ConvertConfig) -> Result<Self, Self::Error> {
Ok(Self::from_update(config.try_into()?))
}
}
fn clamp_max_depth(requested: u64) -> usize {
if let Ok(depth) = usize::try_from(requested) {
depth
} else {
tracing::warn!(
target: "html_to_markdown::mcp",
requested,
clamped_value = usize::MAX,
"max_depth exceeds this platform's usize range; clamping to usize::MAX"
);
usize::MAX
}
}
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.try_into().expect("all values are accepted");
assert_eq!(opts.heading_style, HeadingStyle::AtxClosed);
assert_eq!(opts.output_format, OutputFormat::Djot);
assert_eq!(opts.code_block_style, CodeBlockStyle::Tildes);
}
#[test]
fn should_reject_unknown_enum_string_instead_of_substituting_a_default() {
let config = ConvertConfig {
heading_style: Some("nonsense".into()),
..ConvertConfig::default()
};
let error = ConversionOptions::try_from(config).expect_err("unrecognized value must be rejected");
assert_eq!(error.field, "heading_style");
assert_eq!(error.value, "nonsense");
assert_eq!(error.accepted, HEADING_STYLE_VALUES);
}
#[test]
fn should_report_the_offending_field_and_accepted_values_in_the_message() {
let config = ConvertConfig {
output_format: Some("yaml".into()),
..ConvertConfig::default()
};
let error = ConversionOptions::try_from(config).expect_err("unrecognized value must be rejected");
let message = error.to_string();
assert!(
message.contains("output_format"),
"message must name the field: {message}"
);
assert!(
message.contains("yaml"),
"message must echo the offending value: {message}"
);
assert!(
message.contains("markdown"),
"message must list accepted values: {message}"
);
}
#[test]
fn should_reject_unknown_nested_preprocessing_preset() {
let config = ConvertConfig {
preprocessing: Some(PreprocessingParams {
preset: Some("extreme".into()),
..PreprocessingParams::default()
}),
..ConvertConfig::default()
};
let error = ConversionOptions::try_from(config).expect_err("unrecognized preset must be rejected");
assert_eq!(error.field, "preprocessing.preset");
assert_eq!(error.value, "extreme");
}
#[test]
fn should_reject_unknown_tier_strategy() {
let config = ConvertConfig {
tier_strategy: Some("tier3".into()),
..ConvertConfig::default()
};
let error = ConversionOptions::try_from(config).expect_err("unrecognized tier strategy must be rejected");
assert_eq!(error.field, "tier_strategy");
}
#[test]
fn should_accept_every_documented_heading_style_value() {
for value in HEADING_STYLE_VALUES {
let config = ConvertConfig {
heading_style: Some((*value).to_string()),
..ConvertConfig::default()
};
assert!(
ConversionOptions::try_from(config).is_ok(),
"documented value {value:?} must be accepted"
);
}
}
#[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.try_into().expect("no enum fields set");
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.try_into().expect("no enum fields set");
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.try_into().expect("no enum fields set");
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.try_into().expect("\"aggressive\" is accepted");
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.try_into().expect("\"tier2\" is accepted");
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");
}
}