diff --git a/ftl/core/search.ftl b/ftl/core/search.ftl index fcae80877..f4dafcf60 100644 --- a/ftl/core/search.ftl +++ b/ftl/core/search.ftl @@ -1,30 +1,28 @@ ## Errors shown when invalid search input is encountered. ## Text wrapped in `backticks` is literal search input, and should generally not to be altered. +## It's ok to change quotes however, eg: +## "`{ $context }`" => 「`{ $context }`」 search-invalid-search = Invalid search: { $reason } search-misplaced-and = an `and` was found but it is not connecting two search terms. If you want to search for the word itself, wrap it in double quotes: `"and"`. search-misplaced-or = an `or` was found but it is not connecting two search terms. If you want to search for the word itself, wrap it in double quotes: `"or"`. # Here, the ellipsis "..." may be localised. search-empty-group = a group `(...)` was found, but there was nothing between the brackets to search for. If you want to search for literal brackets, wrap them in double quotes: `"( )"`. -search-unopened-group = a closing bracket `)` was found, but there was no opening bracket `(` preceding it. If you want to search for the literal `)`, wrap it in double quotes or prepend a backslash: `")"` or `\)`. -search-unclosed-group = an opening bracket `(` was found, but there was no closing bracket `)` following it. If you want to search for the literal `(`, wrap it in double quotes or prepend a backslash: `"("` or `\(` . -search-empty-quote = a pair of double quotes `""` was found but there was nothing between them to search for. If you want to search for literal double quotes, prepend backslashes: `\"\"`. -search-unclosed-quote = an opening double quote `"` was found but there was no second one to close it. If you want to search for the literal `"`, prepend a backslash: `\"`. -search-missing-key = a colon `:` was found but there was no keyword preceding it. If you want to search for the literal `:`, prepend a backslash: `\:`. -search-unknown-escape = the escape sequence `{ $val }` is not defined. If you want to search for the literal backslash `\`, prepend another one: `\\`. -search-invalid-id-list = note or card id lists must be comma-separated numbers. +search-unopened-group = a closing bracket `)` was found, but there was no opening bracket `(` preceding it. If you want to search for a literal `)`, wrap it in double quotes or prepend a backslash: `")"` or `\)`. +search-unclosed-group = an opening bracket `(` was found, but there was no closing bracket `)` following it. If you want to search for a literal `(`, wrap it in double quotes or prepend a backslash: `"("` or `\(` . +search-empty-quote = a pair of double quotes `""` was found, but there was nothing between them to search for. If you want to search for literal double quotes, prepend backslashes: `\"\"`. +search-unclosed-quote = an opening double quote `"` was found, but there was no second one to close it. If you want to search for a literal `"`, prepend a backslash: `\"`. +search-missing-key = a colon `:` was found, but there was no keyword preceding it. If you want to search for a literal `:`, prepend a backslash: `\:`. +search-unknown-escape = the escape sequence `{ $val }` is not defined. If you want to search for a literal backslash `\`, prepend another one: `\\`. search-invalid-argument = `{ $term }` was given an invalid argument '`{ $argument }`'. search-invalid-flag = `flag:` must be followed by a valid flag number: `1` (red), `2` (orange), `3` (green), `4` (blue) or `0` (no flag). -search-invalid-followed-by-positive-days = `{ $term }` must be followed by a positive number of days. -search-invalid-rated-days = `rated:` must be followed by a positive number of days. -search-invalid-rated-ease = `{ $val }:` must be followed by `1` (again), `2` (hard), `3` (good) or `4` (easy). -search-invalid-prop-operator = `prop:{ $val }` must be followed by one of the comparison operators: `=`, `!=`, `<`, `>`, `<=` or `>=`. -search-invalid-prop-float = `prop:{ $val }` must be followed by a decimal number. -search-invalid-prop-integer = `prop:{ $val }` must be followed by a whole number. -search-invalid-prop-unsigned = `prop:{ $val }` must be followed by a non-negative whole number. -search-invalid-did = `did:` must be followed by a valid deck id. -search-invalid-mid = `mid:` must be followed by a note type id. +search-invalid-prop-operator = `prop:{ $val }` must be followed by one of the following comparison operators: `=`, `!=`, `<`, `>`, `<=` or `>=`. search-invalid-other = please check for typing mistakes. +search-invalid-number = expected a number in "`{ $context }`", but found "`{ $provided }`". +search-invalid-whole-number = expected a whole number in "`{ $context }`", but found "`{ $provided }`". +search-invalid-positive-whole-number = expected a positive whole number in "`{ $context }`", but found "`{ $provided }`". +search-invalid-negative-whole-number = expected a whole number less than or equal to 0 in "`{ $context }`", but found "`{ $provided }`". +search-invalid-answer-button = expected an answer button between 1-4 in "`{ $context }`", but found "`{ $provided }`". ## Column labels in browse screen diff --git a/ftl/format.py b/ftl/format.py index b353578db..1695dfa38 100644 --- a/ftl/format.py +++ b/ftl/format.py @@ -42,7 +42,7 @@ def check_missing_terms(path: str) -> bool: def check_file(path: str, fix: bool) -> bool: "True if file is ok." - orig_text = open(path).read() + orig_text = open(path, encoding="utf8").read() obj = parse(orig_text, with_spans=False) # make sure there's no junk for ent in obj.body: diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 3f321fbbe..2c087d63f 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -139,7 +139,6 @@ impl AnkiError { tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], ) .into(), - SearchErrorKind::InvalidIdList => i18n.tr(TR::SearchInvalidIdList), SearchErrorKind::InvalidState(state) => i18n .trn( TR::SearchInvalidArgument, @@ -147,32 +146,6 @@ impl AnkiError { ) .into(), SearchErrorKind::InvalidFlag => i18n.tr(TR::SearchInvalidFlag), - SearchErrorKind::InvalidAdded => i18n - .trn( - TR::SearchInvalidFollowedByPositiveDays, - tr_strs!("term" => "added:"), - ) - .into(), - SearchErrorKind::InvalidEdited => i18n - .trn( - TR::SearchInvalidFollowedByPositiveDays, - tr_strs!("term" => "edited:"), - ) - .into(), - SearchErrorKind::InvalidRatedDays => i18n.tr(TR::SearchInvalidRatedDays), - SearchErrorKind::InvalidRatedEase(ctx) => i18n - .trn(TR::SearchInvalidRatedEase, tr_strs!["val"=>(ctx)]) - .into(), - SearchErrorKind::InvalidResched => i18n - .trn( - TR::SearchInvalidFollowedByPositiveDays, - tr_strs!("term" => "resched:"), - ) - .into(), - SearchErrorKind::InvalidDupeMid | SearchErrorKind::InvalidDupeText => { - // this is an undocumented search keyword, so no translation - "`dupe:` arguments were invalid".into() - } SearchErrorKind::InvalidPropProperty(prop) => i18n .trn( TR::SearchInvalidArgument, @@ -182,29 +155,39 @@ impl AnkiError { SearchErrorKind::InvalidPropOperator(ctx) => i18n .trn(TR::SearchInvalidPropOperator, tr_strs!["val"=>(ctx)]) .into(), - SearchErrorKind::InvalidPropFloat(ctx) => i18n - .trn( - TR::SearchInvalidPropFloat, - tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], - ) - .into(), - SearchErrorKind::InvalidPropInteger(ctx) => i18n - .trn( - TR::SearchInvalidPropInteger, - tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], - ) - .into(), - SearchErrorKind::InvalidPropUnsigned(ctx) => i18n - .trn( - TR::SearchInvalidPropUnsigned, - tr_strs!["val"=>(htmlescape::encode_minimal(ctx))], - ) - .into(), - SearchErrorKind::InvalidDid => i18n.tr(TR::SearchInvalidDid), - SearchErrorKind::InvalidMid => i18n.tr(TR::SearchInvalidMid), SearchErrorKind::Regex(text) => text.into(), SearchErrorKind::Other(Some(info)) => info.into(), SearchErrorKind::Other(None) => i18n.tr(TR::SearchInvalidOther), + SearchErrorKind::InvalidNumber { provided, context } => i18n + .trn( + TR::SearchInvalidNumber, + tr_strs!["provided"=>provided, "context"=>context], + ) + .into(), + SearchErrorKind::InvalidWholeNumber { provided, context } => i18n + .trn( + TR::SearchInvalidWholeNumber, + tr_strs!["provided"=>provided, "context"=>context], + ) + .into(), + SearchErrorKind::InvalidPositiveWholeNumber { provided, context } => i18n + .trn( + TR::SearchInvalidPositiveWholeNumber, + tr_strs!["provided"=>provided, "context"=>context], + ) + .into(), + SearchErrorKind::InvalidNegativeWholeNumber { provided, context } => i18n + .trn( + TR::SearchInvalidNegativeWholeNumber, + tr_strs!["provided"=>provided, "context"=>context], + ) + .into(), + SearchErrorKind::InvalidAnswerButton { provided, context } => i18n + .trn( + TR::SearchInvalidAnswerButton, + tr_strs!["provided"=>provided, "context"=>context], + ) + .into(), }; i18n.trn( TR::SearchInvalidSearch, @@ -439,23 +422,15 @@ pub enum SearchErrorKind { UnclosedQuote, MissingKey, UnknownEscape(String), - InvalidIdList, InvalidState(String), InvalidFlag, - InvalidAdded, - InvalidEdited, - InvalidRatedDays, - InvalidRatedEase(String), - InvalidDupeMid, - InvalidDupeText, - InvalidResched, InvalidPropProperty(String), InvalidPropOperator(String), - InvalidPropFloat(String), - InvalidPropInteger(String), - InvalidPropUnsigned(String), - InvalidDid, - InvalidMid, + InvalidNumber { provided: String, context: String }, + InvalidWholeNumber { provided: String, context: String }, + InvalidPositiveWholeNumber { provided: String, context: String }, + InvalidNegativeWholeNumber { provided: String, context: String }, + InvalidAnswerButton { provided: String, context: String }, Regex(String), Other(Option), } diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 6fdff4d8b..e9b6a377b 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -318,8 +318,8 @@ fn search_node_for_text_with_argument<'a>( "is" => parse_state(val)?, "did" => parse_did(val)?, "mid" => parse_mid(val)?, - "nid" => SearchNode::NoteIDs(check_id_list(val)?), - "cid" => SearchNode::CardIDs(check_id_list(val)?), + "nid" => SearchNode::NoteIDs(check_id_list(val, key)?), + "cid" => SearchNode::CardIDs(check_id_list(val, key)?), "re" => SearchNode::Regex(unescape_quotes(val)), "nc" => SearchNode::NoCombining(unescape(val)?), "w" => SearchNode::WordBoundary(unescape(val)?), @@ -351,18 +351,14 @@ fn parse_flag(s: &str) -> ParseResult { /// eg resched:3 fn parse_resched(s: &str) -> ParseResult { - if let Ok(days) = s.parse::() { - Ok(SearchNode::Rated { - days, - ease: EaseKind::ManualReschedule, - }) - } else { - Err(parse_failure(s, FailKind::InvalidResched)) - } + parse_u32(s, "resched:").map(|days| SearchNode::Rated { + days, + ease: EaseKind::ManualReschedule, + }) } /// eg prop:ivl>3, prop:ease!=2.5 -fn parse_prop(s: &str) -> ParseResult { +fn parse_prop(prop_clause: &str) -> ParseResult { let (tail, prop) = alt::<_, _, ParseError, _>(( tag("ivl"), tag("due"), @@ -372,8 +368,13 @@ fn parse_prop(s: &str) -> ParseResult { tag("pos"), tag("rated"), tag("resched"), - ))(s) - .map_err(|_| parse_failure(s, FailKind::InvalidPropProperty(s.into())))?; + ))(prop_clause) + .map_err(|_| { + parse_failure( + prop_clause, + FailKind::InvalidPropProperty(prop_clause.into()), + ) + })?; let (num, operator) = alt::<_, _, ParseError, _>(( tag("<="), @@ -383,87 +384,21 @@ fn parse_prop(s: &str) -> ParseResult { tag("<"), tag(">"), ))(tail) - .map_err(|_| parse_failure(s, FailKind::InvalidPropOperator(prop.to_string())))?; + .map_err(|_| parse_failure(prop_clause, FailKind::InvalidPropOperator(prop.to_string())))?; - let kind = if prop == "ease" { - if let Ok(f) = num.parse::() { - PropertyKind::Ease(f) - } else { - return Err(parse_failure( - s, - FailKind::InvalidPropFloat(format!("{}{}", prop, operator)), - )); - } - } else if prop == "due" { - if let Ok(i) = num.parse::() { - PropertyKind::Due(i) - } else { - return Err(parse_failure( - s, - FailKind::InvalidPropInteger(format!("{}{}", prop, operator)), - )); - } - } else if prop == "rated" { - let mut it = num.splitn(2, ':'); - - let days: i32 = if let Ok(i) = it.next().unwrap().parse::() { - i.min(0) - } else { - return Err(parse_failure( - s, - FailKind::InvalidPropInteger(format!("{}{}", prop, operator)), - )); - }; - - let ease = match it.next() { - Some(v) => { - if let Ok(u) = v.parse::() { - if (1..5).contains(&u) { - EaseKind::AnswerButton(u) - } else { - return Err(parse_failure( - s, - FailKind::InvalidRatedEase(format!( - "prop:{}{}{}", - prop, - operator, - days.to_string() - )), - )); - } - } else { - return Err(parse_failure( - s, - FailKind::InvalidPropInteger(format!("{}{}", prop, operator)), - )); - } - } - None => EaseKind::AnyAnswerButton, - }; - - PropertyKind::Rated(days, ease) - } else if prop == "resched" { - if let Ok(days) = num.parse::() { - PropertyKind::Rated(days.min(0), EaseKind::ManualReschedule) - } else { - return Err(parse_failure( - s, - FailKind::InvalidPropInteger(format!("{}{}", prop, operator)), - )); - } - } else if let Ok(u) = num.parse::() { - match prop { - "ivl" => PropertyKind::Interval(u), - "reps" => PropertyKind::Reps(u), - "lapses" => PropertyKind::Lapses(u), - "pos" => PropertyKind::Position(u), - _ => unreachable!(), - } - } else { - return Err(parse_failure( - s, - FailKind::InvalidPropUnsigned(format!("{}{}", prop, operator)), - )); + let kind = match prop { + "ease" => PropertyKind::Ease(parse_f32(num, prop_clause)?), + "due" => PropertyKind::Due(parse_i32(num, prop_clause)?), + "rated" => parse_prop_rated(num, prop_clause)?, + "resched" => PropertyKind::Rated( + parse_negative_i32(num, prop_clause)?, + EaseKind::ManualReschedule, + ), + "ivl" => PropertyKind::Interval(parse_u32(num, prop_clause)?), + "reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?), + "lapses" => PropertyKind::Lapses(parse_u32(num, prop_clause)?), + "pos" => PropertyKind::Position(parse_u32(num, prop_clause)?), + _ => unreachable!(), }; Ok(SearchNode::Property { @@ -472,53 +407,114 @@ fn parse_prop(s: &str) -> ParseResult { }) } +fn parse_u32<'a>(num: &str, context: &'a str) -> ParseResult<'a, u32> { + num.parse().map_err(|_e| { + parse_failure( + context, + FailKind::InvalidPositiveWholeNumber { + context: context.into(), + provided: num.into(), + }, + ) + }) +} + +fn parse_i32<'a>(num: &str, context: &'a str) -> ParseResult<'a, i32> { + num.parse().map_err(|_e| { + parse_failure( + context, + FailKind::InvalidWholeNumber { + context: context.into(), + provided: num.into(), + }, + ) + }) +} + +fn parse_negative_i32<'a>(num: &str, context: &'a str) -> ParseResult<'a, i32> { + num.parse() + .map_err(|_| ()) + .and_then(|n| if n > 0 { Err(()) } else { Ok(n) }) + .map_err(|_| { + parse_failure( + context, + FailKind::InvalidNegativeWholeNumber { + context: context.into(), + provided: num.into(), + }, + ) + }) +} + +fn parse_f32<'a>(num: &str, context: &'a str) -> ParseResult<'a, f32> { + num.parse().map_err(|_e| { + parse_failure( + context, + FailKind::InvalidNumber { + context: context.into(), + provided: num.into(), + }, + ) + }) +} + +fn parse_i64<'a>(num: &str, context: &'a str) -> ParseResult<'a, i64> { + num.parse().map_err(|_e| { + parse_failure( + context, + FailKind::InvalidWholeNumber { + context: context.into(), + provided: num.into(), + }, + ) + }) +} + +fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'a, EaseKind> { + Ok(if let Some(num) = num { + EaseKind::AnswerButton( + num.parse() + .map_err(|_| ()) + .and_then(|n| if matches!(n, 1..=4) { Ok(n) } else { Err(()) }) + .map_err(|_| { + parse_failure( + context, + FailKind::InvalidAnswerButton { + context: context.into(), + provided: num.into(), + }, + ) + })?, + ) + } else { + EaseKind::AnyAnswerButton + }) +} + +fn parse_prop_rated<'a>(num: &str, context: &'a str) -> ParseResult<'a, PropertyKind> { + let mut it = num.splitn(2, ':'); + let days = parse_negative_i32(it.next().unwrap(), context)?; + let button = parse_answer_button(it.next(), context)?; + Ok(PropertyKind::Rated(days, button)) +} + /// eg added:1 fn parse_added(s: &str) -> ParseResult { - if let Ok(days) = s.parse::() { - Ok(SearchNode::AddedInDays(days.max(1))) - } else { - Err(parse_failure(s, FailKind::InvalidAdded)) - } + parse_u32(s, "added:").map(|n| SearchNode::AddedInDays(n.max(1))) } /// eg edited:1 fn parse_edited(s: &str) -> ParseResult { - if let Ok(days) = s.parse::() { - Ok(SearchNode::EditedInDays(days.max(1))) - } else { - Err(parse_failure(s, FailKind::InvalidEdited)) - } + parse_u32(s, "edited:").map(|n| SearchNode::EditedInDays(n.max(1))) } /// eg rated:3 or rated:10:2 /// second arg must be between 1-4 fn parse_rated(s: &str) -> ParseResult { let mut it = s.splitn(2, ':'); - if let Ok(days) = it.next().unwrap().parse::() { - let days = days.max(1); - let ease = if let Some(tail) = it.next() { - if let Ok(u) = tail.parse::() { - if u > 0 && u < 5 { - EaseKind::AnswerButton(u) - } else { - return Err(parse_failure( - s, - FailKind::InvalidRatedEase(format!("rated:{}", days.to_string())), - )); - } - } else { - return Err(parse_failure( - s, - FailKind::InvalidRatedEase(format!("rated:{}", days.to_string())), - )); - } - } else { - EaseKind::AnyAnswerButton - }; - Ok(SearchNode::Rated { days, ease }) - } else { - Err(parse_failure(s, FailKind::InvalidRatedDays)) - } + let days = parse_u32(it.next().unwrap(), "rated:")?.max(1); + let button = parse_answer_button(it.next(), s)?; + Ok(SearchNode::Rated { days, ease: button }) } /// eg is:due @@ -538,48 +534,48 @@ fn parse_state(s: &str) -> ParseResult { } fn parse_did(s: &str) -> ParseResult { - if let Ok(did) = s.parse() { - Ok(SearchNode::DeckID(did)) - } else { - Err(parse_failure(s, FailKind::InvalidDid)) - } + parse_i64(s, "did:").map(|n| SearchNode::DeckID(n.into())) } fn parse_mid(s: &str) -> ParseResult { - if let Ok(mid) = s.parse() { - Ok(SearchNode::NoteTypeID(mid)) - } else { - Err(parse_failure(s, FailKind::InvalidMid)) - } + parse_i64(s, "mid:").map(|n| SearchNode::NoteTypeID(n.into())) } /// ensure a list of ids contains only numbers and commas, returning unchanged if true /// used by nid: and cid: -fn check_id_list(s: &str) -> ParseResult<&str> { +fn check_id_list<'a, 'b>(s: &'a str, context: &'b str) -> ParseResult<'a, &'a str> { lazy_static! { static ref RE: Regex = Regex::new(r"^(\d+,)*\d+$").unwrap(); } if RE.is_match(s) { Ok(s) } else { - Err(parse_failure(s, FailKind::InvalidIdList)) + Err(parse_failure( + s, + // id lists are undocumented, so no translation + FailKind::Other(Some(format!( + "expected only digits and commas in {}:", + context + ))), + )) } } /// eg dupe:1231,hello fn parse_dupe(s: &str) -> ParseResult { let mut it = s.splitn(2, ','); - if let Ok(mid) = it.next().unwrap().parse::() { - if let Some(text) = it.next() { - Ok(SearchNode::Duplicates { - note_type_id: mid, - text: unescape_quotes_and_backslashes(text), - }) - } else { - Err(parse_failure(s, FailKind::InvalidDupeText)) - } + let ntid = parse_i64(it.next().unwrap(), s)?; + if let Some(text) = it.next() { + Ok(SearchNode::Duplicates { + note_type_id: ntid.into(), + text: unescape_quotes_and_backslashes(text), + }) } else { - Err(parse_failure(s, FailKind::InvalidDupeMid)) + // this is an undocumented keyword, so no translation/help + Err(parse_failure( + s, + FailKind::Other(Some("invalid 'dupe:' search".into())), + )) } } @@ -680,6 +676,8 @@ fn is_parser_escape(txt: &str) -> bool { #[cfg(test)] mod test { + use crate::err::SearchErrorKind; + use super::*; #[test] @@ -847,6 +845,14 @@ mod test { assert_eq!(parse(input), Err(AnkiError::SearchError(kind))); } + fn failkind(input: &str) -> SearchErrorKind { + if let Err(AnkiError::SearchError(err)) = parse(input) { + err + } else { + panic!("expected search error"); + } + } + assert_err_kind("foo and", MisplacedAnd); assert_err_kind("and foo", MisplacedAnd); assert_err_kind("and", MisplacedAnd); @@ -887,14 +893,18 @@ mod test { assert_err_kind(r"\ ", UnknownEscape(r"\".to_string())); assert_err_kind(r#""\ ""#, UnknownEscape(r"\ ".to_string())); - assert_err_kind("nid:1_2,3", InvalidIdList); - assert_err_kind("nid:1,2,x", InvalidIdList); - assert_err_kind("nid:,2,3", InvalidIdList); - assert_err_kind("nid:1,2,", InvalidIdList); - assert_err_kind("cid:1_2,3", InvalidIdList); - assert_err_kind("cid:1,2,x", InvalidIdList); - assert_err_kind("cid:,2,3", InvalidIdList); - assert_err_kind("cid:1,2,", InvalidIdList); + for term in &[ + "nid:1_2,3", + "nid:1,2,x", + "nid:,2,3", + "nid:1,2,", + "cid:1_2,3", + "cid:1,2,x", + "cid:,2,3", + "cid:1,2,", + ] { + assert!(matches!(failkind(term), SearchErrorKind::Other(_))); + } assert_err_kind("is:foo", InvalidState("foo".into())); assert_err_kind("is:DUE", InvalidState("DUE".into())); @@ -908,36 +918,29 @@ mod test { assert_err_kind("flag:5", InvalidFlag); assert_err_kind("flag:1.1", InvalidFlag); - assert_err_kind("added:1.1", InvalidAdded); - assert_err_kind("added:-1", InvalidAdded); - assert_err_kind("added:", InvalidAdded); - assert_err_kind("added:foo", InvalidAdded); + for term in &["added", "edited", "rated", "resched"] { + assert!( + matches!(failkind(&format!("{}:1.1", term)), SearchErrorKind::InvalidPositiveWholeNumber { .. }) + ); + assert!( + matches!(failkind(&format!("{}:-1", term)), SearchErrorKind::InvalidPositiveWholeNumber { .. }) + ); + assert!( + matches!(failkind(&format!("{}:", term)), SearchErrorKind::InvalidPositiveWholeNumber { .. }) + ); + assert!( + matches!(failkind(&format!("{}:foo", term)), SearchErrorKind::InvalidPositiveWholeNumber { .. }) + ); + } - assert_err_kind("edited:1.1", InvalidEdited); - assert_err_kind("edited:-1", InvalidEdited); - assert_err_kind("edited:", InvalidEdited); - assert_err_kind("edited:foo", InvalidEdited); + assert!(matches!(failkind("rated:1:"), SearchErrorKind::InvalidAnswerButton { .. })); + assert!(matches!(failkind("rated:2:-1"), SearchErrorKind::InvalidAnswerButton { .. })); + assert!(matches!(failkind("rated:3:1.1"), SearchErrorKind::InvalidAnswerButton { .. })); + assert!(matches!(failkind("rated:0:foo"), SearchErrorKind::InvalidAnswerButton { .. })); - assert_err_kind("rated:1.1", InvalidRatedDays); - assert_err_kind("rated:-1", InvalidRatedDays); - assert_err_kind("rated:", InvalidRatedDays); - assert_err_kind("rated:foo", InvalidRatedDays); - - assert_err_kind("rated:1:", InvalidRatedEase("rated:1".to_string())); - assert_err_kind("rated:2:-1", InvalidRatedEase("rated:2".to_string())); - assert_err_kind("rated:3:1.1", InvalidRatedEase("rated:3".to_string())); - assert_err_kind("rated:0:foo", InvalidRatedEase("rated:1".to_string())); - - assert_err_kind("resched:", FailKind::InvalidResched); - assert_err_kind("resched:-1", FailKind::InvalidResched); - assert_err_kind("resched:1:1", FailKind::InvalidResched); - assert_err_kind("resched:foo", FailKind::InvalidResched); - - assert_err_kind("dupe:", InvalidDupeMid); - assert_err_kind("dupe:1.1", InvalidDupeMid); - assert_err_kind("dupe:foo", InvalidDupeMid); - - assert_err_kind("dupe:123", InvalidDupeText); + assert!(matches!(failkind("dupe:"), SearchErrorKind::InvalidWholeNumber { .. })); + assert!(matches!(failkind("dupe:1.1"), SearchErrorKind::InvalidWholeNumber { .. })); + assert!(matches!(failkind("dupe:foo"), SearchErrorKind::InvalidWholeNumber { .. })); assert_err_kind("prop:", InvalidPropProperty("".into())); assert_err_kind("prop:=1", InvalidPropProperty("=1".into())); @@ -947,20 +950,33 @@ mod test { assert_err_kind("prop:pos~1", InvalidPropOperator("pos".to_string())); assert_err_kind("prop:reps10", InvalidPropOperator("reps".to_string())); - assert_err_kind("prop:ease>", InvalidPropFloat("ease>".to_string())); - assert_err_kind("prop:ease!=one", InvalidPropFloat("ease!=".to_string())); - assert_err_kind("prop:ease<1,3", InvalidPropFloat("ease<".to_string())); + // unsigned - assert_err_kind("prop:due>", InvalidPropInteger("due>".to_string())); - assert_err_kind("prop:due=0.5", InvalidPropInteger("due=".to_string())); - assert_err_kind("prop:due", term)), SearchErrorKind::InvalidPositiveWholeNumber { .. }) + ); + assert!( + matches!(failkind(&format!("prop:{}=0.5", term)), SearchErrorKind::InvalidPositiveWholeNumber { .. }) + ); + assert!( + matches!(failkind(&format!("prop:{}!=-1", term)), SearchErrorKind::InvalidPositiveWholeNumber { .. }) + ); + assert!( + matches!(failkind(&format!("prop:{}", InvalidPropUnsigned("ivl>".to_string())); - assert_err_kind("prop:reps=1.1", InvalidPropUnsigned("reps=".to_string())); - assert_err_kind( - "prop:lapses!=-1", - InvalidPropUnsigned("lapses!=".to_string()), - ); + // signed + + assert!(matches!(failkind("prop:due>"), SearchErrorKind::InvalidWholeNumber { .. })); + assert!(matches!(failkind("prop:due=0.5"), SearchErrorKind::InvalidWholeNumber { .. })); + + // float + + assert!(matches!(failkind("prop:ease>"), SearchErrorKind::InvalidNumber { .. })); + assert!(matches!(failkind("prop:ease!=one"), SearchErrorKind::InvalidNumber { .. })); + assert!(matches!(failkind("prop:ease<1,3"), SearchErrorKind::InvalidNumber { .. })); Ok(()) }