diff --git a/.kanon-lint-ignore b/.kanon-lint-ignore index 1432afcd..64c13ded 100644 --- a/.kanon-lint-ignore +++ b/.kanon-lint-ignore @@ -397,12 +397,6 @@ RUST/box-dyn-error:crates/paroche/src/routes/request.rs # touches all callers in approval.rs. Tracked in issue #301. RUST/validate-returns-unit:crates/aitesis/src/workflow.rs -# WHY: validate_row_scoped returns Result<(), _> which triggers -# validate-returns-unit. The honest fix is lifecycle-wide — parse each -# template once at definition load and store the ASTs — not a signature -# tweak whose result every caller discards. Tracked in issue #696. -RUST/validate-returns-unit:crates/eksetasis/src/client/cardigann/template.rs - # WHY: the retry-after tests assert on tokio::time::Instant under an # explicitly paused clock (tokio::time::pause()) — the measured "elapsed" # is virtual time, exact and deterministic, never wall latency. The rule's diff --git a/crates/eksetasis/src/client/cardigann/definition.rs b/crates/eksetasis/src/client/cardigann/definition.rs index bf7324a8..8d5f75b5 100644 --- a/crates/eksetasis/src/client/cardigann/definition.rs +++ b/crates/eksetasis/src/client/cardigann/definition.rs @@ -6,7 +6,8 @@ use std::path::Path; use serde::Deserialize; use tracing::warn; -use crate::client::cardigann::{filters, template}; +use crate::client::cardigann::filters; +use crate::client::cardigann::template::ParsedTemplate; use crate::error::SearchIndexerError; mod yaml; @@ -18,8 +19,14 @@ pub use yaml::{FilterArgs, OrderedFields, OrderedPairs, ScalarString}; /// Unknown YAML keys are ignored on purpose: real-world definitions carry /// many blocks this engine does not model, and a permissive schema keeps a /// definition loadable as long as the parts this engine executes are sound. +/// +/// The schema is generic over the template representation `T` +/// (parse-don't-validate, #696): [`ScalarString`] as deserialized, +/// [`ParsedTemplate`] once [`compile_templates`] has parsed every template +/// exactly once at load. Non-template fields stay concrete in both forms. #[derive(Debug, Clone, Deserialize)] -pub struct CardigannDefinition { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct CardigannDefinition { pub id: String, // kanon:ignore RUST/primitive-for-domain-id -- wire DTO mirroring the external Cardigann YAML schema pub name: String, #[serde(default)] @@ -38,12 +45,21 @@ pub struct CardigannDefinition { #[serde(default)] pub settings: Vec, #[serde(default)] - pub login: Option, - pub search: SearchBlock, + pub login: Option>, + pub search: SearchBlock, #[serde(default)] - pub download: Option, + pub download: Option>, } +/// A definition as deserialized from YAML: every template position holds its +/// raw scalar text. Only definition loading consumes this form. +pub type RawDefinition = CardigannDefinition; + +/// A definition past load: every template position holds a [`ParsedTemplate`] +/// produced exactly once, at load. The render path consumes this form — +/// nothing re-parses per search. +pub type CompiledDefinition = CardigannDefinition; + #[derive(Debug, Clone, Deserialize)] pub struct CapsBlock { #[serde(default)] @@ -83,23 +99,24 @@ pub struct SettingsField { } #[derive(Debug, Clone, Deserialize)] -pub struct LoginBlock { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct LoginBlock { #[serde(default)] pub method: Option, #[serde(default)] - pub path: Option, + pub path: Option, /// CSS selector for the login `
` (form method; defaults to "form"). #[serde(default)] pub form: Option, /// Submit-target override, joined on the site base instead of the /// form's `action`. #[serde(default)] - pub submitpath: Option, + pub submitpath: Option, #[serde(default)] - pub inputs: BTreeMap, + pub inputs: BTreeMap, /// Failed-login detectors checked against the post-submit page. #[serde(default)] - pub error: Vec, + pub error: Vec>, #[serde(default)] pub test: Option, /// Unmodeled selector-driven inputs; presence is rejected at load for @@ -115,10 +132,11 @@ pub struct LoginBlock { /// One failed-login detector: `selector` marks the post-submit page as a /// login error; `message` (FieldBlock semantics) refines the reported text. #[derive(Debug, Clone, Deserialize)] -pub struct ErrorBlock { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct ErrorBlock { pub selector: String, #[serde(default)] - pub message: Option, + pub message: Option>, } #[derive(Debug, Clone, Deserialize)] @@ -130,24 +148,26 @@ pub struct LoginTest { } #[derive(Debug, Clone, Deserialize)] -pub struct SearchBlock { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct SearchBlock { #[serde(default)] - pub paths: Vec, + pub paths: Vec>, /// Legacy single-path form. Folded into `paths` by [`normalize`]. #[serde(default)] - pub path: Option, + pub path: Option, #[serde(default)] - pub inputs: BTreeMap, + pub inputs: BTreeMap, #[serde(default)] - pub keywordsfilters: Vec, - pub rows: RowsBlock, + pub keywordsfilters: Vec>, + pub rows: RowsBlock, #[serde(default)] - pub fields: OrderedFields, + pub fields: OrderedFields, } #[derive(Debug, Clone, Deserialize)] -pub struct SearchPath { - pub path: String, +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct SearchPath { + pub path: T, #[serde(default)] pub method: Option, /// Site category ids this path is limited to (empty = all queries). @@ -155,7 +175,7 @@ pub struct SearchPath { pub categories: Vec, /// Extra inputs merged over `search.inputs` for this path. #[serde(default)] - pub inputs: BTreeMap, + pub inputs: BTreeMap, #[serde(default)] pub response: Option, } @@ -167,16 +187,17 @@ pub struct ResponseBlock { } #[derive(Debug, Clone, Deserialize)] -pub struct RowsBlock { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct RowsBlock { pub selector: String, #[serde(default)] - pub filters: Vec, + pub filters: Vec>, #[serde(default)] pub after: Option, #[serde(default)] pub remove: Option, #[serde(default)] - pub dateheaders: Option, + pub dateheaders: Option>, /// JSON nested-row drill-down: a path into each parent row yielding the /// sub-row(s). Combined with `multiple`, the attribute resolves to an array. #[serde(default)] @@ -191,24 +212,25 @@ pub struct RowsBlock { pub missing_attribute_equals_no_results: bool, /// JSON advisory pre-count selector; parsed but not executed. #[serde(default)] - pub count: Option, + pub count: Option>, } #[derive(Debug, Clone, Deserialize)] -pub struct FieldBlock { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct FieldBlock { #[serde(default)] pub selector: Option, #[serde(default)] pub attribute: Option, /// Constant/template value used instead of selecting from the row. #[serde(default)] - pub text: Option, + pub text: Option, /// Fallback template rendered (row scope, like `text`) when selector /// extraction yields nothing (upstream `FieldBlock.Default`). #[serde(default)] - pub default: Option, + pub default: Option, #[serde(default)] - pub filters: Vec, + pub filters: Vec>, #[serde(default)] pub optional: bool, /// Selector → value pairs; the first selector matching the selected @@ -217,20 +239,21 @@ pub struct FieldBlock { /// WHY: stored as ordered pairs, not a map — first-match-wins semantics /// depend on the author's YAML order. #[serde(default)] - pub case: Option, + pub case: Option>, /// Selector for descendants to exclude from text extraction. #[serde(default)] pub remove: Option, } #[derive(Debug, Clone, Deserialize)] -pub struct DownloadBlock { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct DownloadBlock { #[serde(default)] pub selector: Option, #[serde(default)] pub attribute: Option, #[serde(default)] - pub filters: Vec, + pub filters: Vec>, #[serde(default)] pub method: Option, /// Unmodeled pre-request block; presence is detected and deferred. @@ -242,20 +265,21 @@ pub struct DownloadBlock { } #[derive(Debug, Clone, Deserialize)] -pub struct FilterSpec { +#[serde(bound(deserialize = "T: Deserialize<'de> + From"))] +pub struct FilterSpec { pub name: String, #[serde(default)] - pub args: Option, + pub args: Option>, } -impl FilterSpec { - pub fn args(&self) -> &[String] { +impl FilterSpec { + pub fn args(&self) -> &[T] { self.args.as_ref().map_or(&[], |a| a.0.as_slice()) } } /// Parses, normalizes, and validates one definition file. -pub fn load_definition_file(path: &Path) -> Result { +pub fn load_definition_file(path: &Path) -> Result { let display_path = path.display().to_string(); let text = std::fs::read_to_string(path).map_err(|e| SearchIndexerError::DefinitionLoad { path: display_path.clone(), @@ -266,12 +290,13 @@ pub fn load_definition_file(path: &Path) -> Result Result { - let mut definition: CardigannDefinition = +) -> Result { + let mut definition: RawDefinition = serde_norway::from_str(text).map_err(|e| SearchIndexerError::DefinitionLoad { path: origin.to_string(), reason: e.to_string(), @@ -279,11 +304,11 @@ pub fn parse_definition( })?; normalize(&mut definition); validate(&definition)?; - Ok(definition) + compile_templates(definition) } /// Folds legacy schema forms into their modern equivalents. -fn normalize(def: &mut CardigannDefinition) { +fn normalize>(def: &mut CardigannDefinition) { if def.search.paths.is_empty() && let Some(path) = def.search.path.take() { @@ -317,7 +342,7 @@ fn normalize(def: &mut CardigannDefinition) { if path.categories.iter().any(|c| c.0.starts_with('!')) { warn!( definition_id = %def.id, - path = %path.path, + path = %path.path.as_ref(), "negated path categories are not supported; treating path as unconstrained" ); path.categories.clear(); @@ -331,7 +356,11 @@ fn normalize(def: &mut CardigannDefinition) { /// one clear reason, not degrade into per-query noise. Blocks that only add /// information (rows post-filters, date headers) are warned and ignored /// instead — skipping them yields a superset of rows, never wrong values. -fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { +/// +/// Template syntax and scope are NOT checked here: [`compile_templates`] +/// runs next and parses every template position once, which is where those +/// errors surface (parse-don't-validate, #696). +fn validate(def: &RawDefinition) -> Result<(), SearchIndexerError> { let invalid = |reason: String| SearchIndexerError::DefinitionInvalid { definition_id: def.id.clone(), reason, @@ -359,36 +388,6 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { return Err(invalid("no search paths".to_string())); } - let config_keys: Vec<&str> = def.settings.iter().map(|s| s.name.as_str()).collect(); - let field_names: Vec = def.search.fields.names(); - let check_template = |what: &str, tmpl: &str| { - template::validate(tmpl, &config_keys).map_err(|e| invalid(format!("{what}: {e}"))) - }; - // Row scope: `.Result.` is meaningful only where the extractor - // renders with the row's accumulated values — field text/case/default - // values and field filter args. - let check_row_template = |what: &str, tmpl: &str| { - template::validate_row_scoped(tmpl, &config_keys, &field_names) - .map_err(|e| invalid(format!("{what}: {e}"))) - }; - // WHY: filter args are templates too — validate them so an unsupported - // construct fails at load instead of reaching the pipeline verbatim. - let check_filter_args = - |what: &str, specs: &[FilterSpec], row_scoped: bool| -> Result<(), SearchIndexerError> { - for spec in specs { - for (index, arg) in spec.args().iter().enumerate() { - let checked = if row_scoped { - template::validate_row_scoped(arg, &config_keys, &field_names) - } else { - template::validate(arg, &config_keys) - }; - checked.map_err(|e| { - invalid(format!("{what} filter {:?} arg {index}: {e}", spec.name)) - })?; - } - } - Ok(()) - }; let check_selector = |what: &str, sel: &str| { scraper::Selector::parse(sel) .map(|_| ()) @@ -464,16 +463,8 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { // meaning. Reject rather than silently drop it (fail-loud). return Err(unsupported("$raw input with POST search".to_string())); } - check_template("search path", &path.path)?; - for (key, value) in &path.inputs { - check_template(&format!("search path input {key}"), &value.0)?; - } - } - for (key, value) in &def.search.inputs { - check_template(&format!("search input {key}"), &value.0)?; } check_filters("keywordsfilters", &def.search.keywordsfilters)?; - check_filter_args("keywordsfilters", &def.search.keywordsfilters, false)?; if is_json { if def.search.rows.remove.is_some() { @@ -512,7 +503,6 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { if !def.search.rows.filters.is_empty() { filters::parse_row_filters(&def.search.rows.filters) .map_err(|e| invalid(format!("rows.filters: {e}")))?; - check_filter_args("rows.filters", &def.search.rows.filters, false)?; } if def.search.rows.after.is_some() || def.search.rows.dateheaders.is_some() { warn!( @@ -570,19 +560,7 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { } } } - if let Some(text) = &field.text { - check_row_template(&format!("field {name} text"), &text.0)?; - } - if let Some(default) = &field.default { - check_row_template(&format!("field {name} default"), &default.0)?; - } - if let Some(case) = &field.case { - for (_, case_value) in &case.0 { - check_row_template(&format!("field {name} case value"), &case_value.0)?; - } - } check_filters(&format!("field {name}"), &field.filters)?; - check_filter_args(&format!("field {name}"), &field.filters, true)?; } if let Some(download) = &def.download { @@ -590,7 +568,6 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { check_selector("download", sel)?; } check_filters("download", &download.filters)?; - check_filter_args("download", &download.filters, false)?; match download.method.as_deref() { None | Some("get") => {} Some(other) => return Err(unsupported(format!("download method {other:?}"))), @@ -611,20 +588,16 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { // NOTE: login blocks are only validated for methods this engine runs // (none, cookie, form, post, get; an omitted method defaults to "form"). - // Unknown methods fail at client construction with LoginUnsupported, and - // their inputs routinely use template constructs outside this subset. + // Unknown methods fail at client construction with LoginUnsupported. if let Some(login) = &def.login { let method = login.method.as_deref().unwrap_or("form"); let interactive = matches!(method, "form" | "post" | "get"); - if interactive || matches!(method, "none" | "cookie") { - for (key, value) in &login.inputs { - check_template(&format!("login input {key}"), &value.0)?; - } - if let Some(test) = &login.test - && let Some(sel) = &test.selector - { - check_selector("login test", sel)?; - } + let known_method = interactive || matches!(method, "none" | "cookie"); + if known_method + && let Some(test) = &login.test + && let Some(sel) = &test.selector + { + check_selector("login test", sel)?; } if interactive { // WHY: these blocks change what a login submits — silently @@ -636,18 +609,14 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { if login.captcha.is_some() { return Err(unsupported("login.captcha".to_string())); } - let Some(path) = &login.path else { + if login.path.is_none() { return Err(invalid(format!( "login method {method:?} requires login.path" ))); - }; - check_template("login path", path)?; + } if let Some(form) = &login.form { check_selector("login form", form)?; } - if let Some(submitpath) = &login.submitpath { - check_template("login submitpath", submitpath)?; - } for (index, block) in login.error.iter().enumerate() { let what = format!("login error {index}"); check_selector(&what, &block.selector)?; @@ -659,16 +628,11 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { check_selector(&format!("{what} message remove"), remove)?; } if let Some(case) = &message.case { - for (case_selector, case_value) in &case.0 { + for (case_selector, _) in &case.0 { check_selector(&format!("{what} message case"), case_selector)?; - check_template(&format!("{what} message case value"), &case_value.0)?; } } - if let Some(text) = &message.text { - check_template(&format!("{what} message text"), &text.0)?; - } check_filters(&format!("{what} message"), &message.filters)?; - check_filter_args(&format!("{what} message"), &message.filters, false)?; } } } @@ -677,5 +641,305 @@ fn validate(def: &CardigannDefinition) -> Result<(), SearchIndexerError> { Ok(()) } +/// Parses every template in a raw definition exactly once, producing the +/// compiled form the render path consumes (parse-don't-validate, #696). +/// +/// The parse IS the load-time template check the pre-#696 `validate` / +/// `validate_row_scoped` pair performed and discarded: unsupported +/// constructs, unknown config keys, and out-of-scope `.Result` references +/// fail here with the same messages, and the parsed AST is what the +/// definition stores — an invalid template is unrepresentable past load. +/// +/// SCOPE NOTE: login template positions are parsed whenever present, +/// regardless of the declared login method. The pre-#696 validators skipped +/// them for methods this engine does not run; those definitions fail at +/// client construction regardless, so the check surfacing at load instead +/// can only affect a definition that was never usable. +fn compile_templates(def: RawDefinition) -> Result { + fn invalid(definition_id: &str, reason: String) -> SearchIndexerError { + SearchIndexerError::DefinitionInvalid { + definition_id: definition_id.to_string(), + reason, + location: std::panic::Location::caller(), + } + } + + let id = def.id.clone(); + // WHY owned: `config_keys` borrows must not pin `def` — the rebuild below + // moves every field out of it. + let config_keys_owned: Vec = def.settings.iter().map(|s| s.name.clone()).collect(); + let config_keys: Vec<&str> = config_keys_owned.iter().map(String::as_str).collect(); + let field_names: Vec = def.search.fields.names(); + + let parse = |what: &str, raw: &str| { + ParsedTemplate::parse(raw, &config_keys).map_err(|e| invalid(&id, format!("{what}: {e}"))) + }; + // Row scope: `.Result.` is meaningful only where the extractor + // renders with the row's accumulated values — field text/case/default + // values and field filter args. + let parse_row = |what: &str, raw: &str| { + ParsedTemplate::parse_row_scoped(raw, &config_keys, &field_names) + .map_err(|e| invalid(&id, format!("{what}: {e}"))) + }; + // WHY: filter args are templates too — parse them so an unsupported + // construct fails at load instead of reaching the pipeline verbatim. + let compile_specs = |what: &str, + specs: Vec, + row_scoped: bool| + -> Result>, SearchIndexerError> { + let parse_arg: &dyn Fn(&str, &str) -> Result = + if row_scoped { &parse_row } else { &parse }; + let mut out = Vec::with_capacity(specs.len()); + for spec in specs { + let args = spec + .args + .map(|args| { + args.0 + .into_iter() + .enumerate() + .map(|(index, arg)| { + parse_arg( + &format!("{what} filter {:?} arg {index}", spec.name), + arg.as_ref(), + ) + }) + .collect::, _>>() + }) + .transpose()?; + out.push(FilterSpec { + name: spec.name, + args: args.map(FilterArgs), + }); + } + Ok(out) + }; + let compile_field = |what: &str, + field: FieldBlock, + row_scoped: bool| + -> Result, SearchIndexerError> { + let parse_value: &dyn Fn(&str, &str) -> Result = + if row_scoped { &parse_row } else { &parse }; + let FieldBlock { + selector, + attribute, + text, + default, + filters, + optional, + case, + remove, + } = field; + let text = text + .map(|t| parse_value(&format!("{what} text"), t.as_ref())) + .transpose()?; + let default = default + .map(|d| parse_value(&format!("{what} default"), d.as_ref())) + .transpose()?; + let case = case + .map(|case| { + case.0 + .into_iter() + .map(|(sel, value)| { + Ok(( + sel, + parse_value(&format!("{what} case value"), value.as_ref())?, + )) + }) + .collect::, SearchIndexerError>>() + }) + .transpose()? + .map(OrderedPairs); + let filters = compile_specs(what, filters, row_scoped)?; + Ok(FieldBlock { + selector, + attribute, + text, + default, + filters, + optional, + case, + remove, + }) + }; + let compile_inputs = |what: &str, + inputs: BTreeMap| + -> Result, SearchIndexerError> { + inputs + .into_iter() + .map(|(key, value)| { + Ok(( + key.clone(), + parse(&format!("{what} {key}"), value.as_ref())?, + )) + }) + .collect() + }; + + let CardigannDefinition { + id, + name, + description, + language, + site_type, + encoding, + links, + legacylinks, + caps, + settings, + login, + search, + download, + } = def; + + let SearchBlock { + paths, + path: _, // legacy single-path form — already folded into `paths` by normalize + inputs, + keywordsfilters, + rows, + fields, + } = search; + + let mut compiled_paths = Vec::with_capacity(paths.len()); + for search_path in paths { + let SearchPath { + path, + method, + categories, + inputs: path_inputs, + response, + } = search_path; + compiled_paths.push(SearchPath { + path: parse("search path", path.as_ref())?, + method, + categories, + inputs: compile_inputs("search path input", path_inputs)?, + response, + }); + } + + let RowsBlock { + selector, + filters: rows_filters, + after, + remove, + dateheaders, + attribute, + multiple, + missing_attribute_equals_no_results, + count, + } = rows; + + let mut compiled_fields: Vec<(String, FieldBlock)> = + Vec::with_capacity(fields.0.len()); + for (name, field) in fields.0 { + compiled_fields.push(( + name.clone(), + compile_field(&format!("field {name}"), field, true)?, + )); + } + + let login = login + .map(|login| { + let LoginBlock { + method, + path, + form, + submitpath, + inputs, + error, + test, + selectorinputs, + captcha, + } = login; + let path = path.map(|p| parse("login path", p.as_ref())).transpose()?; + let submitpath = submitpath + .map(|p| parse("login submitpath", p.as_ref())) + .transpose()?; + let mut compiled_error = Vec::with_capacity(error.len()); + for (index, block) in error.into_iter().enumerate() { + let what = format!("login error {index}"); + let message = block + .message + .map(|message| compile_field(&format!("{what} message"), message, false)) + .transpose()?; + compiled_error.push(ErrorBlock { + selector: block.selector, + message, + }); + } + Ok(LoginBlock { + method, + path, + form, + submitpath, + inputs: compile_inputs("login input", inputs)?, + error: compiled_error, + test, + selectorinputs, + captcha, + }) + }) + .transpose()?; + + let download = download + .map(|download| { + let DownloadBlock { + selector, + attribute, + filters, + method, + before, + infohash, + } = download; + Ok(DownloadBlock { + selector, + attribute, + filters: compile_specs("download", filters, false)?, + method, + before, + infohash, + }) + }) + .transpose()?; + + Ok(CardigannDefinition { + id, + name, + description, + language, + site_type, + encoding, + links, + legacylinks, + caps, + settings, + login, + search: SearchBlock { + paths: compiled_paths, + path: None, + inputs: compile_inputs("search input", inputs)?, + keywordsfilters: compile_specs("keywordsfilters", keywordsfilters, false)?, + rows: RowsBlock { + selector, + filters: compile_specs("rows.filters", rows_filters, false)?, + after, + remove, + dateheaders: dateheaders + .map(|d| compile_field("rows.dateheaders", d, true)) + .transpose()?, + attribute, + multiple, + missing_attribute_equals_no_results, + count: count + .map(|c| compile_field("rows.count", c, true)) + .transpose()?, + }, + fields: OrderedFields(compiled_fields), + }, + download, + }) +} + #[cfg(test)] mod tests; diff --git a/crates/eksetasis/src/client/cardigann/definition/tests.rs b/crates/eksetasis/src/client/cardigann/definition/tests.rs index 943d6fee..6dc92717 100644 --- a/crates/eksetasis/src/client/cardigann/definition/tests.rs +++ b/crates/eksetasis/src/client/cardigann/definition/tests.rs @@ -119,7 +119,7 @@ fn parses_representative_definition() { assert_eq!(def.search.paths.len(), 1); assert_eq!(def.search.paths[0].path, "/browse"); - assert_eq!(def.search.inputs.get("q").unwrap().0, "{{ .Keywords }}"); + assert_eq!(def.search.inputs.get("q").unwrap(), "{{ .Keywords }}"); assert_eq!(def.search.keywordsfilters.len(), 1); assert_eq!(def.search.keywordsfilters[0].name, "re_replace"); assert_eq!(def.search.keywordsfilters[0].args(), ["\\s+", "."]); @@ -134,10 +134,8 @@ fn parses_representative_definition() { let dvf = def.search.fields.get("downloadvolumefactor").unwrap(); let case = dvf.case.as_ref().unwrap(); - assert_eq!( - case.0[0], - ("img.freeleech".to_string(), ScalarString("0".to_string())) - ); + assert_eq!(case.0[0].0, "img.freeleech"); + assert_eq!(case.0[0].1, "0"); assert_eq!(case.0[1].0, "*"); assert!(def.search.fields.get("description").unwrap().optional); diff --git a/crates/eksetasis/src/client/cardigann/definition/yaml.rs b/crates/eksetasis/src/client/cardigann/definition/yaml.rs index 432a90fd..97d6cb56 100644 --- a/crates/eksetasis/src/client/cardigann/definition/yaml.rs +++ b/crates/eksetasis/src/client/cardigann/definition/yaml.rs @@ -7,6 +7,7 @@ //! fields, `case:` branches) carries semantics. use std::fmt; +use std::marker::PhantomData; use serde::Deserialize; use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor}; @@ -17,9 +18,26 @@ use super::FieldBlock; /// /// WHY: definition authors write `id: 42` and `id: "42"` interchangeably; /// downstream code only ever compares/joins string forms. +/// +/// This is also the RAW template representation: the schema structs are +/// generic over `ScalarString` (as deserialized) vs +/// [`ParsedTemplate`](crate::client::cardigann::template::ParsedTemplate) +/// (parsed once at load — see `definition::compile_templates`). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ScalarString(pub String); +impl AsRef for ScalarString { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for ScalarString { + fn from(value: String) -> Self { + ScalarString(value) + } +} + impl<'de> Deserialize<'de> for ScalarString { fn deserialize>(deserializer: D) -> Result { struct ScalarVisitor; @@ -57,15 +75,27 @@ impl<'de> Deserialize<'de> for ScalarString { } /// A YAML mapping with author order preserved. -#[derive(Debug, Clone, Default)] -pub struct OrderedPairs(pub Vec<(String, ScalarString)>); +#[derive(Debug, Clone)] +pub struct OrderedPairs(pub Vec<(String, T)>); -impl<'de> Deserialize<'de> for OrderedPairs { +impl Default for OrderedPairs { + fn default() -> Self { + OrderedPairs(Vec::new()) + } +} + +impl<'de, T> Deserialize<'de> for OrderedPairs +where + T: Deserialize<'de>, +{ fn deserialize>(deserializer: D) -> Result { - struct PairsVisitor; + struct PairsVisitor(PhantomData); - impl<'de> Visitor<'de> for PairsVisitor { - type Value = OrderedPairs; + impl<'de, T> Visitor<'de> for PairsVisitor + where + T: Deserialize<'de>, + { + type Value = OrderedPairs; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a mapping") @@ -73,27 +103,33 @@ impl<'de> Deserialize<'de> for OrderedPairs { fn visit_map>(self, mut map: A) -> Result { let mut out = Vec::new(); - while let Some(entry) = map.next_entry::()? { + while let Some(entry) = map.next_entry::()? { out.push(entry); } Ok(OrderedPairs(out)) } } - deserializer.deserialize_map(PairsVisitor) + deserializer.deserialize_map(PairsVisitor(PhantomData)) } } /// Filter arguments: YAML allows a bare scalar or a list of scalars. #[derive(Debug, Clone)] -pub struct FilterArgs(pub Vec); +pub struct FilterArgs(pub Vec); -impl<'de> Deserialize<'de> for FilterArgs { +impl<'de, T> Deserialize<'de> for FilterArgs +where + T: Deserialize<'de> + From, +{ fn deserialize>(deserializer: D) -> Result { - struct ArgsVisitor; + struct ArgsVisitor(PhantomData); - impl<'de> Visitor<'de> for ArgsVisitor { - type Value = FilterArgs; + impl<'de, T> Visitor<'de> for ArgsVisitor + where + T: Deserialize<'de> + From, + { + type Value = FilterArgs; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a scalar or a list of scalars") @@ -101,34 +137,34 @@ impl<'de> Deserialize<'de> for FilterArgs { fn visit_seq>(self, mut seq: A) -> Result { let mut out = Vec::new(); - while let Some(item) = seq.next_element::()? { - out.push(item.0); + while let Some(item) = seq.next_element::()? { + out.push(item); } Ok(FilterArgs(out)) } fn visit_str(self, v: &str) -> Result { - Ok(FilterArgs(vec![v.to_string()])) + Ok(FilterArgs(vec![T::from(v.to_string())])) } fn visit_i64(self, v: i64) -> Result { - Ok(FilterArgs(vec![v.to_string()])) + Ok(FilterArgs(vec![T::from(v.to_string())])) } fn visit_u64(self, v: u64) -> Result { - Ok(FilterArgs(vec![v.to_string()])) + Ok(FilterArgs(vec![T::from(v.to_string())])) } fn visit_f64(self, v: f64) -> Result { - Ok(FilterArgs(vec![v.to_string()])) + Ok(FilterArgs(vec![T::from(v.to_string())])) } fn visit_bool(self, v: bool) -> Result { - Ok(FilterArgs(vec![v.to_string()])) + Ok(FilterArgs(vec![T::from(v.to_string())])) } } - deserializer.deserialize_any(ArgsVisitor) + deserializer.deserialize_any(ArgsVisitor(PhantomData)) } } @@ -138,13 +174,19 @@ impl<'de> Deserialize<'de> for FilterArgs { /// definition, so extraction must follow declaration order; a map's sorted /// iteration would feed them the wrong subset (and `title:` falling back to /// `title_default:` is the single most common `.Result` pattern). -#[derive(Debug, Clone, Default)] -pub struct OrderedFields(pub Vec<(String, FieldBlock)>); +#[derive(Debug, Clone)] +pub struct OrderedFields(pub Vec<(String, FieldBlock)>); + +impl Default for OrderedFields { + fn default() -> Self { + OrderedFields(Vec::new()) + } +} -impl OrderedFields { +impl OrderedFields { /// Time: O(n) in the number of declared fields (definitions carry a /// dozen-odd fields; a map would out-allocate the scan). Space: O(1). - pub fn get(&self, name: &str) -> Option<&FieldBlock> { + pub fn get(&self, name: &str) -> Option<&FieldBlock> { self.0 .iter() .find(|(n, _)| n == name) @@ -157,7 +199,7 @@ impl OrderedFields { } /// Time: O(1) to hand out the iterator, Space: O(1). - pub fn iter(&self) -> impl Iterator { + pub fn iter(&self) -> impl Iterator)> { self.0.iter() } @@ -172,26 +214,32 @@ impl OrderedFields { } /// Time: O(n), Space: O(n) — the names are cloned for the row-scope - /// validator, which outlives the definition borrow. + /// parser, which outlives the definition borrow. pub fn names(&self) -> Vec { self.0.iter().map(|(name, _)| name.clone()).collect() } } -impl<'de> Deserialize<'de> for OrderedFields { +impl<'de, T> Deserialize<'de> for OrderedFields +where + T: Deserialize<'de> + From, +{ fn deserialize>(deserializer: D) -> Result { - struct FieldsVisitor; + struct FieldsVisitor(PhantomData); - impl<'de> Visitor<'de> for FieldsVisitor { - type Value = OrderedFields; + impl<'de, T> Visitor<'de> for FieldsVisitor + where + T: Deserialize<'de> + From, + { + type Value = OrderedFields; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a mapping of field name to field block") } fn visit_map>(self, mut map: A) -> Result { - let mut out: Vec<(String, FieldBlock)> = Vec::new(); - while let Some((name, block)) = map.next_entry::()? { + let mut out: Vec<(String, FieldBlock)> = Vec::new(); + while let Some((name, block)) = map.next_entry::>()? { // WHY: a repeated key overrides in place (last wins, // keeping its first position) rather than duplicating. match out.iter_mut().find(|entry| entry.0 == name) { @@ -203,6 +251,6 @@ impl<'de> Deserialize<'de> for OrderedFields { } } - deserializer.deserialize_map(FieldsVisitor) + deserializer.deserialize_map(FieldsVisitor(PhantomData)) } } diff --git a/crates/eksetasis/src/client/cardigann/extract.rs b/crates/eksetasis/src/client/cardigann/extract.rs index 89289b6f..189079df 100644 --- a/crates/eksetasis/src/client/cardigann/extract.rs +++ b/crates/eksetasis/src/client/cardigann/extract.rs @@ -11,7 +11,8 @@ use jiff::Zoned; use scraper::{ElementRef, Html, Selector}; use tracing::debug; -use crate::client::cardigann::definition::{CardigannDefinition, FieldBlock}; +use crate::client::cardigann::definition::{CompiledDefinition, FieldBlock}; +use crate::client::cardigann::template::ParsedTemplate; use crate::client::cardigann::{filters, template, template::TemplateContext}; /// Extracted field values for the rows in one search-results page. Fields @@ -19,7 +20,7 @@ use crate::client::cardigann::{filters, template, template::TemplateContext}; /// templates see the values extracted before them (upstream semantics). pub fn extract_rows( html: &str, - def: &CardigannDefinition, + def: &CompiledDefinition, ctx: &TemplateContext, now: &Zoned, ) -> Result>, String> { @@ -57,12 +58,12 @@ pub fn extract_rows( fn extract_field( row: ElementRef, - field: &FieldBlock, + field: &FieldBlock, ctx: &TemplateContext, now: &Zoned, ) -> Result, String> { let raw: Option = if let Some(text) = &field.text { - Some(ctx.render(&text.0)?) + Some(ctx.render(text)?) } else { let element = match &field.selector { Some(selector) => row.select(&parse_selector(selector)?).next(), @@ -87,7 +88,7 @@ fn extract_field( // rendered in row scope and consulted only when extraction is blank. let raw = match (raw, &field.default) { (None, Some(default)) => { - let rendered = ctx.render(&default.0)?.trim().to_string(); + let rendered = ctx.render(default)?.trim().to_string(); (!rendered.is_empty()).then_some(rendered) } (raw, _) => raw, @@ -107,13 +108,13 @@ fn extract_field( /// (row scope — a case value may reference `.Result.`). fn resolve_case( element: ElementRef, - case: &crate::client::cardigann::definition::OrderedPairs, + case: &crate::client::cardigann::definition::OrderedPairs, ctx: &TemplateContext, ) -> Result, String> { for (selector_str, value) in &case.0 { let selector = parse_selector(selector_str)?; if selector.matches(&element) || element.select(&selector).next().is_some() { - return Ok(Some(ctx.render(&value.0)?)); + return Ok(Some(ctx.render(value)?)); } } Ok(None) @@ -177,7 +178,7 @@ fn collect_text( pub fn extract_error_message( html: &str, selector: &str, - message: Option<&FieldBlock>, + message: Option<&FieldBlock>, ctx: &TemplateContext, now: &Zoned, ) -> Result, String> { diff --git a/crates/eksetasis/src/client/cardigann/filters.rs b/crates/eksetasis/src/client/cardigann/filters.rs index 9cda2a83..947cbdc8 100644 --- a/crates/eksetasis/src/client/cardigann/filters.rs +++ b/crates/eksetasis/src/client/cardigann/filters.rs @@ -40,7 +40,7 @@ static NON_SPACING_MARK: LazyLock = LazyLock::new(|| { /// /// `now` anchors the relative-time filters; injecting it keeps them /// deterministic under test. -pub fn apply(value: String, specs: &[FilterSpec], now: &Zoned) -> Result { +pub fn apply(value: String, specs: &[FilterSpec], now: &Zoned) -> Result { let mut value = value; for spec in specs { value = apply_one(value, spec, now)?; @@ -50,7 +50,7 @@ pub fn apply(value: String, specs: &[FilterSpec], now: &Zoned) -> Result Result<(), String> { +pub fn validate>(specs: &[FilterSpec]) -> Result<(), String> { for spec in specs { let args = spec.args(); let arity = |min: usize, max: usize| { @@ -70,13 +70,13 @@ pub fn validate(specs: &[FilterSpec]) -> Result<(), String> { if spec.name == "re_replace" && args.len() != 2 { return Err("filter \"re_replace\" takes exactly 2 args".to_string()); } - let pattern = args.first().map(String::as_str).unwrap_or_default(); + let pattern = args.first().map(AsRef::as_ref).unwrap_or_default(); Regex::new(pattern).map_err(|e| format!("filter {:?} pattern: {e}", spec.name))?; } "replace" => arity(2, 2)?, "split" => { arity(2, 2)?; - let index = args.get(1).map(String::as_str).unwrap_or_default(); + let index = args.get(1).map(AsRef::as_ref).unwrap_or_default(); index .parse::() .map_err(|_| format!("split index {index:?} is not an integer"))?; @@ -91,7 +91,7 @@ pub fn validate(specs: &[FilterSpec]) -> Result<(), String> { arity(1, 1)?; // WHY: upstream accepts only "replace" and throws otherwise — // reject at load rather than silently no-op on a typo. - let mode = args.first().map(String::as_str).unwrap_or_default(); + let mode = args.first().map(AsRef::as_ref).unwrap_or_default(); if mode != "replace" { return Err(format!( "filter \"diacritics\" takes only \"replace\", got {mode:?}" @@ -136,11 +136,11 @@ pub(crate) enum RowFilter { /// state the field-filter pipeline never sees. Upstream recognizes `andmatch` /// and a debug-only `strdump`; only `andmatch` is implemented, and anything /// else is rejected rather than silently ignored. -pub fn parse_row_filters(specs: &[FilterSpec]) -> Result, String> { +pub fn parse_row_filters>(specs: &[FilterSpec]) -> Result, String> { specs.iter().map(parse_row_filter).collect() } -fn parse_row_filter(spec: &FilterSpec) -> Result { +fn parse_row_filter>(spec: &FilterSpec) -> Result { match spec.name.as_str() { "andmatch" => { let args = spec.args(); @@ -151,9 +151,14 @@ fn parse_row_filter(spec: &FilterSpec) -> Result { )); } let character_limit = match args.first() { - Some(limit) => NonZeroUsize::new(limit.parse::().map_err(|_| { - format!("andmatch character limit {limit:?} is not an integer") - })?), + Some(limit) => { + NonZeroUsize::new(limit.as_ref().parse::().map_err(|_| { + format!( + "andmatch character limit {:?} is not an integer", + limit.as_ref() + ) + })?) + } None => None, }; Ok(RowFilter::AndMatch { character_limit }) @@ -217,7 +222,7 @@ fn fold_for_match(value: &str) -> String { strip_diacritics(value).to_lowercase() } -fn apply_one(value: String, spec: &FilterSpec, now: &Zoned) -> Result { +fn apply_one(value: String, spec: &FilterSpec, now: &Zoned) -> Result { let args = spec.args(); // WHY: arity is checked at definition load, but this accessor keeps the // pipeline panic-free if a spec ever arrives unvalidated. @@ -568,7 +573,7 @@ fn timeago(value: &str, now: &Zoned) -> String { mod tests { use super::*; - fn spec(name: &str, args: &[&str]) -> FilterSpec { + fn spec(name: &str, args: &[&str]) -> FilterSpec { FilterSpec { name: name.to_string(), args: if args.is_empty() { @@ -588,7 +593,7 @@ mod tests { .to_zoned(TimeZone::UTC) } - fn run(value: &str, specs: &[FilterSpec]) -> Result { + fn run(value: &str, specs: &[FilterSpec]) -> Result { apply(value.to_string(), specs, &now()) } diff --git a/crates/eksetasis/src/client/cardigann/json_extract.rs b/crates/eksetasis/src/client/cardigann/json_extract.rs index 0e103a9f..fa9f690c 100644 --- a/crates/eksetasis/src/client/cardigann/json_extract.rs +++ b/crates/eksetasis/src/client/cardigann/json_extract.rs @@ -17,7 +17,8 @@ use jiff::Zoned; use serde_json::Value; use tracing::debug; -use crate::client::cardigann::definition::{CardigannDefinition, FieldBlock, OrderedPairs}; +use crate::client::cardigann::definition::{CompiledDefinition, FieldBlock, OrderedPairs}; +use crate::client::cardigann::template::ParsedTemplate; use crate::client::cardigann::{filters, template, template::TemplateContext}; /// Extracted field values for the rows in one JSON search-results body. @@ -25,7 +26,7 @@ use crate::client::cardigann::{filters, template, template::TemplateContext}; /// templates see the values extracted before them (upstream semantics). pub fn extract_rows_json( body: &str, - def: &CardigannDefinition, + def: &CompiledDefinition, ctx: &TemplateContext, now: &Zoned, ) -> Result>, String> { @@ -116,14 +117,14 @@ pub fn extract_rows_json( fn extract_field_json( row: &Value, parent: Option<&Value>, - field: &FieldBlock, + field: &FieldBlock, ctx: &TemplateContext, now: &Zoned, ) -> Result, String> { // WHY: `text` short-circuits selection entirely (incl. `case`), matching // upstream `handleJsonSelector`. if let Some(text) = &field.text { - let rendered = ctx.render(&text.0)?; + let rendered = ctx.render(text)?; let specs = template::render_specs(&field.filters, ctx)?; return filters::apply(rendered, &specs, now).map(Some); } @@ -162,7 +163,7 @@ fn extract_field_json( // rendered in row scope and consulted only when extraction is blank. let value = match (value, &field.default) { (None, Some(default)) => { - let rendered = normalize_space(&ctx.render(&default.0)?); + let rendered = normalize_space(&ctx.render(default)?); (!rendered.is_empty()).then_some(rendered) } (value, _) => value, @@ -182,12 +183,12 @@ fn extract_field_json( /// extractor's `resolve_case`). Returns `None` when no branch matches. fn apply_case_json( value: Option<&str>, - case: &OrderedPairs, + case: &OrderedPairs, ctx: &TemplateContext, ) -> Result, String> { for (key, replacement) in &case.0 { if key == "*" || value == Some(key.as_str()) { - return ctx.render(&replacement.0).map(Some); + return ctx.render(replacement).map(Some); } } Ok(None) diff --git a/crates/eksetasis/src/client/cardigann/mod.rs b/crates/eksetasis/src/client/cardigann/mod.rs index 15652992..74a1047f 100644 --- a/crates/eksetasis/src/client/cardigann/mod.rs +++ b/crates/eksetasis/src/client/cardigann/mod.rs @@ -38,16 +38,16 @@ use crate::types::{ DownloadResponse, IndexerCaps, IndexerStatus, ReleaseProtocol, SearchFunction, SearchLimits, SearchQuery, SearchResult, ServerInfo, }; -use definition::{CardigannDefinition, SearchPath}; +use definition::{CompiledDefinition, SearchPath}; pub use session::SessionStore; use session::{LoginMethod, LoginVerb}; -use template::TemplateContext; +use template::{ParsedTemplate, TemplateContext}; /// Cardigann definitions loaded from `cardigann_definitions_dir`, keyed by /// definition id. pub struct CardigannRegistry { config: Arc, - definitions: HashMap>, + definitions: HashMap>, } impl CardigannRegistry { @@ -127,7 +127,7 @@ impl CardigannRegistry { /// Resolves an indexer row's `url` column to a loaded definition: an /// exact definition-id match first, then a match against the /// definition's declared site links (so a row may carry either form). - pub fn resolve(&self, indexer_url: &str) -> Option> { + pub fn resolve(&self, indexer_url: &str) -> Option> { if let Some(def) = self.definitions.get(indexer_url) { return Some(Arc::clone(def)); } @@ -176,7 +176,7 @@ pub struct CardigannClient { cf_proxy: Arc, timeout: Duration, indexer: IndexerConfig, - definition: Arc, + definition: Arc, base_url: Url, /// Resolved login strategy (none / static cookie / interactive). login: LoginMethod, @@ -216,7 +216,7 @@ impl CardigannClient { cf_proxy: Arc, timeout: Duration, indexer: IndexerConfig, - definition: Arc, + definition: Arc, sessions: Arc, ) -> Result { let base_url = resolve_base_url(&indexer, &definition)?; @@ -353,7 +353,7 @@ impl CardigannClient { /// inputs into the query string and carry no body. fn build_search_request( &self, - path: &SearchPath, + path: &SearchPath, ctx: &TemplateContext, ) -> Result<(Url, Option), SearchIndexerError> { let rendered = ctx @@ -371,12 +371,12 @@ impl CardigannClient { .map_err(|e| self.invalid(format!("search path {rendered:?}: {e}")))?, }; - let mut inputs: BTreeMap<&str, &str> = BTreeMap::new(); + let mut inputs: BTreeMap<&str, &ParsedTemplate> = BTreeMap::new(); for (key, value) in &self.definition.search.inputs { - inputs.insert(key, &value.0); + inputs.insert(key, value); } for (key, value) in &path.inputs { - inputs.insert(key, &value.0); + inputs.insert(key, value); } let is_post = path.method.as_deref() == Some("post"); @@ -974,7 +974,7 @@ impl IndexerClient for CardigannClient { fn resolve_base_url( indexer: &IndexerConfig, - definition: &CardigannDefinition, + definition: &CompiledDefinition, ) -> Result { let invalid = |reason: String| SearchIndexerError::DefinitionInvalid { definition_id: definition.id.clone(), @@ -1016,7 +1016,7 @@ fn parse_absolute_http(raw: &str) -> Option { /// cookie the login flow depends on. fn validate_settings( indexer: &IndexerConfig, - definition: &CardigannDefinition, + definition: &CompiledDefinition, ) -> Result<(), SearchIndexerError> { let invalid = |reason: String| SearchIndexerError::SettingsInvalid { definition_id: definition.id.clone(), @@ -1074,7 +1074,7 @@ fn validate_settings( /// settings overrides, then the injected static cookie (never /// user-overridable — see `validate_settings`). fn build_config_seed( - definition: &CardigannDefinition, + definition: &CompiledDefinition, indexer_settings: &BTreeMap, cookie: Option<&str>, ) -> BTreeMap { @@ -1108,7 +1108,7 @@ fn build_config_seed( /// exempt: an unset optional setting simply makes the branch false. fn resolve_login( indexer: &IndexerConfig, - definition: &CardigannDefinition, + definition: &CompiledDefinition, ) -> Result { let Some(login) = &definition.login else { return Ok(LoginMethod::None); @@ -1143,7 +1143,7 @@ fn resolve_login( let config = build_config_seed(definition, &indexer.settings, None); for value in login.inputs.values() { - for key in template::config_keys(&value.0) { + for key in value.config_keys() { let resolved = config.get(&key).map(String::as_str).unwrap_or_default(); if resolved.trim().is_empty() { return Err(SearchIndexerError::SettingsInvalid { @@ -1164,7 +1164,7 @@ fn resolve_login( /// Renders `login.path` (config-only context) and joins it on the site base. fn resolve_login_url( indexer: &IndexerConfig, - definition: &CardigannDefinition, + definition: &CompiledDefinition, base_url: &Url, ) -> Result { let invalid = |reason: String| SearchIndexerError::DefinitionInvalid { @@ -1213,7 +1213,11 @@ fn resolve_login_url( Ok(resolved) } -fn path_applies(path: &SearchPath, site_categories: &[String], unconstrained: bool) -> bool { +fn path_applies( + path: &SearchPath, + site_categories: &[String], + unconstrained: bool, +) -> bool { if path.categories.is_empty() || unconstrained { return true; } diff --git a/crates/eksetasis/src/client/cardigann/session.rs b/crates/eksetasis/src/client/cardigann/session.rs index 541c0295..a98b9842 100644 --- a/crates/eksetasis/src/client/cardigann/session.rs +++ b/crates/eksetasis/src/client/cardigann/session.rs @@ -15,6 +15,7 @@ use tracing::instrument; use url::Url; use crate::client::cardigann::definition::{LoginBlock, LoginTest}; +use crate::client::cardigann::template::ParsedTemplate; use crate::client::cardigann::{CardigannClient, extract, template::TemplateContext}; use crate::client::{SsrfGuardResolver, read_body_bounded}; use crate::error::{self, SearchIndexerError}; @@ -190,7 +191,7 @@ impl CardigannClient { inputs = form.inputs; for (key, value) in &login.inputs { let rendered = ctx - .render(&value.0) + .render(value) .map_err(|e| self.invalid(format!("login input {key}: {e}")))?; override_input(&mut inputs, key, rendered); } @@ -199,7 +200,7 @@ impl CardigannClient { LoginVerb::Post | LoginVerb::Get => { for (key, value) in &login.inputs { let rendered = ctx - .render(&value.0) + .render(value) .map_err(|e| self.invalid(format!("login input {key}: {e}")))?; inputs.push((key.clone(), rendered)); } @@ -319,7 +320,7 @@ impl CardigannClient { /// Jackett does not enforce this; this engine deliberately does. fn resolve_submit_url( &self, - login: &LoginBlock, + login: &LoginBlock, ctx: &TemplateContext, page_url: &Url, action: Option<&str>, diff --git a/crates/eksetasis/src/client/cardigann/template.rs b/crates/eksetasis/src/client/cardigann/template.rs index e8090d31..bf1ef0e9 100644 --- a/crates/eksetasis/src/client/cardigann/template.rs +++ b/crates/eksetasis/src/client/cardigann/template.rs @@ -2,15 +2,20 @@ //! //! Supported: `{{ .Keywords }}`, `{{ .Categories }}` (comma-joined), //! `{{ .Config. }}`, `{{ .Query. }}`, `{{ .Result. }}` -//! (row scope only — see [`validate_row_scoped`]), +//! (row scope only — see [`ParsedTemplate::parse_row_scoped`]), //! `{{ join .Categories "" }}`, the block constructs //! `{{ if }}…{{ else }}…{{ end }}` and //! `{{ range .Categories }}…{{ . }}…{{ end }}` (blocks nest), and the //! operators `and` / `or` (variadic, value-returning), `eq` / `ne` (two //! operands), `(...)` grouping, quoted string literals, and the boolean //! constants `.True` / `.False`. Anything else — pipelines, `with`, -//! variables, `not`, `else if` — is rejected with a clear reason at -//! definition load. +//! variables, `not`, `else if` — is rejected when the template parses. +//! +//! Parse-don't-validate (#696): a template parses exactly once, at +//! definition load, into a [`ParsedTemplate`]; the scope checks (declared +//! config keys, `.Result` field scope, `{{ . }}` range placement) fold into +//! the parser, and the render path consumes the stored AST — nothing +//! re-parses per search. //! //! Truthiness is dynamically typed, matching upstream: an absent or empty //! value is false. Checkbox settings are stored as the literal strings @@ -36,8 +41,8 @@ pub struct TemplateContext { pub query: BTreeMap<&'static str, String>, /// Row scope: the values of fields extracted so far for the row being /// processed (YAML declaration order), backing `.Result.`. Empty - /// outside field extraction (search-path/login rendering), where load - /// validation has already rejected `.Result` references. + /// outside field extraction (search-path/login rendering), where the + /// load-time parse has already rejected `.Result` references. pub result: BTreeMap, } @@ -56,31 +61,144 @@ impl std::fmt::Debug for TemplateContext { } impl TemplateContext { - /// Renders `template` against this context. Errors carry a + /// Renders a parsed template against this context. Errors carry a /// human-readable reason. - pub fn render(&self, template: &str) -> Result { + pub fn render(&self, template: &ParsedTemplate) -> Result { self.render_mode(template, Mode::Plain) } - /// Renders `template` for a URL context: literal template text (the - /// path's own `?`/`&`/`=` structure and branch text) passes through + /// Renders a parsed template for a URL context: literal template text + /// (the path's own `?`/`&`/`=` structure and branch text) passes through /// untouched while every expression expansion is form-urlencoded. /// /// WHY: expansions are data, not URL structure — a keyword containing /// `&` must not split the query and `#` must not start a fragment. /// Upstream Cardigann engines encode substituted values the same way. - pub fn render_url(&self, template: &str) -> Result { + pub fn render_url(&self, template: &ParsedTemplate) -> Result { self.render_mode(template, Mode::Url) } - fn render_mode(&self, template: &str, mode: Mode) -> Result { - let nodes = parse(template)?; - let mut out = String::with_capacity(template.len()); - emit(&nodes, self, None, mode, &mut out)?; + fn render_mode(&self, template: &ParsedTemplate, mode: Mode) -> Result { + let mut out = String::with_capacity(template.source.len()); + emit(&template.nodes, self, None, mode, &mut out)?; Ok(out) } } +/// A template parsed once — at definition load — in the scope it renders in. +/// +/// This is the constrained type of parse-don't-validate: the parser rejects +/// unsupported constructs AND out-of-scope references (unknown config keys, +/// `.Result` outside row scope, `{{ . }}` outside a range body), so a +/// `ParsedTemplate` can only exist for a template this engine can execute. +#[derive(Debug, Clone)] +pub struct ParsedTemplate { + /// The template source, kept for load-time value checks (a filter arg + /// validated as a regex or an index) and diagnostics. + source: String, + nodes: Vec, +} + +impl ParsedTemplate { + /// Parses `source` in search/login scope: `.Result` references are + /// rejected here — they are meaningful only in row scope. + pub fn parse(source: &str, config_keys: &[&str]) -> Result { + Self::parse_scoped(source, config_keys, None) + } + + /// Like [`parse`](Self::parse), but allows `.Result.` references + /// against the definition's declared search fields. Row scope applies to + /// field `text`/`case`/`default` values and field filter args — the + /// positions the extractor renders with the row's accumulated values. + pub fn parse_row_scoped( + source: &str, + config_keys: &[&str], + field_names: &[String], + ) -> Result { + Self::parse_scoped(source, config_keys, Some(field_names)) + } + + fn parse_scoped( + source: &str, + config_keys: &[&str], + field_names: Option<&[String]>, + ) -> Result { + let scope = Scope { + config_keys, + field_names, + in_range: false, + }; + let tokens = scan(source)?; + let (nodes, stop) = parse_nodes(&tokens, 0, &scope)?; + let nodes = match stop { + Stop::Eof => nodes, + Stop::End(_) => { + return Err(format!( + "{{{{ end }}}} without a matching block in {source:?}" + )); + } + Stop::Else(_) => { + return Err(format!( + "{{{{ else }}}} without a matching if in {source:?}" + )); + } + }; + Ok(Self { + source: source.to_string(), + nodes, + }) + } + + /// The template source as authored in the definition. + pub fn source(&self) -> &str { + &self.source + } + + /// `.Config.` names referenced by this template in VALUE positions. + /// + /// Condition atoms (`{{ if .Config.x }}`) are excluded on purpose: a + /// missing or empty optional setting simply makes the branch false, so + /// callers using this list to demand non-empty values (interactive-login + /// construction) only see keys whose rendered value the definition + /// actually substitutes. + pub fn config_keys(&self) -> Vec { + let mut keys = Vec::new(); + collect_value_config_keys(&self.nodes, &mut keys); + keys + } +} + +/// A parsed template compares by its source — the AST is a function of it. +impl PartialEq for ParsedTemplate { + fn eq(&self, other: &str) -> bool { + self.source == other + } +} + +impl PartialEq for str { + fn eq(&self, other: &ParsedTemplate) -> bool { + self == other.source + } +} + +impl PartialEq<&str> for ParsedTemplate { + fn eq(&self, other: &&str) -> bool { + self.source == *other + } +} + +impl PartialEq for &str { + fn eq(&self, other: &ParsedTemplate) -> bool { + *self == other.source + } +} + +impl AsRef for ParsedTemplate { + fn as_ref(&self) -> &str { + &self.source + } +} + /// Form-urlencodes one expanded value (`&`→`%26`, `#`→`%23`, space→`+`). fn encode_value(value: &str) -> String { url::form_urlencoded::byte_serialize(value.as_bytes()).collect() @@ -102,132 +220,6 @@ pub const QUERY_FIELDS: &[&str] = &[ "Offset", ]; -/// Checks every expression in `template` without a runtime context, so -/// unsupported constructs surface at definition load. `.Result` references -/// are rejected here — they are meaningful only in row scope. -pub fn validate(template: &str, config_keys: &[&str]) -> Result<(), String> { - validate_inner(template, config_keys, None) -} - -/// Like [`validate`], but allows `.Result.` references against the -/// definition's declared search fields. Row scope applies to field -/// `text`/`case`/`default` values and field filter args — the positions the -/// extractor renders with the row's accumulated values. -pub fn validate_row_scoped( - template: &str, - config_keys: &[&str], - field_names: &[String], -) -> Result<(), String> { - validate_inner(template, config_keys, Some(field_names)) -} - -fn validate_inner( - template: &str, - config_keys: &[&str], - field_names: Option<&[String]>, -) -> Result<(), String> { - let nodes = parse(template)?; - validate_nodes(&nodes, config_keys, field_names, false) -} - -fn validate_nodes( - nodes: &[Node], - config_keys: &[&str], - field_names: Option<&[String]>, - in_range: bool, -) -> Result<(), String> { - for node in nodes { - match node { - Node::Text(_) => {} - Node::Value(expr) => validate_expr(expr, config_keys, field_names, in_range)?, - Node::If { - cond, - then, - otherwise, - } => { - validate_expr(cond, config_keys, field_names, in_range)?; - validate_nodes(then, config_keys, field_names, in_range)?; - validate_nodes(otherwise, config_keys, field_names, in_range)?; - } - Node::Range { body } => validate_nodes(body, config_keys, field_names, true)?, - } - } - Ok(()) -} - -fn validate_expr( - expr: &Expr, - config_keys: &[&str], - field_names: Option<&[String]>, - in_range: bool, -) -> Result<(), String> { - match expr { - Expr::Atom(atom) => validate_atom(atom, config_keys, field_names, in_range), - Expr::Join(_) => Ok(()), - Expr::And(args) | Expr::Or(args) => { - for arg in args { - validate_expr(arg, config_keys, field_names, in_range)?; - } - Ok(()) - } - Expr::Eq(a, b) | Expr::Ne(a, b) => { - validate_expr(a, config_keys, field_names, in_range)?; - validate_expr(b, config_keys, field_names, in_range) - } - } -} - -fn validate_atom( - atom: &Atom, - config_keys: &[&str], - field_names: Option<&[String]>, - in_range: bool, -) -> Result<(), String> { - match atom { - Atom::Keywords | Atom::Categories | Atom::Bool(_) | Atom::Str(_) => Ok(()), - Atom::Dot if in_range => Ok(()), - Atom::Dot => Err("{{ . }} is only valid inside a range block".to_string()), - // WHY: "cookie" is injected at client construction from the indexer - // row, so cookie-login definitions may reference it without a - // matching settings entry. - Atom::Config(key) if key == "cookie" || config_keys.contains(&key.as_str()) => Ok(()), - Atom::Config(key) => Err(format!("unknown config key {key:?}")), - // WHY: membership in QUERY_FIELDS is enforced when the atom is - // classified during parsing. - Atom::Query(_) => Ok(()), - Atom::Result(field) => { - let Some(names) = field_names else { - return Err(format!( - ".Result references are only supported in field text/case/default values \ - and field filter args, got .Result.{field}" - )); - }; - if names.iter().any(|name| name == field) { - Ok(()) - } else { - Err(format!(".Result references undeclared field {field:?}")) - } - } - } -} - -/// `.Config.` names referenced by `template` in VALUE positions. -/// -/// Condition atoms (`{{ if .Config.x }}`) are excluded on purpose: a missing -/// or empty optional setting simply makes the branch false, so callers using -/// this list to demand non-empty values (interactive-login construction) only -/// see keys whose rendered value the definition actually substitutes. -/// -/// NOTE: unparseable templates yield an empty list — load-time [`validate`] -/// has already rejected them for every template this is called on. -pub fn config_keys(template: &str) -> Vec { - let mut keys = Vec::new(); - if let Ok(nodes) = parse(template) { - collect_value_config_keys(&nodes, &mut keys); - } - keys -} - fn collect_value_config_keys(nodes: &[Node], keys: &mut Vec) { for node in nodes { match node { @@ -266,9 +258,9 @@ fn collect_expr_config_keys(expr: &Expr, keys: &mut Vec) { /// pipeline must never see a raw `{{ ... }}` — an unrendered arg would /// silently corrupt the value it transforms. pub fn render_specs( - specs: &[FilterSpec], + specs: &[FilterSpec], ctx: &TemplateContext, -) -> Result, String> { +) -> Result>, String> { specs .iter() .map(|spec| { @@ -292,6 +284,7 @@ pub fn render_specs( // ── parsing ───────────────────────────────────────────────────────────── +#[derive(Debug, Clone)] enum Node { Text(String), Value(Expr), @@ -344,6 +337,21 @@ enum Stop { Eof, } +/// Parse-time scope: which references a template may make. The checks fold +/// into parsing, so scope violations surface with the same messages the +/// pre-#696 load validators produced. +#[derive(Clone, Copy)] +struct Scope<'a> { + /// Setting names the definition declares (the injected `cookie` is + /// always allowed — see [`classify_atom`]). + config_keys: &'a [&'a str], + /// The definition's declared search fields in row scope; `None` rejects + /// `.Result` references outright. + field_names: Option<&'a [String]>, + /// Inside a `range .Categories` body, where `{{ . }}` is the loop item. + in_range: bool, +} + /// Splits `template` into literal text and `{{ ... }}` tag tokens. fn scan(template: &str) -> Result, String> { let unclosed = || format!("unclosed {{{{ in template {template:?}"); @@ -368,21 +376,11 @@ fn scan(template: &str) -> Result, String> { Ok(tokens) } -fn parse(template: &str) -> Result, String> { - let tokens = scan(template)?; - let (nodes, stop) = parse_nodes(&tokens, 0)?; - match stop { - Stop::Eof => Ok(nodes), - Stop::End(_) => Err(format!( - "{{{{ end }}}} without a matching block in {template:?}" - )), - Stop::Else(_) => Err(format!( - "{{{{ else }}}} without a matching if in {template:?}" - )), - } -} - -fn parse_nodes(tokens: &[Token], mut i: usize) -> Result<(Vec, Stop), String> { +fn parse_nodes( + tokens: &[Token], + mut i: usize, + scope: &Scope<'_>, +) -> Result<(Vec, Stop), String> { let mut nodes = Vec::new(); while let Some(token) = tokens.get(i) { match token { @@ -409,10 +407,10 @@ fn parse_nodes(tokens: &[Token], mut i: usize) -> Result<(Vec, Stop), Stri return Err("{{ if }} requires a condition".to_string()); } "if" => { - let cond = parse_expr(rest)?; - let (then, stop) = parse_nodes(tokens, i + 1)?; + let cond = parse_expr(rest, scope)?; + let (then, stop) = parse_nodes(tokens, i + 1, scope)?; let (otherwise, next) = match stop { - Stop::Else(j) => match parse_nodes(tokens, j + 1)? { + Stop::Else(j) => match parse_nodes(tokens, j + 1, scope)? { (else_nodes, Stop::End(k)) => (else_nodes, k), (_, Stop::Else(_)) => { return Err( @@ -437,7 +435,13 @@ fn parse_nodes(tokens: &[Token], mut i: usize) -> Result<(Vec, Stop), Stri if rest != ".Categories" { return Err(format!("range supports only .Categories, got {rest:?}")); } - let (body, stop) = parse_nodes(tokens, i + 1)?; + // WHY: `{{ . }}` becomes the loop item inside the + // body — parse it with the range scope bit set. + let body_scope = Scope { + in_range: true, + ..*scope + }; + let (body, stop) = parse_nodes(tokens, i + 1, &body_scope)?; match stop { Stop::End(j) => { nodes.push(Node::Range { body }); @@ -450,7 +454,7 @@ fn parse_nodes(tokens: &[Token], mut i: usize) -> Result<(Vec, Stop), Stri } } _ => { - nodes.push(Node::Value(parse_expr(tag)?)); + nodes.push(Node::Value(parse_expr(tag, scope)?)); i += 1; } } @@ -508,9 +512,13 @@ fn lex(expr: &str) -> Result, String> { } } -fn parse_expr(src: &str) -> Result { +fn parse_expr(src: &str, scope: &Scope<'_>) -> Result { let toks = lex(src)?; - let mut parser = ExprParser { toks, pos: 0 }; + let mut parser = ExprParser { + toks, + pos: 0, + scope, + }; let expr = parser.term()?; if parser.pos != parser.toks.len() { return Err(format!( @@ -522,12 +530,13 @@ fn parse_expr(src: &str) -> Result { Ok(expr) } -struct ExprParser { +struct ExprParser<'a> { toks: Vec, pos: usize, + scope: &'a Scope<'a>, } -impl ExprParser { +impl ExprParser<'_> { fn peek(&self) -> Option<&Tok> { self.toks.get(self.pos) } @@ -579,7 +588,9 @@ impl ExprParser { } } "join" => self.join(), - other if other.starts_with('.') => Ok(Expr::Atom(classify_atom(other)?)), + other if other.starts_with('.') => { + Ok(Expr::Atom(classify_atom(other, self.scope)?)) + } other => Err(format!( "unsupported template construct {other:?} (supported: .Keywords, \ .Categories, .Config., .Query., .Result., \ @@ -602,7 +613,7 @@ impl ExprParser { match self.peek() { Some(Tok::LParen) | Some(Tok::Str(_)) => args.push(self.term()?), Some(Tok::Word(word)) if word.starts_with('.') => { - args.push(Expr::Atom(classify_atom(word)?)); + args.push(Expr::Atom(classify_atom(word, self.scope)?)); self.pos += 1; } _ => break, @@ -635,11 +646,15 @@ impl ExprParser { } } -fn classify_atom(word: &str) -> Result { +/// Classifies a `.`-prefixed word into an atom, enforcing the parse scope: +/// config keys must be declared, `.Result` needs row scope against declared +/// fields, and `{{ . }}` needs a surrounding range body. +fn classify_atom(word: &str, scope: &Scope<'_>) -> Result { match word { ".Keywords" => return Ok(Atom::Keywords), ".Categories" => return Ok(Atom::Categories), - "." => return Ok(Atom::Dot), + "." if scope.in_range => return Ok(Atom::Dot), + "." => return Err("{{ . }} is only valid inside a range block".to_string()), ".True" => return Ok(Atom::Bool(true)), ".False" => return Ok(Atom::Bool(false)), _ => {} @@ -648,6 +663,12 @@ fn classify_atom(word: &str) -> Result { if key.is_empty() || key.contains(char::is_whitespace) { return Err(format!("malformed config reference {word:?}")); } + // WHY: "cookie" is injected at client construction from the indexer + // row, so cookie-login definitions may reference it without a + // matching settings entry. + if key != "cookie" && !scope.config_keys.contains(&key) { + return Err(format!("unknown config key {key:?}")); + } return Ok(Atom::Config(key.to_string())); } if let Some(field) = word.strip_prefix(".Query.") { @@ -661,6 +682,15 @@ fn classify_atom(word: &str) -> Result { if field.is_empty() || field.contains(char::is_whitespace) { return Err(format!("malformed result reference {word:?}")); } + let Some(names) = scope.field_names else { + return Err(format!( + ".Result references are only supported in field text/case/default values \ + and field filter args, got .Result.{field}" + )); + }; + if !names.iter().any(|name| name == field) { + return Err(format!(".Result references undeclared field {field:?}")); + } return Ok(Atom::Result(field.to_string())); } Err(format!( diff --git a/crates/eksetasis/src/client/cardigann/template/tests.rs b/crates/eksetasis/src/client/cardigann/template/tests.rs index 0f9433a5..219e449f 100644 --- a/crates/eksetasis/src/client/cardigann/template/tests.rs +++ b/crates/eksetasis/src/client/cardigann/template/tests.rs @@ -1,9 +1,24 @@ -//! Template evaluator tests (block constructs, validation, rendering). +//! Template evaluator tests (block constructs, parse-time scope, rendering). use std::collections::BTreeMap; use super::*; +/// Every config key the test templates reference, declared as a definition's +/// settings would declare them — parse rejects undeclared keys at load. +const CONFIG_KEYS: &[&str] = &["sort", "freeleech", "multilang", "vip", "missing"]; + +/// Parses in search/login scope (the position most templates live in). +fn parsed(source: &str) -> ParsedTemplate { + ParsedTemplate::parse(source, CONFIG_KEYS).unwrap() +} + +/// Parses in row scope with the fields the test templates reference declared. +fn parsed_row(source: &str) -> ParsedTemplate { + let fields = vec!["year".to_string(), "missing".to_string()]; + ParsedTemplate::parse_row_scoped(source, CONFIG_KEYS, &fields).unwrap() +} + fn ctx() -> TemplateContext { TemplateContext { keywords: "test query".to_string(), @@ -17,7 +32,7 @@ fn ctx() -> TemplateContext { #[test] fn plain_text_passes_through() { assert_eq!( - ctx().render("no templates here").unwrap(), + ctx().render(&parsed("no templates here")).unwrap(), "no templates here" ); } @@ -25,27 +40,34 @@ fn plain_text_passes_through() { #[test] fn keywords_and_surrounding_text() { assert_eq!( - ctx().render("/search?q={{ .Keywords }}&x=1").unwrap(), + ctx() + .render(&parsed("/search?q={{ .Keywords }}&x=1")) + .unwrap(), "/search?q=test query&x=1" ); } #[test] fn categories_join_comma_by_default() { - assert_eq!(ctx().render("{{ .Categories }}").unwrap(), "6,12"); + assert_eq!(ctx().render(&parsed("{{ .Categories }}")).unwrap(), "6,12"); } #[test] fn join_with_custom_separator() { assert_eq!( - ctx().render("{{ join .Categories \";\" }}").unwrap(), + ctx() + .render(&parsed("{{ join .Categories \";\" }}")) + .unwrap(), "6;12" ); } #[test] fn config_lookup() { - assert_eq!(ctx().render("{{ .Config.sort }}").unwrap(), "created"); + assert_eq!( + ctx().render(&parsed("{{ .Config.sort }}")).unwrap(), + "created" + ); } #[test] @@ -60,62 +82,82 @@ fn config_checkbox_strings_render_literally_in_value_position() { ]), ..Default::default() }; - assert_eq!(c.render("fl={{ .Config.freeleech }}").unwrap(), "fl=false"); - assert_eq!(c.render("vip={{ .Config.vip }}").unwrap(), "vip=true"); assert_eq!( - c.render("{{ if .Config.freeleech }}yes{{ else }}no{{ end }}") - .unwrap(), + c.render(&parsed("fl={{ .Config.freeleech }}")).unwrap(), + "fl=false" + ); + assert_eq!( + c.render(&parsed("vip={{ .Config.vip }}")).unwrap(), + "vip=true" + ); + assert_eq!( + c.render(&parsed( + "{{ if .Config.freeleech }}yes{{ else }}no{{ end }}" + )) + .unwrap(), "no" ); } #[test] -fn config_missing_renders_empty_after_load_validation() { - // WHY: load-time validate() rejects undeclared keys, so render only - // ever sees a declared-but-unset key — which is false-valued and - // renders empty, matching upstream's missing-variable behavior. - assert_eq!(ctx().render("[{{ .Config.missing }}]").unwrap(), "[]"); +fn config_declared_but_unset_renders_empty() { + // WHY: parse rejects undeclared keys at load, so render only ever sees a + // declared-but-unset key — which is false-valued and renders empty, + // matching upstream's missing-variable behavior. + assert_eq!( + ctx().render(&parsed("[{{ .Config.missing }}]")).unwrap(), + "[]" + ); } #[test] fn query_field_lookup_and_default_empty() { - assert_eq!(ctx().render("S{{ .Query.Season }}").unwrap(), "S3"); - assert_eq!(ctx().render("[{{ .Query.Ep }}]").unwrap(), "[]"); + assert_eq!(ctx().render(&parsed("S{{ .Query.Season }}")).unwrap(), "S3"); + assert_eq!(ctx().render(&parsed("[{{ .Query.Ep }}]")).unwrap(), "[]"); } #[test] fn result_references_read_the_row_scope() { let mut c = ctx(); c.result.insert("year".to_string(), "2024".to_string()); - assert_eq!(c.render("{{ .Result.year }}").unwrap(), "2024"); - assert_eq!(c.render("[{{ .Result.missing }}]").unwrap(), "[]"); + assert_eq!(c.render(&parsed_row("{{ .Result.year }}")).unwrap(), "2024"); + assert_eq!( + c.render(&parsed_row("[{{ .Result.missing }}]")).unwrap(), + "[]" + ); assert_eq!( - c.render("{{ or .Result.missing .Result.year }}").unwrap(), + c.render(&parsed_row("{{ or .Result.missing .Result.year }}")) + .unwrap(), "2024" ); } #[test] -fn unsupported_constructs_error() { +fn unsupported_constructs_rejected_at_parse() { for tmpl in [ "{{ .Keywords | tolower }}", "{{ with .x }}{{ end }}", "{{ not .Keywords }}", "{{ .Result.date | jsomething }}", ] { - assert!(ctx().render(tmpl).is_err(), "should reject {tmpl}"); + assert!( + ParsedTemplate::parse(tmpl, &[]).is_err(), + "should reject {tmpl}" + ); } } #[test] -fn unclosed_braces_error() { - assert!(ctx().render("{{ .Keywords").is_err()); +fn unclosed_braces_rejected_at_parse() { + assert!(ParsedTemplate::parse("{{ .Keywords", &[]).is_err()); } #[test] fn multiple_expressions() { assert_eq!( - ctx().render("{{ .Keywords }}-{{ .Config.sort }}").unwrap(), + ctx() + .render(&parsed("{{ .Keywords }}-{{ .Config.sort }}")) + .unwrap(), "test query-created" ); } @@ -127,24 +169,25 @@ fn render_url_encodes_expansions_but_not_structure() { ..ctx() }; assert_eq!( - c.render_url("/browse.php?search={{ .Keywords }}&cat=0") + c.render_url(&parsed("/browse.php?search={{ .Keywords }}&cat=0")) .unwrap(), "/browse.php?search=AT%26T+%231&cat=0" ); // WHY: join output is data too — an "&" separator must not // masquerade as a query delimiter. assert_eq!( - c.render_url("{{ join .Categories \"&\" }}").unwrap(), + c.render_url(&parsed("{{ join .Categories \"&\" }}")) + .unwrap(), "6%2612" ); } #[test] -fn validate_accepts_known_and_rejects_unknown() { - assert!(validate("{{ .Keywords }} {{ .Config.sort }}", &["sort"]).is_ok()); - assert!(validate("{{ .Config.cookie }}", &[]).is_ok()); - assert!(validate("{{ .Config.nope }}", &["sort"]).is_err()); - assert!(validate("{{ if .x }}{{ end }}", &[]).is_err()); +fn parse_accepts_known_and_rejects_unknown_config_keys() { + assert!(ParsedTemplate::parse("{{ .Keywords }} {{ .Config.sort }}", &["sort"]).is_ok()); + assert!(ParsedTemplate::parse("{{ .Config.cookie }}", &[]).is_ok()); + assert!(ParsedTemplate::parse("{{ .Config.nope }}", &["sort"]).is_err()); + assert!(ParsedTemplate::parse("{{ if .x }}{{ end }}", &[]).is_err()); } // ── block constructs (#513) ────────────────────────────────────────── @@ -171,13 +214,17 @@ fn if_else_end_selects_branch_on_checkbox_truthiness() { // "false" string as truthy. let c = block_ctx(); assert_eq!( - c.render("{{ if .Config.freeleech }}fl=1{{ else }}fl=0{{ end }}") - .unwrap(), + c.render(&parsed( + "{{ if .Config.freeleech }}fl=1{{ else }}fl=0{{ end }}" + )) + .unwrap(), "fl=1" ); assert_eq!( - c.render("{{ if .Config.multilang }}ml=1{{ else }}ml=0{{ end }}") - .unwrap(), + c.render(&parsed( + "{{ if .Config.multilang }}ml=1{{ else }}ml=0{{ end }}" + )) + .unwrap(), "ml=0" ); } @@ -186,28 +233,36 @@ fn if_else_end_selects_branch_on_checkbox_truthiness() { fn if_supports_and_or_eq_ne_and_parens() { let c = block_ctx(); assert_eq!( - c.render("{{ if and .Keywords .Config.freeleech }}yes{{ else }}no{{ end }}") - .unwrap(), + c.render(&parsed( + "{{ if and .Keywords .Config.freeleech }}yes{{ else }}no{{ end }}" + )) + .unwrap(), "yes" ); assert_eq!( - c.render("{{ if or .Config.multilang .Config.freeleech }}yes{{ else }}no{{ end }}") - .unwrap(), + c.render(&parsed( + "{{ if or .Config.multilang .Config.freeleech }}yes{{ else }}no{{ end }}" + )) + .unwrap(), "yes" ); assert_eq!( - c.render("{{ if eq .Config.sort \"created\" }}new{{ else }}old{{ end }}") - .unwrap(), + c.render(&parsed( + "{{ if eq .Config.sort \"created\" }}new{{ else }}old{{ end }}" + )) + .unwrap(), "new" ); assert_eq!( - c.render("{{ if ne .Config.sort \"score\" }}ok{{ end }}") + c.render(&parsed("{{ if ne .Config.sort \"score\" }}ok{{ end }}")) .unwrap(), "ok" ); assert_eq!( - c.render("{{ if and (eq .Config.sort \"created\") .Keywords }}both{{ end }}") - .unwrap(), + c.render(&parsed( + "{{ if and (eq .Config.sort \"created\") .Keywords }}both{{ end }}" + )) + .unwrap(), "both" ); } @@ -216,12 +271,14 @@ fn if_supports_and_or_eq_ne_and_parens() { fn if_missing_query_field_is_false() { let c = block_ctx(); assert_eq!( - c.render("{{ if .Query.IMDBID }}imdb{{ else }}keywords{{ end }}") - .unwrap(), + c.render(&parsed( + "{{ if .Query.IMDBID }}imdb{{ else }}keywords{{ end }}" + )) + .unwrap(), "keywords" ); assert_eq!( - c.render("{{ if eq .Query.IMDBID .False }}no-id{{ end }}") + c.render(&parsed("{{ if eq .Query.IMDBID .False }}no-id{{ end }}")) .unwrap(), "no-id" ); @@ -231,7 +288,7 @@ fn if_missing_query_field_is_false() { fn range_categories_repeats_body_with_dot() { let c = block_ctx(); assert_eq!( - c.render("{{ range .Categories }}&cat[]={{ . }}{{ end }}") + c.render(&parsed("{{ range .Categories }}&cat[]={{ . }}{{ end }}")) .unwrap(), "&cat[]=6&cat[]=12" ); @@ -240,19 +297,21 @@ fn range_categories_repeats_body_with_dot() { #[test] fn nested_if_blocks_evaluate() { let c = block_ctx(); - let tmpl = "{{ if .Keywords }}k{{ if .Config.freeleech }}+fl{{ end }}{{ end }}"; - assert_eq!(c.render(tmpl).unwrap(), "k+fl"); + let tmpl = parsed("{{ if .Keywords }}k{{ if .Config.freeleech }}+fl{{ end }}{{ end }}"); + assert_eq!(c.render(&tmpl).unwrap(), "k+fl"); } #[test] fn or_as_value_returns_first_truthy() { let c = block_ctx(); assert_eq!( - c.render("{{ or .Query.IMDBID .Keywords }}").unwrap(), + c.render(&parsed("{{ or .Query.IMDBID .Keywords }}")) + .unwrap(), "test query" ); assert_eq!( - c.render("{{ or .Config.multilang .Config.sort }}").unwrap(), + c.render(&parsed("{{ or .Config.multilang .Config.sort }}")) + .unwrap(), "created" ); } @@ -264,47 +323,52 @@ fn render_url_encodes_expansions_inside_taken_branch() { ..block_ctx() }; assert_eq!( - c.render_url("{{ if .Keywords }}/search?q={{ .Keywords }}{{ else }}/browse{{ end }}") - .unwrap(), + c.render_url(&parsed( + "{{ if .Keywords }}/search?q={{ .Keywords }}{{ else }}/browse{{ end }}" + )) + .unwrap(), "/search?q=a%26b" ); } #[test] -fn unbalanced_blocks_error() { - let c = block_ctx(); - assert!(c.render("{{ if .Keywords }}x").is_err()); - assert!(c.render("{{ end }}").is_err()); - assert!(c.render("{{ else }}").is_err()); - assert!(c.render("{{ range .Categories }}{{ . }}").is_err()); +fn unbalanced_blocks_rejected_at_parse() { + assert!(ParsedTemplate::parse("{{ if .Keywords }}x", CONFIG_KEYS).is_err()); + assert!(ParsedTemplate::parse("{{ end }}", CONFIG_KEYS).is_err()); + assert!(ParsedTemplate::parse("{{ else }}", CONFIG_KEYS).is_err()); + assert!(ParsedTemplate::parse("{{ range .Categories }}{{ . }}", CONFIG_KEYS).is_err()); } #[test] -fn dot_and_range_misuse_error() { - let c = block_ctx(); - assert!(c.render("{{ . }}").is_err()); - assert!(c.render("{{ range .Keywords }}x{{ end }}").is_err()); +fn dot_and_range_misuse_rejected_at_parse() { + assert!(ParsedTemplate::parse("{{ . }}", CONFIG_KEYS).is_err()); + assert!(ParsedTemplate::parse("{{ range .Keywords }}x{{ end }}", CONFIG_KEYS).is_err()); assert!( - c.render("{{ if .Keywords }}x{{ else }}y{{ else }}z{{ end }}") - .is_err() + ParsedTemplate::parse( + "{{ if .Keywords }}x{{ else }}y{{ else }}z{{ end }}", + CONFIG_KEYS + ) + .is_err() ); } #[test] -fn validate_checks_condition_atoms() { - assert!(validate("{{ if .Config.sort }}a{{ end }}", &["sort"]).is_ok()); - assert!(validate("{{ if .Config.nope }}a{{ end }}", &["sort"]).is_err()); - assert!(validate("{{ range .Categories }}{{ . }}{{ end }}", &[]).is_ok()); - assert!(validate("{{ range .Keywords }}x{{ end }}", &[]).is_err()); - assert!(validate("{{ . }}", &[]).is_err()); - assert!(validate("{{ if .Keywords }}x", &[]).is_err()); +fn parse_checks_condition_atoms() { + assert!(ParsedTemplate::parse("{{ if .Config.sort }}a{{ end }}", &["sort"]).is_ok()); + assert!(ParsedTemplate::parse("{{ if .Config.nope }}a{{ end }}", &["sort"]).is_err()); + assert!(ParsedTemplate::parse("{{ range .Categories }}{{ . }}{{ end }}", &[]).is_ok()); + assert!(ParsedTemplate::parse("{{ range .Keywords }}x{{ end }}", &[]).is_err()); + assert!(ParsedTemplate::parse("{{ . }}", &[]).is_err()); + assert!(ParsedTemplate::parse("{{ if .Keywords }}x", &[]).is_err()); } #[test] -fn validate_row_scoped_gates_result_references() { +fn parse_row_scoped_gates_result_references() { let fields = vec!["title".to_string(), "year".to_string()]; - assert!(validate("{{ .Result.year }}", &[]).is_err()); - assert!(validate_row_scoped("{{ .Result.year }}", &[], &fields).is_ok()); - assert!(validate_row_scoped("{{ .Result.nope }}", &[], &fields).is_err()); - assert!(validate_row_scoped("{{ if .Result.year }}y{{ end }}", &[], &fields).is_ok()); + assert!(ParsedTemplate::parse("{{ .Result.year }}", &[]).is_err()); + assert!(ParsedTemplate::parse_row_scoped("{{ .Result.year }}", &[], &fields).is_ok()); + assert!(ParsedTemplate::parse_row_scoped("{{ .Result.nope }}", &[], &fields).is_err()); + assert!( + ParsedTemplate::parse_row_scoped("{{ if .Result.year }}y{{ end }}", &[], &fields).is_ok() + ); } diff --git a/crates/eksetasis/src/client/cardigann/tests.rs b/crates/eksetasis/src/client/cardigann/tests.rs index cfab130e..4d4c16cd 100644 --- a/crates/eksetasis/src/client/cardigann/tests.rs +++ b/crates/eksetasis/src/client/cardigann/tests.rs @@ -103,7 +103,7 @@ const SAMPLE_HTML: &str = r#" "#; -fn definition(yaml: &str) -> Arc { +fn definition(yaml: &str) -> Arc { Arc::new(definition::parse_definition(yaml, "test").unwrap()) }