From 4b5c864c255a614e6ece3645ea3a9a63680cc038 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 15:18:21 +0200 Subject: [PATCH 01/12] refactor: module structure for grammar / spell checking --- Cargo.lock | 68 +++++++++++ csaf-rs/Cargo.toml | 1 + csaf-rs/src/validations/mod.rs | 1 + .../utils/text_check/checkers/mod.rs | 79 ------------- .../utils/text_check/grammar/mock_grammar.rs | 83 +++++++++++++ .../src/validations/utils/text_check/mod.rs | 68 +++++++++-- .../text_check/spell/integration_tests.rs | 109 +++++++++++++++++ .../{checkers => spell}/mock_spell.rs | 5 +- .../validations/utils/text_check/spell/mod.rs | 17 +++ .../{checkers => spell}/symspell_spell.rs | 5 +- .../utils/text_check/spell/unit_tests.rs | 98 ++++++++++++++++ .../{tests/utils.rs => test_utils.rs} | 81 +++++-------- .../text_check/tests/integration_tests.rs | 86 -------------- .../validations/utils/text_check/tests/mod.rs | 3 - .../utils/text_check/tests/unit_tests.rs | 111 ------------------ .../utils/text_check/{checkers => }/utils.rs | 11 +- 16 files changed, 479 insertions(+), 347 deletions(-) delete mode 100644 csaf-rs/src/validations/utils/text_check/checkers/mod.rs create mode 100644 csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs create mode 100644 csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs rename csaf-rs/src/validations/utils/text_check/{checkers => spell}/mock_spell.rs (94%) create mode 100644 csaf-rs/src/validations/utils/text_check/spell/mod.rs rename csaf-rs/src/validations/utils/text_check/{checkers => spell}/symspell_spell.rs (96%) create mode 100644 csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs rename csaf-rs/src/validations/utils/text_check/{tests/utils.rs => test_utils.rs} (64%) delete mode 100644 csaf-rs/src/validations/utils/text_check/tests/integration_tests.rs delete mode 100644 csaf-rs/src/validations/utils/text_check/tests/mod.rs delete mode 100644 csaf-rs/src/validations/utils/text_check/tests/unit_tests.rs rename csaf-rs/src/validations/utils/text_check/{checkers => }/utils.rs (91%) diff --git a/Cargo.lock b/Cargo.lock index e69c8999..ac8fb4f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,6 +509,7 @@ dependencies = [ "regex", "regress", "rstest", + "rstest_reuse", "rust-embed", "semver", "serde", @@ -797,6 +798,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1433,6 +1445,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1492,6 +1513,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1632,6 +1683,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rstest_reuse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" +dependencies = [ + "quote", + "rand", + "syn 2.0.117", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -2438,6 +2500,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" diff --git a/csaf-rs/Cargo.toml b/csaf-rs/Cargo.toml index e7ae90fb..8e521a71 100644 --- a/csaf-rs/Cargo.toml +++ b/csaf-rs/Cargo.toml @@ -48,6 +48,7 @@ serde_json = { version = "1", features = ["preserve_order"] } [dev-dependencies] rstest = "0.26.1" +rstest_reuse = "0.7.0" criterion = { version = "0.8", features = ["html_reports"] } tempfile = "3" diff --git a/csaf-rs/src/validations/mod.rs b/csaf-rs/src/validations/mod.rs index aa004266..cba5066e 100644 --- a/csaf-rs/src/validations/mod.rs +++ b/csaf-rs/src/validations/mod.rs @@ -137,5 +137,6 @@ pub mod test_6_3_11; pub mod test_6_3_12; pub mod test_6_3_14; pub mod test_6_3_15; +pub mod test_6_3_16; pub mod test_6_3_18; pub mod test_6_3_20; diff --git a/csaf-rs/src/validations/utils/text_check/checkers/mod.rs b/csaf-rs/src/validations/utils/text_check/checkers/mod.rs deleted file mode 100644 index 4a4c4f81..00000000 --- a/csaf-rs/src/validations/utils/text_check/checkers/mod.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::csaf::types::language::ValidCsafLanguage; -use crate::validations::utils::text_check::checkers::TextCheckerMatchingError::{ - NoCheckerAvailable, UnsupportedLanguage, -}; -use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextCheckerMatchingError}; -use std::cmp::PartialEq; - -#[cfg(test)] -pub(crate) mod mock_spell; -pub(crate) mod symspell_spell; -mod utils; - -/// A backend capable of checking text for spelling / grammar issues. -pub trait TextChecker { - /// Temporary measure of quality, will be replaced later. - fn get_quality(&self) -> TemporaryTextCheckQuality; - - /// Get the lowercases primary language tags - fn get_available_languages(&self) -> Vec<&str>; - - /// Checks a single text snippet for issues of the given [`TextCheckKind`]. - /// - /// Returns a (possibly empty) vector of findings. Each finding corresponds to a - /// single lint that matches the requested check kind. - fn check_text(&self, kind: TextCheckKind, text: &str) -> Vec; -} - -/// Returns all known [`TextChecker`] implementations. -pub(crate) fn all_spell_checkers() -> Vec> { - vec![ - #[cfg(test)] - Box::new(mock_spell::MockSpellChecker), - Box::new(symspell_spell::EnglishSymspellChecker), - ] -} - -pub(crate) fn all_grammar_checkers() -> Vec> { - vec![] -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum TemporaryTextCheckQuality { - #[allow(unused)] - Good, - #[allow(unused)] - Medium, - Poor, -} - -/// TODO: Add unit tests once grammar checkers are implemented -pub(crate) fn filter_checkers( - kind: TextCheckKind, - lang: &ValidCsafLanguage, -) -> Result>, TextCheckerMatchingError> { - let all_checkers = match kind { - TextCheckKind::Spell => all_spell_checkers(), - TextCheckKind::Grammar => all_grammar_checkers(), - }; - - if all_checkers.is_empty() { - return Err(NoCheckerAvailable(kind)); - } - - let matches = all_checkers - .into_iter() - .filter(|checker| { - checker - .get_available_languages() - .iter() - .any(|avail_lang| avail_lang.eq_ignore_ascii_case(lang.primary_language())) - }) - .collect::>(); - - if matches.is_empty() { - return Err(UnsupportedLanguage(lang.primary_language().to_string())); - } - - Ok(matches) -} diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs new file mode 100644 index 00000000..b9f9fc48 --- /dev/null +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -0,0 +1,83 @@ +//! Mock grammar-checker implementation. +//! +//! This module provides a basic English grammar-checker backed by a static list of +//! known incorrect two-word sequences. + +use super::TextChecker; +use crate::validations::utils::text_check::utils::TemporaryTextCheckQuality; +use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; +use std::collections::HashSet; + +/// A mock grammar-checker for English text. +/// +/// Behavior: +/// - Only [`TextCheckKind::Grammar`] findings are produced; spell checking is not implemented. +/// - Text is tokenized by whitespace, punctuation is stripped. +/// - Consecutive word pairs are matched against a built-in list of known incorrect sequences. +/// - When a bad sequence is detected, the first word of the pair is reported as the problematic fragment. +pub struct MockGrammarChecker; + +impl TextChecker for MockGrammarChecker { + fn get_quality(&self) -> TemporaryTextCheckQuality { + TemporaryTextCheckQuality::Poor + } + + fn get_available_languages(&self) -> Vec<&str> { + vec!["en"] + } + + fn check_text(&self, kind: TextCheckKind, text: &str) -> Vec { + if kind != TextCheckKind::Grammar { + return vec![]; + } + grammar_check(text) + } +} + +fn grammar_check(text: &str) -> Vec { + let known_bad_sequences = known_bad_sequences(); + let mut findings = Vec::new(); + let mut search_from = 0; + + // Collect (lowercase, original, char_start, char_end) for each token + let mut tokens: Vec<(String, String, usize, usize)> = Vec::new(); + + for token in text.split_whitespace() { + let offset = text[search_from..].find(token).unwrap_or(0); + let token_start = search_from + offset; + search_from = token_start + token.len(); + + let trimmed = token.trim_matches(|c: char| !c.is_alphabetic()); + if trimmed.is_empty() { + continue; + } + let word_offset = trimmed.as_ptr() as usize - token.as_ptr() as usize; + let word_start = text[..token_start + word_offset].chars().count(); + let word_end = word_start + trimmed.chars().count(); + + tokens.push((trimmed.to_lowercase(), trimmed.to_string(), word_start, word_end)); + } + + // Check each consecutive pair against the bad-sequence list. + for window in tokens.windows(2) { + let (lower1, original1, start, _) = &window[0]; + let (lower2, original2, _, end) = &window[1]; + let key = format!("{lower1} {lower2}"); + if known_bad_sequences.contains(key.as_str()) { + findings.push(TextCheckFinding { + fragment: format!("{original1} {original2}"), + start: *start, + end: *end, + replacement: None, + }); + } + } + + findings +} + +fn known_bad_sequences() -> HashSet<&'static str> { + ["must followed", "for ensure", "a products"].iter().copied().collect() +} + +// TODO unit tests \ No newline at end of file diff --git a/csaf-rs/src/validations/utils/text_check/mod.rs b/csaf-rs/src/validations/utils/text_check/mod.rs index 7775935f..70eafe51 100644 --- a/csaf-rs/src/validations/utils/text_check/mod.rs +++ b/csaf-rs/src/validations/utils/text_check/mod.rs @@ -10,24 +10,43 @@ //! The [`TextChecker`] trait abstracts over the concrete language-checking engine. use crate::csaf::types::language::ValidCsafLanguage; -use crate::validations::utils::text_check::checkers::filter_checkers; -pub(crate) mod checkers; +mod grammar; +mod spell; #[cfg(test)] -mod tests; +mod test_utils; +mod utils; use crate::validation::TestFindingData; +use crate::validations::utils::text_check::TextCheckerMatchingError::{NoCheckerAvailable, UnsupportedLanguage}; +use crate::validations::utils::text_check::grammar::all_grammar_checkers; #[cfg(test)] -use crate::validations::utils::text_check::checkers::mock_spell::MockSpellChecker; -pub use checkers::TextChecker; +use crate::validations::utils::text_check::grammar::mock_grammar::MockGrammarChecker; +use crate::validations::utils::text_check::spell::all_spell_checkers; +#[cfg(test)] +use crate::validations::utils::text_check::spell::mock_spell::MockSpellChecker; +use crate::validations::utils::text_check::utils::TemporaryTextCheckQuality; + +pub trait TextChecker { + /// Temporary measure of quality, will be replaced later. + fn get_quality(&self) -> TemporaryTextCheckQuality; + + /// Get the lowercases primary language tags + fn get_available_languages(&self) -> Vec<&str>; + + /// Checks a single text snippet for issues of the given [`TextCheckKind`]. + /// + /// Returns a (possibly empty) vector of findings. Each finding corresponds to a + /// single lint that matches the requested check kind. + fn check_text(&self, kind: TextCheckKind, text: &str) -> Vec; +} /// The kind of text check to perform. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TextCheckKind { /// Spell checking only. Spell, - /// Grammar checking only (TODO not yet implemented) - #[allow(dead_code)] + /// Grammar checking only. Grammar, } @@ -85,6 +104,10 @@ pub fn select_checker( if kind == TextCheckKind::Spell { return Ok(Box::new(MockSpellChecker)); } + #[cfg(test)] + if kind == TextCheckKind::Grammar { + return Ok(Box::new(MockGrammarChecker)); + } // Prod code gets matching let checkers = filter_checkers(kind, lang)?; @@ -104,3 +127,34 @@ pub fn select_checker( Ok(checker) } + +/// TODO: Add unit tests once grammar checkers are implemented +pub(crate) fn filter_checkers( + kind: TextCheckKind, + lang: &ValidCsafLanguage, +) -> Result>, TextCheckerMatchingError> { + let all_checkers = match kind { + TextCheckKind::Spell => all_spell_checkers(), + TextCheckKind::Grammar => all_grammar_checkers(), + }; + + if all_checkers.is_empty() { + return Err(NoCheckerAvailable(kind)); + } + + let matches = all_checkers + .into_iter() + .filter(|checker| { + checker + .get_available_languages() + .iter() + .any(|avail_lang| avail_lang.eq_ignore_ascii_case(lang.primary_language())) + }) + .collect::>(); + + if matches.is_empty() { + return Err(UnsupportedLanguage(lang.primary_language().to_string())); + } + + Ok(matches) +} diff --git a/csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs b/csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs new file mode 100644 index 00000000..eae4a2b5 --- /dev/null +++ b/csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs @@ -0,0 +1,109 @@ +//! Tests that run the full 6.3.8 spell-check validation while forcing a single, specific +//! [`TextChecker`] implementation. + +use crate::csaf_traits::CsafTrait; +use crate::csaf2_0::testcases::Test6_3_8 as Test6_3_8_2_0; +use crate::csaf2_1::testcases::Test6_3_8 as Test6_3_8_2_1; +use crate::validation::TestFinding; +use crate::validations::test_6_3_08::test_6_3_8_spell_check_impl; +use crate::validations::test_6_3_08::{EXPECTED_RESULTS_2_0, EXPECTED_RESULTS_2_1, create_misspelling_finding_info}; +use crate::validations::utils::text_check::TextChecker; +use crate::validations::utils::text_check::spell::mock_spell::MockSpellChecker; +use crate::validations::utils::text_check::spell::symspell_spell::EnglishSymspellChecker; +use crate::validations::utils::text_check::test_utils::{ExpectedResultExt, NoSuggestionChecker}; +use rstest::rstest; + +/// Test-only entry point that forces the given checker instead of `select_checker`'s matching. +fn test_6_3_8_spell_check_with_checker( + doc: &impl CsafTrait, + checker: impl TextChecker + 'static, +) -> Result<(), Vec> { + test_6_3_8_spell_check_impl(doc, |_| Ok(Box::new(checker) as Box)) +} + +// Any `TextChecker` can act as its own `TestValidator`, forcing +// `check_text` to use exactly that checker. This allows us to run the CSAF 2.0 / 2.1 test suites +// separately against each of the spell checkers. Suggested replacements are discarded (see +// `NoSuggestionChecker`) since they can vary between checker implementations. +impl crate::test_validation::TestValidator for C +where + C: TextChecker + Default + Copy + 'static, + Doc: CsafTrait, +{ + fn validate(&self, doc: &Doc) -> Result<(), Vec> { + test_6_3_8_spell_check_with_checker( + doc, + NoSuggestionChecker(*self), + ) + } +} + +fn expected_results_2_0_symspell() -> crate::csaf2_0::testcases::ExpectedResults_6_3_8 { + let base = EXPECTED_RESULTS_2_0.clone(); + crate::csaf2_0::testcases::ExpectedResults_6_3_8 { + case_02: base + .case_02 + .clone() + .with_finding(create_misspelling_finding_info( + "unauthenticated", + 66, + 81, + &None, + "/vulnerabilities/0/notes/0/text", + )) + .without_finding("rood"), + case_12: base.case_12.clone().with_finding(create_misspelling_finding_info( + "unauthenticated", + 66, + 81, + &None, + "/vulnerabilities/0/notes/0/text", + )), + ..base + } +} + +fn expected_results_2_1_symspell() -> crate::csaf2_1::testcases::ExpectedResults_6_3_8 { + let base = EXPECTED_RESULTS_2_1.clone(); + crate::csaf2_1::testcases::ExpectedResults_6_3_8 { + case_02: base.case_02.clone().with_finding(create_misspelling_finding_info( + "unauthenticated", + 66, + 81, + &None, + "/vulnerabilities/0/notes/0/text", + )), + case_12: base.case_12.clone().with_finding(create_misspelling_finding_info( + "unauthenticated", + 66, + 81, + &None, + "/vulnerabilities/0/notes/0/text", + )), + ..base + } +} + +/// Runs the full 6.3.8 test suite against a single, specific checker. +/// +/// `MockSpellChecker` is also already used during the "regular" (non-integration) test run; +/// keeping it in here too for test development / test isolation. +/// +/// `EnglishSymspellChecker` is a real, basic spell checker. Its findings differ slightly from +/// the expected results. See `expected_results_2_0_symspell` / `expected_results_2_1_symspell` +/// for the diff: "rood" is an old-english word, "unauthenticated" is not in the dictionary. +#[rstest] +#[case::mock_checker(MockSpellChecker, EXPECTED_RESULTS_2_0.clone(), EXPECTED_RESULTS_2_1.clone())] +#[case::symspell_checker( + EnglishSymspellChecker, + expected_results_2_0_symspell(), + expected_results_2_1_symspell() +)] +fn test_test_6_3_8_checker_only( + #[case] _checker: C, + #[case] expected_2_0: crate::csaf2_0::testcases::ExpectedResults_6_3_8, + #[case] expected_2_1: crate::csaf2_1::testcases::ExpectedResults_6_3_8, +) { + Test6_3_8_2_0::::new().expect(expected_2_0); + Test6_3_8_2_1::::new().expect(expected_2_1); +} diff --git a/csaf-rs/src/validations/utils/text_check/checkers/mock_spell.rs b/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs similarity index 94% rename from csaf-rs/src/validations/utils/text_check/checkers/mock_spell.rs rename to csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs index 0c2fdfe6..a915e605 100644 --- a/csaf-rs/src/validations/utils/text_check/checkers/mock_spell.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs @@ -2,9 +2,8 @@ //! //! This module provides a basic English spell-checker backed by a static word list. -use crate::validations::utils::text_check::checkers::utils::tokenize_words; -use crate::validations::utils::text_check::checkers::{TemporaryTextCheckQuality, TextChecker}; -use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; +use crate::validations::utils::text_check::utils::{TemporaryTextCheckQuality, tokenize_words}; +use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextChecker}; use std::collections::HashSet; /// A mock spell-checker for English text. diff --git a/csaf-rs/src/validations/utils/text_check/spell/mod.rs b/csaf-rs/src/validations/utils/text_check/spell/mod.rs new file mode 100644 index 00000000..11dd72e7 --- /dev/null +++ b/csaf-rs/src/validations/utils/text_check/spell/mod.rs @@ -0,0 +1,17 @@ +use crate::validations::utils::text_check::TextChecker; + +#[cfg(test)] +mod integration_tests; +#[cfg(test)] +pub(crate) mod mock_spell; +pub(crate) mod symspell_spell; +#[cfg(test)] +mod unit_tests; + +pub(crate) fn all_spell_checkers() -> Vec> { + vec![ + #[cfg(test)] + Box::new(mock_spell::MockSpellChecker), + Box::new(symspell_spell::EnglishSymspellChecker), + ] +} diff --git a/csaf-rs/src/validations/utils/text_check/checkers/symspell_spell.rs b/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs similarity index 96% rename from csaf-rs/src/validations/utils/text_check/checkers/symspell_spell.rs rename to csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs index c6e6e4b8..23760dde 100644 --- a/csaf-rs/src/validations/utils/text_check/checkers/symspell_spell.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs @@ -1,9 +1,8 @@ //! English spell-checker backed by the SymSpell algorithm and static word-frequency //! dictionaries (TODO: only english for now) -use crate::validations::utils::text_check::checkers::utils::tokenize_words; -use crate::validations::utils::text_check::checkers::{TemporaryTextCheckQuality, TextChecker}; -use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; +use crate::validations::utils::text_check::utils::{TemporaryTextCheckQuality, tokenize_words}; +use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextChecker}; use std::sync::LazyLock; use symspell::{SymSpell, SymSpellBuilder, UnicodeStringStrategy, Verbosity}; diff --git a/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs new file mode 100644 index 00000000..d0cbc537 --- /dev/null +++ b/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs @@ -0,0 +1,98 @@ +/// Tests that verify the behavior shared by every [`TextChecker`] implementation +use crate::validations::utils::text_check::spell::mock_spell; +use crate::validations::utils::text_check::spell::symspell_spell; +use crate::validations::utils::text_check::test_utils::char_slice; +use crate::validations::utils::text_check::{TextCheckKind, TextChecker}; +use rstest::rstest; +use rstest_reuse::{self, apply, template}; + +/// Shared set of [`TextChecker`] implementations that every test in this module runs against. +#[template] +#[rstest] +#[case::mock(Box::new(mock_spell::MockSpellChecker))] +#[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] +fn all_checkers(#[case] checker: Box) {} + +#[apply(all_checkers)] +fn detects_misspelling(#[case] checker: Box) { + let text = "Secruity researchers"; + let findings = checker.check_text(TextCheckKind::Spell, text); + let finding = findings.iter().find(|f| f.fragment.eq_ignore_ascii_case("secruity")); + let finding = finding.expect("expected a misspelling finding"); + assert_eq!(finding.start, 0); + assert_eq!(finding.end, 8); + assert_eq!(char_slice(text, finding.start, finding.end), "Secruity"); +} + +#[apply(all_checkers)] +fn detects_misspelling_not_at_start(#[case] checker: Box) { + let text = "A Secruity test"; + let findings = checker.check_text(TextCheckKind::Spell, text); + let finding = findings.iter().find(|f| f.fragment.eq_ignore_ascii_case("secruity")); + let finding = finding.expect("expected a misspelling finding"); + assert_eq!(finding.start, 2); + assert_eq!(finding.end, 10); + assert_eq!(char_slice(text, finding.start, finding.end), "Secruity"); +} + +/// Validates that start/end are character indices, not byte offsets. +/// 'é' is a two-byte UTF-8 character; if bytes were used the start would be 3 +/// instead of the correct character index 2. +#[apply(all_checkers)] +fn detects_misspelling_after_multibyte_char(#[case] checker: Box) { + let text = "é Secruity"; + let findings = checker.check_text(TextCheckKind::Spell, text); + let finding = findings.iter().find(|f| f.fragment.eq_ignore_ascii_case("secruity")); + let finding = finding.expect("expected a misspelling finding"); + assert_eq!(finding.start, 2); + assert_eq!(finding.end, 10); + assert_eq!(char_slice(text, finding.start, finding.end), "Secruity"); +} + +#[apply(all_checkers)] +fn does_not_flag_correct_spelling(#[case] checker: Box) { + let findings = checker.check_text(TextCheckKind::Spell, "Security researchers"); + assert!(findings.is_empty(), "expected no spell findings, got: {findings:?}"); +} + +#[apply(all_checkers)] +fn handles_known_acronyms(#[case] checker: Box) { + let findings = checker.check_text(TextCheckKind::Spell, "OASIS CSAF TC"); + assert!( + findings.is_empty(), + "expected acronyms to be ignored, got: {findings:?}" + ); +} + +// TODO: Currently custom dictionary is mocked +#[apply(all_checkers)] +fn does_not_flag_custom_dictionary_words(#[case] checker: Box) { + let findings = checker.check_text(TextCheckKind::Spell, "OASIS TC"); + assert!( + findings.is_empty(), + "expected custom dictionary words to be ignored, got: {findings:?}" + ); +} + +// TODO: Currently CVE-ID like stuff is mocked +#[apply(all_checkers)] +fn does_not_flag_cve_ids(#[case] checker: Box) { + let findings = checker.check_text(TextCheckKind::Spell, "CVE-2024-1234"); + assert!(findings.is_empty(), "expected CVE IDs to be ignored, got: {findings:?}"); +} + +#[apply(all_checkers)] +fn empty_text_produces_no_findings(#[case] checker: Box) { + let findings = checker.check_text(TextCheckKind::Spell, ""); + assert!(findings.is_empty()); +} + +#[apply(all_checkers)] +fn spell_check_ignores_grammar_issues(#[case] checker: Box) { + // "He are going" is a grammar issue, not a spelling issue. + let findings = checker.check_text(TextCheckKind::Spell, "He are going"); + assert!( + findings.is_empty(), + "spell check should not flag grammar issues, got: {findings:?}" + ); +} diff --git a/csaf-rs/src/validations/utils/text_check/tests/utils.rs b/csaf-rs/src/validations/utils/text_check/test_utils.rs similarity index 64% rename from csaf-rs/src/validations/utils/text_check/tests/utils.rs rename to csaf-rs/src/validations/utils/text_check/test_utils.rs index 1c14ab21..324f8d11 100644 --- a/csaf-rs/src/validations/utils/text_check/tests/utils.rs +++ b/csaf-rs/src/validations/utils/text_check/test_utils.rs @@ -1,10 +1,5 @@ -//! Shared test-only helpers for building expected [`crate::validation::TestFinding`] results -//! and for forcing a specific [`TextChecker`] to be used during validation. - -use crate::csaf_traits::CsafTrait; use crate::validation::TestFinding; -use crate::validations::test_6_3_08::test_6_3_8_spell_check_impl; -use crate::validations::utils::text_check::checkers::TemporaryTextCheckQuality; +use crate::validations::utils::text_check::utils::TemporaryTextCheckQuality; use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextChecker}; /// Returns the substring of `text` identified by the character-index span `[start, end)`. @@ -12,36 +7,6 @@ pub(crate) fn char_slice(text: &str, start: usize, end: usize) -> String { text.chars().skip(start).take(end - start).collect() } -/// Extension trait to build checker-specific expected results `EXPECTED_RESULTS_*`. -pub(crate) trait ExpectedResultExt { - /// Returns `self` with `finding` added to it, turning `Ok` into a `Err` - fn with_finding(self, finding: TestFinding) -> Self; - - /// Returns `self` with any finding whose message contains `fragment` removed, possibly turning now-empty `Err` into `Ok` - fn without_finding(self, fragment: &str) -> Self; -} - -impl ExpectedResultExt for Result<(), Vec> { - fn with_finding(self, finding: TestFinding) -> Self { - let mut findings = self.err().unwrap_or_default(); - findings.push(finding); - Err(findings) - } - - fn without_finding(self, fragment: &str) -> Self { - match self { - Ok(()) => Ok(()), - Err(findings) => { - let remaining: Vec = findings - .into_iter() - .filter(|f| !f.get_data().message.contains(fragment)) - .collect(); - if remaining.is_empty() { Ok(()) } else { Err(remaining) } - }, - } - } -} - /// Wraps a [`TextChecker`], discarding any suggested `replacement` from its findings. /// /// Different checkers may legitimately suggest different corrections for the same @@ -49,7 +14,7 @@ impl ExpectedResultExt for Result<(), Vec> { /// `EXPECTED_RESULTS_*` fixtures (which only specify `replacement: None`) be reused /// across checkers. #[derive(Debug, Clone, Copy, Default)] -struct NoSuggestionChecker(C); +pub(crate) struct NoSuggestionChecker(pub C); impl TextChecker for NoSuggestionChecker { fn get_quality(&self) -> TemporaryTextCheckQuality { @@ -72,24 +37,32 @@ impl TextChecker for NoSuggestionChecker { } } -/// Test-only entry point that forces the given checker instead of `select_checker`'s matching. -fn test_6_3_8_spell_check_with_checker( - doc: &impl CsafTrait, - checker: impl TextChecker + 'static, -) -> Result<(), Vec> { - test_6_3_8_spell_check_impl(doc, |_| Ok(Box::new(checker) as Box)) +/// Extension trait to build checker-specific expected results `EXPECTED_RESULTS_*`. +pub(crate) trait ExpectedResultExt { + /// Returns `self` with `finding` added to it, turning `Ok` into a `Err` + fn with_finding(self, finding: TestFinding) -> Self; + + /// Returns `self` with any finding whose message contains `fragment` removed, possibly turning now-empty `Err` into `Ok` + fn without_finding(self, fragment: &str) -> Self; } -// Any `TextChecker` can act as its own `TestValidator`, forcing -// `check_text` to use exactly that checker. This allows us to run the CSAF 2.0 / 2.1 test suites -// separately against each of the spell checkers. Suggested replacements are discarded (see -// `NoSuggestionChecker`) since they can vary between checker implementations. -impl crate::test_validation::TestValidator for C -where - C: TextChecker + Default + Copy + 'static, - Doc: CsafTrait, -{ - fn validate(&self, doc: &Doc) -> Result<(), Vec> { - test_6_3_8_spell_check_with_checker(doc, NoSuggestionChecker(*self)) +impl ExpectedResultExt for Result<(), Vec> { + fn with_finding(self, finding: TestFinding) -> Self { + let mut findings = self.err().unwrap_or_default(); + findings.push(finding); + Err(findings) + } + + fn without_finding(self, fragment: &str) -> Self { + match self { + Ok(()) => Ok(()), + Err(findings) => { + let remaining: Vec = findings + .into_iter() + .filter(|f| !f.get_data().message.contains(fragment)) + .collect(); + if remaining.is_empty() { Ok(()) } else { Err(remaining) } + }, + } } } diff --git a/csaf-rs/src/validations/utils/text_check/tests/integration_tests.rs b/csaf-rs/src/validations/utils/text_check/tests/integration_tests.rs deleted file mode 100644 index 7ecd7179..00000000 --- a/csaf-rs/src/validations/utils/text_check/tests/integration_tests.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Tests that run the full 6.3.8 spell-check validation while forcing a single, specific -//! [`TextChecker`] implementation. - -#[cfg(test)] -mod tests { - use crate::csaf2_0::testcases::Test6_3_8 as Test6_3_8_2_0; - use crate::csaf2_1::testcases::Test6_3_8 as Test6_3_8_2_1; - use crate::validations::test_6_3_08::{ - EXPECTED_RESULTS_2_0, EXPECTED_RESULTS_2_1, create_misspelling_finding_info, - }; - use crate::validations::utils::text_check::TextChecker; - use crate::validations::utils::text_check::checkers::mock_spell::MockSpellChecker; - use crate::validations::utils::text_check::checkers::symspell_spell::EnglishSymspellChecker; - use crate::validations::utils::text_check::tests::utils::ExpectedResultExt; - use rstest::rstest; - - fn expected_results_2_0_symspell() -> crate::csaf2_0::testcases::ExpectedResults_6_3_8 { - let base = EXPECTED_RESULTS_2_0.clone(); - crate::csaf2_0::testcases::ExpectedResults_6_3_8 { - case_02: base - .case_02 - .clone() - .with_finding(create_misspelling_finding_info( - "unauthenticated", - 66, - 81, - &None, - "/vulnerabilities/0/notes/0/text", - )) - .without_finding("rood"), - case_12: base.case_12.clone().with_finding(create_misspelling_finding_info( - "unauthenticated", - 66, - 81, - &None, - "/vulnerabilities/0/notes/0/text", - )), - ..base - } - } - - fn expected_results_2_1_symspell() -> crate::csaf2_1::testcases::ExpectedResults_6_3_8 { - let base = EXPECTED_RESULTS_2_1.clone(); - crate::csaf2_1::testcases::ExpectedResults_6_3_8 { - case_02: base.case_02.clone().with_finding(create_misspelling_finding_info( - "unauthenticated", - 66, - 81, - &None, - "/vulnerabilities/0/notes/0/text", - )), - case_12: base.case_12.clone().with_finding(create_misspelling_finding_info( - "unauthenticated", - 66, - 81, - &None, - "/vulnerabilities/0/notes/0/text", - )), - ..base - } - } - - /// Runs the full 6.3.8 test suite against a single, specific checker. - /// - /// `MockSpellChecker` is also already used during the "regular" (non-integration) test run; - /// keeping it in here too for test development / test isolation. - /// - /// `EnglishSymspellChecker` is a real, basic spell checker. Its findings differ slightly from - /// the expected results. See `expected_results_2_0_symspell` / `expected_results_2_1_symspell` - /// for the diff: "rood" is an old-english word, "unauthenticated" is not in the dictionary. - #[rstest] - #[case::mock_checker(MockSpellChecker, EXPECTED_RESULTS_2_0.clone(), EXPECTED_RESULTS_2_1.clone())] - #[case::symspell_checker( - EnglishSymspellChecker, - expected_results_2_0_symspell(), - expected_results_2_1_symspell() - )] - fn test_test_6_3_8_checker_only( - #[case] _checker: C, - #[case] expected_2_0: crate::csaf2_0::testcases::ExpectedResults_6_3_8, - #[case] expected_2_1: crate::csaf2_1::testcases::ExpectedResults_6_3_8, - ) { - Test6_3_8_2_0::::new().expect(expected_2_0); - Test6_3_8_2_1::::new().expect(expected_2_1); - } -} diff --git a/csaf-rs/src/validations/utils/text_check/tests/mod.rs b/csaf-rs/src/validations/utils/text_check/tests/mod.rs deleted file mode 100644 index 3f7bda3a..00000000 --- a/csaf-rs/src/validations/utils/text_check/tests/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod integration_tests; -mod unit_tests; -mod utils; diff --git a/csaf-rs/src/validations/utils/text_check/tests/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/tests/unit_tests.rs deleted file mode 100644 index 87f12f85..00000000 --- a/csaf-rs/src/validations/utils/text_check/tests/unit_tests.rs +++ /dev/null @@ -1,111 +0,0 @@ -/// Tests that verify the behavior shared by every [`TextChecker`] implementation -#[cfg(test)] -mod tests { - use crate::validations::utils::text_check::checkers::mock_spell; - use crate::validations::utils::text_check::checkers::symspell_spell; - use crate::validations::utils::text_check::tests::utils::char_slice; - use crate::validations::utils::text_check::{TextCheckKind, TextChecker}; - use rstest::rstest; - - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn detects_misspelling(#[case] checker: Box) { - let text = "Secruity researchers"; - let findings = checker.check_text(TextCheckKind::Spell, text); - let finding = findings.iter().find(|f| f.fragment.eq_ignore_ascii_case("secruity")); - let finding = finding.expect("expected a misspelling finding"); - assert_eq!(finding.start, 0); - assert_eq!(finding.end, 8); - assert_eq!(char_slice(text, finding.start, finding.end), "Secruity"); - } - - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn detects_misspelling_not_at_start(#[case] checker: Box) { - let text = "A Secruity test"; - let findings = checker.check_text(TextCheckKind::Spell, text); - let finding = findings.iter().find(|f| f.fragment.eq_ignore_ascii_case("secruity")); - let finding = finding.expect("expected a misspelling finding"); - assert_eq!(finding.start, 2); - assert_eq!(finding.end, 10); - assert_eq!(char_slice(text, finding.start, finding.end), "Secruity"); - } - - /// Validates that start/end are character indices, not byte offsets. - /// 'é' is a two-byte UTF-8 character; if bytes were used the start would be 3 - /// instead of the correct character index 2. - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn detects_misspelling_after_multibyte_char(#[case] checker: Box) { - let text = "é Secruity"; - let findings = checker.check_text(TextCheckKind::Spell, text); - let finding = findings.iter().find(|f| f.fragment.eq_ignore_ascii_case("secruity")); - let finding = finding.expect("expected a misspelling finding"); - assert_eq!(finding.start, 2); - assert_eq!(finding.end, 10); - assert_eq!(char_slice(text, finding.start, finding.end), "Secruity"); - } - - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn does_not_flag_correct_spelling(#[case] checker: Box) { - let findings = checker.check_text(TextCheckKind::Spell, "Security researchers"); - assert!(findings.is_empty(), "expected no spell findings, got: {findings:?}"); - } - - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn handles_known_acronyms(#[case] checker: Box) { - let findings = checker.check_text(TextCheckKind::Spell, "OASIS CSAF TC"); - assert!( - findings.is_empty(), - "expected acronyms to be ignored, got: {findings:?}" - ); - } - - // TODO: Currently custom dictionary is mocked - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn does_not_flag_custom_dictionary_words(#[case] checker: Box) { - let findings = checker.check_text(TextCheckKind::Spell, "OASIS TC"); - assert!( - findings.is_empty(), - "expected custom dictionary words to be ignored, got: {findings:?}" - ); - } - - // TODO: Currently CVE-ID like stuff is mocked - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn does_not_flag_cve_ids(#[case] checker: Box) { - let findings = checker.check_text(TextCheckKind::Spell, "CVE-2024-1234"); - assert!(findings.is_empty(), "expected CVE IDs to be ignored, got: {findings:?}"); - } - - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn empty_text_produces_no_findings(#[case] checker: Box) { - let findings = checker.check_text(TextCheckKind::Spell, ""); - assert!(findings.is_empty()); - } - - #[rstest] - #[case::mock(Box::new(mock_spell::MockSpellChecker))] - #[case::symspell(Box::new(symspell_spell::EnglishSymspellChecker))] - fn spell_check_ignores_grammar_issues(#[case] checker: Box) { - // "He are going" is a grammar issue, not a spelling issue. - let findings = checker.check_text(TextCheckKind::Spell, "He are going"); - assert!( - findings.is_empty(), - "spell check should not flag grammar issues, got: {findings:?}" - ); - } -} diff --git a/csaf-rs/src/validations/utils/text_check/checkers/utils.rs b/csaf-rs/src/validations/utils/text_check/utils.rs similarity index 91% rename from csaf-rs/src/validations/utils/text_check/checkers/utils.rs rename to csaf-rs/src/validations/utils/text_check/utils.rs index fe1c826f..d20c8d1c 100644 --- a/csaf-rs/src/validations/utils/text_check/checkers/utils.rs +++ b/csaf-rs/src/validations/utils/text_check/utils.rs @@ -1,4 +1,13 @@ -//! Shared helpers for spell-checking and grammar-checking. +//! Shared helpers and structs for spell-checking and grammar-checking. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TemporaryTextCheckQuality { + #[allow(unused)] + Good, + #[allow(unused)] + Medium, + Poor, +} /// Splits `text` into alphanumeric word tokens suitable for spell-checking. /// From f820ea01b994d4edc0782d09b7d964522af7f55c Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 16:08:41 +0200 Subject: [PATCH 02/12] feat: add test 6.3.16, grammar unit+integration tests --- csaf-rs/src/validations/test_6_3_16.rs | 241 ++++++++++++++++++ .../text_check/grammar/integration_tests.rs | 49 ++++ .../utils/text_check/grammar/mock_grammar.rs | 5 +- .../utils/text_check/grammar/mod.rs | 18 ++ .../utils/text_check/grammar/unit_tests.rs | 67 +++++ .../text_check/spell/integration_tests.rs | 24 +- .../utils/text_check/spell/mock_spell.rs | 3 + .../validations/utils/text_check/spell/mod.rs | 3 + .../utils/text_check/spell/symspell_spell.rs | 3 + .../utils/text_check/spell/unit_tests.rs | 18 +- 10 files changed, 419 insertions(+), 12 deletions(-) create mode 100644 csaf-rs/src/validations/test_6_3_16.rs create mode 100644 csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs create mode 100644 csaf-rs/src/validations/utils/text_check/grammar/mod.rs create mode 100644 csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs diff --git a/csaf-rs/src/validations/test_6_3_16.rs b/csaf-rs/src/validations/test_6_3_16.rs new file mode 100644 index 00000000..1777b474 --- /dev/null +++ b/csaf-rs/src/validations/test_6_3_16.rs @@ -0,0 +1,241 @@ +use crate::csaf::types::language::{CsafLanguage, ValidCsafLanguage}; +use crate::csaf_traits::{ + AcknowledgmentTrait, AggregateSeverityTrait, CsafTrait, CsafVersion, DistributionTrait, DocumentTrait, + InvolvementTrait, NoteTrait, ProductGroupTrait, ProductTreeTrait, PublisherTrait, ReferenceTrait, RemediationTrait, + RestartRequiredTrait, RevisionTrait, ThreatTrait, TrackingTrait, VulnerabilityTrait, +}; +use crate::validation::{TestFinding, TestFindingData}; +use crate::validations::utils::text_check::{TextCheckKind, TextChecker, TextCheckerMatchingError, select_checker}; + +fn create_grammar_mistake_finding_info( + fragment: &str, + start: usize, + end: usize, + replacement: &Option, + instance_path: &str, +) -> TestFinding { + let fix = replacement + .as_deref() + .map(|r| format!(", suggested fix: `{r}`")) + .unwrap_or_default(); + TestFinding::Information(TestFindingData { + message: format!("Grammar mistake: `{fragment}` (position {start}-{end}{fix})"), + instance_path: instance_path.to_string(), + }) +} + +/// 6.3.16 Grammar Check +/// +/// If the document language is given it MUST be tested that a grammar check for the given +/// language does not find any mistakes. The test is skipped if the document language is not +/// set. It fails if the given language is not supported (only English is currently supported). +pub fn test_6_3_16_grammar_check(doc: &impl CsafTrait) -> Result<(), Vec> { + // Select the checker once for this lang/kind + // Note: If this is run as a unit test, the matcher will return a mock + test_6_3_16_grammar_check_impl(doc, |lang| select_checker(TextCheckKind::Grammar, lang)) +} + +/// Shared implementation, used by production code +/// (with auto-selected checker) and integration tests (with a single, fixed checker, see text_check/integration_tests). +pub fn test_6_3_16_grammar_check_impl( + doc: &impl CsafTrait, + select_checker: impl FnOnce(&ValidCsafLanguage) -> Result, TextCheckerMatchingError>, +) -> Result<(), Vec> { + let document = doc.get_document(); + + // Skip this test if language is not set + let lang = match document.get_lang() { + None => return Ok(()), // #409 skipped + Some(lang) => lang, + }; + + // Check if the language is supported + let lang = match &lang { + CsafLanguage::Valid(valid_lang) => valid_lang, + _ => { + return Err(vec![TestFinding::Information(TestFindingData { + message: format!("Grammar check does not support language '{lang}'"), + instance_path: "/document/lang".to_string(), + })]); + }, + }; + + let mut errors: Option> = None; + + // Select the checker once for this lang/kind + // Note: If this is run as a unit test, the matcher will return a mock + let checker = select_checker(lang).map_err(|err| vec![TestFinding::Information(err.into())])?; + + // Runs the grammar-check for a single piece of text and appends any resulting findings + let mut check = |text: &str, instance_path: String| { + for finding in checker.check_text(TextCheckKind::Grammar, text) { + errors.get_or_insert_default().push(create_grammar_mistake_finding_info( + &finding.fragment, + finding.start, + finding.end, + &finding.replacement, + &instance_path, + )); + } + }; + + // Check all text fields listed in the spec + if let Some(acknowledgments) = document.get_acknowledgments() { + for (a_i, ack) in acknowledgments.iter().enumerate() { + if let Some(summary) = ack.get_summary() { + check(summary, format!("/document/acknowledgments/{a_i}/summary")); + } + } + } + + if let Some(aggregate_severity) = document.get_aggregate_severity() { + check( + aggregate_severity.get_text(), + "/document/aggregate_severity/text".to_string(), + ); + } + + let distribution_text = match document.get_csaf_version() { + CsafVersion::X20 => document.get_distribution_20().and_then(|d| d.get_text()), + CsafVersion::X21 => document.get_distribution_21().ok().and_then(|d| d.get_text()), + }; + if let Some(text) = distribution_text { + check(text, "/document/distribution/text".to_string()); + } + + if let Some(notes) = document.get_notes() { + for (n_i, note) in notes.iter().enumerate() { + if let Some(audience) = note.get_audience() { + check(audience, format!("/document/notes/{n_i}/audience")); + } + check(note.get_text(), format!("/document/notes/{n_i}/text")); + if let Some(title) = note.get_title() { + check(title, format!("/document/notes/{n_i}/title")); + } + } + } + + let publisher = document.get_publisher(); + if let Some(issuing_authority) = publisher.get_issuing_authority() { + check(issuing_authority, "/document/publisher/issuing_authority".to_string()); + } + + if let Some(references) = document.get_references() { + for (r_i, reference) in references.iter().enumerate() { + check(reference.get_summary(), format!("/document/references/{r_i}/summary")); + } + } + + check(document.get_title(), "/document/title".to_string()); + + let tracking = document.get_tracking(); + for (r_i, revision) in tracking.get_revision_history().iter().enumerate() { + check( + revision.get_summary(), + format!("/document/tracking/revision_history/{r_i}/summary"), + ); + } + + if let Some(product_tree) = doc.get_product_tree() { + for (pg_i, product_group) in product_tree.get_product_groups().iter().enumerate() { + if let Some(summary) = product_group.get_summary() { + check(summary, format!("/product_tree/product_groups/{pg_i}/summary")); + } + } + } + + for (v_i, vuln) in doc.get_vulnerabilities().iter().enumerate() { + let vuln_prefix = format!("/vulnerabilities/{v_i}"); + + if let Some(acknowledgments) = vuln.get_acknowledgments() { + for (a_i, ack) in acknowledgments.iter().enumerate() { + if let Some(summary) = ack.get_summary() { + check(summary, format!("{vuln_prefix}/acknowledgments/{a_i}/summary")); + } + } + } + + for (i_i, involvement) in vuln.get_involvements().iter().flat_map(|v| v.iter()).enumerate() { + if let Some(summary) = involvement.get_summary() { + check(summary, format!("{vuln_prefix}/involvements/{i_i}/summary")); + } + } + + if let Some(notes) = vuln.get_notes() { + for (n_i, note) in notes.iter().enumerate() { + if let Some(audience) = note.get_audience() { + check(audience, format!("{vuln_prefix}/notes/{n_i}/audience")); + } + check(note.get_text(), format!("{vuln_prefix}/notes/{n_i}/text")); + if let Some(title) = note.get_title() { + check(title, format!("{vuln_prefix}/notes/{n_i}/title")); + } + } + } + + if let Some(references) = vuln.get_references() { + for (r_i, reference) in references.iter().enumerate() { + check( + reference.get_summary(), + format!("{vuln_prefix}/references/{r_i}/summary"), + ); + } + } + + for (r_i, remediation) in vuln.get_remediations().iter().enumerate() { + check( + remediation.get_details(), + format!("{vuln_prefix}/remediations/{r_i}/details"), + ); + for (e_i, entitlement) in remediation.get_entitlements().into_iter().enumerate() { + check( + entitlement, + format!("{vuln_prefix}/remediations/{r_i}/entitlements/{e_i}"), + ); + } + if let Some(restart_required) = remediation.get_restart_required() + && let Some(details) = restart_required.get_details() + { + check( + details, + format!("{vuln_prefix}/remediations/{r_i}/restart_required/details"), + ); + } + } + + for (t_i, threat) in vuln.get_threats().iter().enumerate() { + check(threat.get_details(), format!("{vuln_prefix}/threats/{t_i}/details")); + } + + if let Some(title) = vuln.get_title() { + check(title, format!("{vuln_prefix}/title")); + } + } + + errors.map_or(Ok(()), Err) +} + +crate::test_validation::impl_validator!(csaf2_1, ValidatorForTest6_3_16, test_6_3_16_grammar_check); + +#[cfg(test)] +/// Expected results, also used by the text_check/integration tests +pub(crate) static EXPECTED_RESULTS_2_1: std::sync::LazyLock = + std::sync::LazyLock::new(|| crate::csaf2_1::testcases::ExpectedResults_6_3_16 { + case_01: Err(vec![ + create_grammar_mistake_finding_info("must followed", 29, 42, &None, "/document/notes/0/text"), + create_grammar_mistake_finding_info("for ensure", 43, 53, &None, "/document/notes/0/text"), + create_grammar_mistake_finding_info("a products", 75, 85, &None, "/document/notes/0/text"), + ]), + case_11: Ok(()), + }); + +#[cfg(test)] +mod tests { + use super::*; + use crate::csaf2_1::testcases::TESTS_2_1; + + #[test] + fn test_test_6_3_16() { + TESTS_2_1.test_6_3_16.expect(EXPECTED_RESULTS_2_1.to_owned()); + } +} diff --git a/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs b/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs new file mode 100644 index 00000000..d86da07e --- /dev/null +++ b/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs @@ -0,0 +1,49 @@ +use rstest::rstest; +use crate::csaf_traits::CsafTrait; +use crate::validation::TestFinding; +use crate::validations::test_6_3_16::{test_6_3_16_grammar_check_impl, EXPECTED_RESULTS_2_1}; +use crate::validations::utils::text_check::grammar::GrammarTextChecker; +use crate::validations::utils::text_check::test_utils::NoSuggestionChecker; +use crate::validations::utils::text_check::TextChecker; +use crate::validations::utils::text_check::grammar::mock_grammar::MockGrammarChecker; + +/// Test-only entry point that forces the given checker instead of `select_checker`'s matching. +fn test_6_3_16_grammar_check_with_checker( + doc: &impl CsafTrait, + checker: impl TextChecker + 'static, +) -> Result<(), Vec> { + test_6_3_16_grammar_check_impl(doc, |_| Ok(Box::new(checker) as Box)) +} + +/// Wraps a [`GrammarTextChecker`] so it can act as its own `TestValidator`, forcing +/// `check_text` to use exactly that checker. This allows us to run the CSAF 2.0 / 2.1 test suites +/// separately against each of the grammar checkers. Suggested replacements are discarded (see +/// `NoSuggestionChecker`) since they can vary between checker implementations. +#[derive(Debug, Clone, Copy, Default)] +struct GrammarCheckerValidator(C); + +impl crate::test_validation::TestValidator for GrammarCheckerValidator +where + C: GrammarTextChecker + Default + Copy + 'static, + Doc: CsafTrait, +{ + fn validate(&self, doc: &Doc) -> Result<(), Vec> { + test_6_3_16_grammar_check_with_checker( + doc, + NoSuggestionChecker(self.0), + ) + } +} + +/// Runs the full 6.3.16 test suite against a single, specific checker. +/// +/// `MockGrammarChecker` is also already used during the "regular" (non-integration) test run; +/// keeping it in here too for test development / test isolation. +#[rstest] +#[case::mock_checker(MockGrammarChecker, EXPECTED_RESULTS_2_1.clone())] +fn test_test_6_3_8_checker_only( + #[case] _checker: C, + #[case] expected_2_1: crate::csaf2_1::testcases::ExpectedResults_6_3_16, +) { + crate::csaf2_1::testcases::Test6_3_16::>::new().expect(expected_2_1); +} diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs index b9f9fc48..4f956617 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -3,7 +3,7 @@ //! This module provides a basic English grammar-checker backed by a static list of //! known incorrect two-word sequences. -use super::TextChecker; +use super::{GrammarTextChecker, TextChecker}; use crate::validations::utils::text_check::utils::TemporaryTextCheckQuality; use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; use std::collections::HashSet; @@ -15,6 +15,7 @@ use std::collections::HashSet; /// - Text is tokenized by whitespace, punctuation is stripped. /// - Consecutive word pairs are matched against a built-in list of known incorrect sequences. /// - When a bad sequence is detected, the first word of the pair is reported as the problematic fragment. +#[derive(Default, Clone, Copy)] pub struct MockGrammarChecker; impl TextChecker for MockGrammarChecker { @@ -34,6 +35,8 @@ impl TextChecker for MockGrammarChecker { } } +impl GrammarTextChecker for MockGrammarChecker {} + fn grammar_check(text: &str) -> Vec { let known_bad_sequences = known_bad_sequences(); let mut findings = Vec::new(); diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mod.rs b/csaf-rs/src/validations/utils/text_check/grammar/mod.rs new file mode 100644 index 00000000..61d1880a --- /dev/null +++ b/csaf-rs/src/validations/utils/text_check/grammar/mod.rs @@ -0,0 +1,18 @@ +use crate::validations::utils::text_check::TextChecker; + +#[cfg(test)] +pub(crate) mod mock_grammar; +#[cfg(test)] +mod unit_tests; +#[cfg(test)] +mod integration_tests; + +/// Marker trait for [`TextChecker`] implementations that perform grammar checking. +pub trait GrammarTextChecker: TextChecker {} + +pub(crate) fn all_grammar_checkers() -> Vec> { + vec![ + #[cfg(test)] + Box::new(mock_grammar::MockGrammarChecker), + ] +} diff --git a/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs new file mode 100644 index 00000000..8e9f8ba1 --- /dev/null +++ b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs @@ -0,0 +1,67 @@ +/// Tests that verify the behavior shared by every [`TextChecker`] implementation +use crate::validations::utils::text_check::grammar::mock_grammar; +use crate::validations::utils::text_check::{TextCheckKind, TextChecker}; +use rstest::rstest; +use rstest_reuse::{self, apply, template}; + +/// Shared set of [`TextChecker`] implementations that every test in this module runs against. +#[template] +#[rstest] +#[case::mock(Box::new(mock_grammar::MockGrammarChecker))] +fn all_checkers(#[case] checker: Box) {} + +#[apply(all_checkers)] +fn detects_grammar_mistake(#[case] checker: Box) { + let text = "The security hardening guide must followed."; + let findings = checker.check_text(TextCheckKind::Grammar, text); + assert_eq!(findings.len(), 1, "expected exactly three findings, got: {findings:?}"); + let finding = findings.first().unwrap(); + assert_eq!( + &text[finding.start..finding.end], + "must followed", + "expected a grammar finding 'must followed'" + ); +} + +#[apply(all_checkers)] +fn detects_multiple_grammar_mistake(#[case] checker: Box) { + let text = "The security hardening guide must followed for ensure secure operations of a products."; + let findings = checker.check_text(TextCheckKind::Grammar, text); + assert_eq!(findings.len(), 3, "expected exactly three findings, got: {findings:?}"); + let finding_1 = findings.get(0).unwrap(); + assert_eq!( + &text[finding_1.start..finding_1.end], + "must followed", + "expected a grammar finding 'must followed'" + ); + let finding_2 = findings.get(1).unwrap(); + assert_eq!( + &text[finding_2.start..finding_2.end], + "for ensure", + "expected a grammar finding 'for ensure'" + ); + let finding_3 = findings.get(2).unwrap(); + assert_eq!( + &text[finding_3.start..finding_3.end], + "a products", + "expected a grammar finding for 'a products'" + ); +} + +#[apply(all_checkers)] +fn does_not_flag_correct_grammar(#[case] checker: Box) { + let findings = checker.check_text( + TextCheckKind::Grammar, + "The security hardening guide must be followed to ensure secure operations of the products.", + ); + assert!(findings.is_empty(), "expected no grammar findings, got: {findings:?}"); +} + +#[apply(all_checkers)] +fn grammar_check_ignores_pure_spelling_issues(#[case] checker: Box) { + let findings = checker.check_text(TextCheckKind::Grammar, "Secruity researchers"); + assert!( + findings.is_empty(), + "grammar check should not flag pure spelling issues, got: {findings:?}" + ); +} \ No newline at end of file diff --git a/csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs b/csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs index eae4a2b5..71b933a2 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/integration_tests.rs @@ -12,6 +12,7 @@ use crate::validations::utils::text_check::spell::mock_spell::MockSpellChecker; use crate::validations::utils::text_check::spell::symspell_spell::EnglishSymspellChecker; use crate::validations::utils::text_check::test_utils::{ExpectedResultExt, NoSuggestionChecker}; use rstest::rstest; +use crate::validations::utils::text_check::spell::SpellTextChecker; /// Test-only entry point that forces the given checker instead of `select_checker`'s matching. fn test_6_3_8_spell_check_with_checker( @@ -21,19 +22,22 @@ fn test_6_3_8_spell_check_with_checker( test_6_3_8_spell_check_impl(doc, |_| Ok(Box::new(checker) as Box)) } -// Any `TextChecker` can act as its own `TestValidator`, forcing -// `check_text` to use exactly that checker. This allows us to run the CSAF 2.0 / 2.1 test suites -// separately against each of the spell checkers. Suggested replacements are discarded (see -// `NoSuggestionChecker`) since they can vary between checker implementations. -impl crate::test_validation::TestValidator for C +/// Wraps a [`SpellTextChecker`] so it can act as its own `TestValidator`, forcing +/// `check_text` to use exactly that checker. This allows us to run the CSAF 2.0 / 2.1 test suites +/// separately against each of the spell checkers. Suggested replacements are discarded (see +/// `NoSuggestionChecker`) since they can vary between checker implementations. +#[derive(Debug, Clone, Copy, Default)] +struct SpellCheckerValidator(C); + +impl crate::test_validation::TestValidator for SpellCheckerValidator where - C: TextChecker + Default + Copy + 'static, + C: SpellTextChecker + Default + Copy + 'static, Doc: CsafTrait, { fn validate(&self, doc: &Doc) -> Result<(), Vec> { test_6_3_8_spell_check_with_checker( doc, - NoSuggestionChecker(*self), + NoSuggestionChecker(self.0), ) } } @@ -99,11 +103,11 @@ fn expected_results_2_1_symspell() -> crate::csaf2_1::testcases::ExpectedResults expected_results_2_0_symspell(), expected_results_2_1_symspell() )] -fn test_test_6_3_8_checker_only( +fn test_test_6_3_8_checker_only( #[case] _checker: C, #[case] expected_2_0: crate::csaf2_0::testcases::ExpectedResults_6_3_8, #[case] expected_2_1: crate::csaf2_1::testcases::ExpectedResults_6_3_8, ) { - Test6_3_8_2_0::::new().expect(expected_2_0); - Test6_3_8_2_1::::new().expect(expected_2_1); + Test6_3_8_2_0::>::new().expect(expected_2_0); + Test6_3_8_2_1::>::new().expect(expected_2_1); } diff --git a/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs b/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs index a915e605..451bb053 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs @@ -2,6 +2,7 @@ //! //! This module provides a basic English spell-checker backed by a static word list. +use crate::validations::utils::text_check::spell::SpellTextChecker; use crate::validations::utils::text_check::utils::{TemporaryTextCheckQuality, tokenize_words}; use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextChecker}; use std::collections::HashSet; @@ -34,6 +35,8 @@ impl TextChecker for MockSpellChecker { } } +impl SpellTextChecker for MockSpellChecker {} + fn spell_check(text: &str) -> Vec { let dict = dictionary(); let mut findings = Vec::new(); diff --git a/csaf-rs/src/validations/utils/text_check/spell/mod.rs b/csaf-rs/src/validations/utils/text_check/spell/mod.rs index 11dd72e7..adb9eab5 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/mod.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/mod.rs @@ -8,6 +8,9 @@ pub(crate) mod symspell_spell; #[cfg(test)] mod unit_tests; +/// Marker trait for [`TextChecker`] implementations that perform spell checking. +pub trait SpellTextChecker: TextChecker {} + pub(crate) fn all_spell_checkers() -> Vec> { vec![ #[cfg(test)] diff --git a/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs b/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs index 23760dde..a066c2c8 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs @@ -1,6 +1,7 @@ //! English spell-checker backed by the SymSpell algorithm and static word-frequency //! dictionaries (TODO: only english for now) +use crate::validations::utils::text_check::spell::SpellTextChecker; use crate::validations::utils::text_check::utils::{TemporaryTextCheckQuality, tokenize_words}; use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextChecker}; use std::sync::LazyLock; @@ -100,6 +101,8 @@ impl TextChecker for EnglishSymspellChecker { } } +impl SpellTextChecker for EnglishSymspellChecker {} + #[cfg(test)] mod tests { use super::*; diff --git a/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs index d0cbc537..352102c0 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs @@ -24,6 +24,22 @@ fn detects_misspelling(#[case] checker: Box) { assert_eq!(char_slice(text, finding.start, finding.end), "Secruity"); } +#[apply(all_checkers)] +fn detects_multiple_misspelling(#[case] checker: Box) { + let text = "Secruity ressearchers"; + let findings = checker.check_text(TextCheckKind::Spell, text); + assert_eq!(findings.len(), 2, "expected exactly two findings, got: {findings:?}"); + let finding_1 = findings.get(0).unwrap(); + assert_eq!(finding_1.start, 0); + assert_eq!(finding_1.end, 8); + assert_eq!(char_slice(text, finding_1.start, finding_1.end), "Secruity"); + let finding_2 = findings.get(1).unwrap(); + assert_eq!(finding_2.start, 9); + assert_eq!(finding_2.end, 21); + assert_eq!(char_slice(text, finding_2.start, finding_2.end), "ressearchers"); +} + + #[apply(all_checkers)] fn detects_misspelling_not_at_start(#[case] checker: Box) { let text = "A Secruity test"; @@ -95,4 +111,4 @@ fn spell_check_ignores_grammar_issues(#[case] checker: Box) { findings.is_empty(), "spell check should not flag grammar issues, got: {findings:?}" ); -} +} \ No newline at end of file From be535fb20b26e5ccc14988f1ec5cdbfcc639a6b8 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 16:09:34 +0200 Subject: [PATCH 03/12] style: fmt --- .../utils/text_check/grammar/integration_tests.rs | 13 +++++-------- .../utils/text_check/grammar/mock_grammar.rs | 2 +- .../src/validations/utils/text_check/grammar/mod.rs | 4 ++-- .../utils/text_check/grammar/unit_tests.rs | 2 +- .../utils/text_check/spell/integration_tests.rs | 7 ++----- .../utils/text_check/spell/unit_tests.rs | 3 +-- 6 files changed, 12 insertions(+), 19 deletions(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs b/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs index d86da07e..3b2a31d2 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs @@ -1,11 +1,11 @@ -use rstest::rstest; use crate::csaf_traits::CsafTrait; use crate::validation::TestFinding; -use crate::validations::test_6_3_16::{test_6_3_16_grammar_check_impl, EXPECTED_RESULTS_2_1}; -use crate::validations::utils::text_check::grammar::GrammarTextChecker; -use crate::validations::utils::text_check::test_utils::NoSuggestionChecker; +use crate::validations::test_6_3_16::{EXPECTED_RESULTS_2_1, test_6_3_16_grammar_check_impl}; use crate::validations::utils::text_check::TextChecker; +use crate::validations::utils::text_check::grammar::GrammarTextChecker; use crate::validations::utils::text_check::grammar::mock_grammar::MockGrammarChecker; +use crate::validations::utils::text_check::test_utils::NoSuggestionChecker; +use rstest::rstest; /// Test-only entry point that forces the given checker instead of `select_checker`'s matching. fn test_6_3_16_grammar_check_with_checker( @@ -28,10 +28,7 @@ where Doc: CsafTrait, { fn validate(&self, doc: &Doc) -> Result<(), Vec> { - test_6_3_16_grammar_check_with_checker( - doc, - NoSuggestionChecker(self.0), - ) + test_6_3_16_grammar_check_with_checker(doc, NoSuggestionChecker(self.0)) } } diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs index 4f956617..ba8f560f 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -83,4 +83,4 @@ fn known_bad_sequences() -> HashSet<&'static str> { ["must followed", "for ensure", "a products"].iter().copied().collect() } -// TODO unit tests \ No newline at end of file +// TODO unit tests diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mod.rs b/csaf-rs/src/validations/utils/text_check/grammar/mod.rs index 61d1880a..f71082aa 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mod.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mod.rs @@ -1,11 +1,11 @@ use crate::validations::utils::text_check::TextChecker; +#[cfg(test)] +mod integration_tests; #[cfg(test)] pub(crate) mod mock_grammar; #[cfg(test)] mod unit_tests; -#[cfg(test)] -mod integration_tests; /// Marker trait for [`TextChecker`] implementations that perform grammar checking. pub trait GrammarTextChecker: TextChecker {} diff --git a/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs index 8e9f8ba1..7d2caf96 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs @@ -64,4 +64,4 @@ fn grammar_check_ignores_pure_spelling_issues(#[case] checker: Box Result<(), Vec> { - test_6_3_8_spell_check_with_checker( - doc, - NoSuggestionChecker(self.0), - ) + test_6_3_8_spell_check_with_checker(doc, NoSuggestionChecker(self.0)) } } diff --git a/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs index 352102c0..a3724d93 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/unit_tests.rs @@ -39,7 +39,6 @@ fn detects_multiple_misspelling(#[case] checker: Box) { assert_eq!(char_slice(text, finding_2.start, finding_2.end), "ressearchers"); } - #[apply(all_checkers)] fn detects_misspelling_not_at_start(#[case] checker: Box) { let text = "A Secruity test"; @@ -111,4 +110,4 @@ fn spell_check_ignores_grammar_issues(#[case] checker: Box) { findings.is_empty(), "spell check should not flag grammar issues, got: {findings:?}" ); -} \ No newline at end of file +} From 5d720485a5dea2bbd7daae1e6b50a2f037f78008 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 16:38:38 +0200 Subject: [PATCH 04/12] chore: align imports and comments --- .../validations/utils/text_check/grammar/mock_grammar.rs | 3 +++ csaf-rs/src/validations/utils/text_check/grammar/mod.rs | 4 +++- .../src/validations/utils/text_check/spell/mock_spell.rs | 6 ++++-- csaf-rs/src/validations/utils/text_check/spell/mod.rs | 4 +++- .../validations/utils/text_check/spell/symspell_spell.rs | 6 ++++-- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs index ba8f560f..108faa75 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -35,6 +35,9 @@ impl TextChecker for MockGrammarChecker { } } +/// Marker trait for [`TextChecker`] implementations that perform grammar checking. +/// For now, only relevant to integration tests. +#[cfg(test)] impl GrammarTextChecker for MockGrammarChecker {} fn grammar_check(text: &str) -> Vec { diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mod.rs b/csaf-rs/src/validations/utils/text_check/grammar/mod.rs index f71082aa..dc185273 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mod.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mod.rs @@ -8,7 +8,9 @@ pub(crate) mod mock_grammar; mod unit_tests; /// Marker trait for [`TextChecker`] implementations that perform grammar checking. -pub trait GrammarTextChecker: TextChecker {} +/// For now, only relevant to integration tests. +#[allow(unused)] +trait GrammarTextChecker: TextChecker {} pub(crate) fn all_grammar_checkers() -> Vec> { vec![ diff --git a/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs b/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs index 451bb053..77b03064 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/mock_spell.rs @@ -2,9 +2,9 @@ //! //! This module provides a basic English spell-checker backed by a static word list. -use crate::validations::utils::text_check::spell::SpellTextChecker; +use super::{SpellTextChecker, TextChecker}; use crate::validations::utils::text_check::utils::{TemporaryTextCheckQuality, tokenize_words}; -use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextChecker}; +use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; use std::collections::HashSet; /// A mock spell-checker for English text. @@ -35,6 +35,8 @@ impl TextChecker for MockSpellChecker { } } +/// Marker trait for [`TextChecker`] implementations that perform spell checking. +/// For now, only relevant to integration tests. impl SpellTextChecker for MockSpellChecker {} fn spell_check(text: &str) -> Vec { diff --git a/csaf-rs/src/validations/utils/text_check/spell/mod.rs b/csaf-rs/src/validations/utils/text_check/spell/mod.rs index adb9eab5..94732c64 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/mod.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/mod.rs @@ -9,7 +9,9 @@ pub(crate) mod symspell_spell; mod unit_tests; /// Marker trait for [`TextChecker`] implementations that perform spell checking. -pub trait SpellTextChecker: TextChecker {} +/// For now, only relevant to integration tests. +#[allow(unused)] +trait SpellTextChecker: TextChecker {} pub(crate) fn all_spell_checkers() -> Vec> { vec![ diff --git a/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs b/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs index a066c2c8..7b231baa 100644 --- a/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs +++ b/csaf-rs/src/validations/utils/text_check/spell/symspell_spell.rs @@ -1,9 +1,9 @@ //! English spell-checker backed by the SymSpell algorithm and static word-frequency //! dictionaries (TODO: only english for now) -use crate::validations::utils::text_check::spell::SpellTextChecker; +use super::{SpellTextChecker, TextChecker}; use crate::validations::utils::text_check::utils::{TemporaryTextCheckQuality, tokenize_words}; -use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind, TextChecker}; +use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; use std::sync::LazyLock; use symspell::{SymSpell, SymSpellBuilder, UnicodeStringStrategy, Verbosity}; @@ -101,6 +101,8 @@ impl TextChecker for EnglishSymspellChecker { } } +/// Marker trait implementation: this checker performs spell checking. +/// For now, only relevant to integration tests. impl SpellTextChecker for EnglishSymspellChecker {} #[cfg(test)] From 4b74509ef4e53a6b03ce4e540f89816c22ec02ab Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:22:29 +0200 Subject: [PATCH 05/12] chore: fix typo --- csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs index 7d2caf96..9e0c6149 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs @@ -14,7 +14,7 @@ fn all_checkers(#[case] checker: Box) {} fn detects_grammar_mistake(#[case] checker: Box) { let text = "The security hardening guide must followed."; let findings = checker.check_text(TextCheckKind::Grammar, text); - assert_eq!(findings.len(), 1, "expected exactly three findings, got: {findings:?}"); + assert_eq!(findings.len(), 1, "expected exactly one findings, got: {findings:?}"); let finding = findings.first().unwrap(); assert_eq!( &text[finding.start..finding.end], From 151da14e1482c89dd3c47cca53e606160bf7fdba Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:38:54 +0200 Subject: [PATCH 06/12] chore: use char slice helper --- .../validations/utils/text_check/grammar/unit_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs index 9e0c6149..5b9e730f 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/unit_tests.rs @@ -1,5 +1,6 @@ /// Tests that verify the behavior shared by every [`TextChecker`] implementation use crate::validations::utils::text_check::grammar::mock_grammar; +use crate::validations::utils::text_check::test_utils::char_slice; use crate::validations::utils::text_check::{TextCheckKind, TextChecker}; use rstest::rstest; use rstest_reuse::{self, apply, template}; @@ -17,7 +18,7 @@ fn detects_grammar_mistake(#[case] checker: Box) { assert_eq!(findings.len(), 1, "expected exactly one findings, got: {findings:?}"); let finding = findings.first().unwrap(); assert_eq!( - &text[finding.start..finding.end], + char_slice(text, finding.start, finding.end), "must followed", "expected a grammar finding 'must followed'" ); @@ -30,19 +31,19 @@ fn detects_multiple_grammar_mistake(#[case] checker: Box) { assert_eq!(findings.len(), 3, "expected exactly three findings, got: {findings:?}"); let finding_1 = findings.get(0).unwrap(); assert_eq!( - &text[finding_1.start..finding_1.end], + char_slice(text, finding_1.start, finding_1.end), "must followed", "expected a grammar finding 'must followed'" ); let finding_2 = findings.get(1).unwrap(); assert_eq!( - &text[finding_2.start..finding_2.end], + char_slice(text, finding_2.start, finding_2.end), "for ensure", "expected a grammar finding 'for ensure'" ); let finding_3 = findings.get(2).unwrap(); assert_eq!( - &text[finding_3.start..finding_3.end], + char_slice(text, finding_3.start, finding_3.end), "a products", "expected a grammar finding for 'a products'" ); From b45a93e36e7b6f7d11024e96c7d9daa7a24c71b8 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:39:38 +0200 Subject: [PATCH 07/12] chore: function naming --- .../validations/utils/text_check/grammar/integration_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs b/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs index 3b2a31d2..c0dd56ff 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/integration_tests.rs @@ -38,7 +38,7 @@ where /// keeping it in here too for test development / test isolation. #[rstest] #[case::mock_checker(MockGrammarChecker, EXPECTED_RESULTS_2_1.clone())] -fn test_test_6_3_8_checker_only( +fn test_test_6_3_16_checker_only( #[case] _checker: C, #[case] expected_2_1: crate::csaf2_1::testcases::ExpectedResults_6_3_16, ) { From 7a1cf81125af1278799697367b8a73de78c55246 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:47:18 +0200 Subject: [PATCH 08/12] chore: fix docs --- .../src/validations/utils/text_check/grammar/mock_grammar.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs index 108faa75..86aaa05e 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -4,7 +4,7 @@ //! known incorrect two-word sequences. use super::{GrammarTextChecker, TextChecker}; -use crate::validations::utils::text_check::utils::TemporaryTextCheckQuality; +use crate::validations::utils::text_check::utils::{tokenize_words, TemporaryTextCheckQuality}; use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; use std::collections::HashSet; @@ -12,7 +12,7 @@ use std::collections::HashSet; /// /// Behavior: /// - Only [`TextCheckKind::Grammar`] findings are produced; spell checking is not implemented. -/// - Text is tokenized by whitespace, punctuation is stripped. +/// - Strings are tokenized, see [`tokenize_words`]. /// - Consecutive word pairs are matched against a built-in list of known incorrect sequences. /// - When a bad sequence is detected, the first word of the pair is reported as the problematic fragment. #[derive(Default, Clone, Copy)] From 4a00d81c1f9f21e7013486b6a2a5067ff9a08c58 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:49:54 +0200 Subject: [PATCH 09/12] chore: fix docs --- csaf-rs/src/validations/utils/text_check/test_utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaf-rs/src/validations/utils/text_check/test_utils.rs b/csaf-rs/src/validations/utils/text_check/test_utils.rs index 324f8d11..f862edd9 100644 --- a/csaf-rs/src/validations/utils/text_check/test_utils.rs +++ b/csaf-rs/src/validations/utils/text_check/test_utils.rs @@ -39,7 +39,7 @@ impl TextChecker for NoSuggestionChecker { /// Extension trait to build checker-specific expected results `EXPECTED_RESULTS_*`. pub(crate) trait ExpectedResultExt { - /// Returns `self` with `finding` added to it, turning `Ok` into a `Err` + /// Returns `self` with `finding` added to it, turning `Ok` into an `Err` fn with_finding(self, finding: TestFinding) -> Self; /// Returns `self` with any finding whose message contains `fragment` removed, possibly turning now-empty `Err` into `Ok` From da4b03fbb244138b420f110a4fa9f0e687f7e4f2 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:53:37 +0200 Subject: [PATCH 10/12] chore: fix docs --- .../src/validations/utils/text_check/grammar/mock_grammar.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs index 86aaa05e..4ca771c0 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -84,6 +84,4 @@ fn grammar_check(text: &str) -> Vec { fn known_bad_sequences() -> HashSet<&'static str> { ["must followed", "for ensure", "a products"].iter().copied().collect() -} - -// TODO unit tests +} \ No newline at end of file From 3e7a8a7d019f78c01275031a0e6405a3ceca7f30 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:53:50 +0200 Subject: [PATCH 11/12] refactor: use tokenize_words --- .../utils/text_check/grammar/mock_grammar.rs | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs index 4ca771c0..6f139ce7 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -43,35 +43,17 @@ impl GrammarTextChecker for MockGrammarChecker {} fn grammar_check(text: &str) -> Vec { let known_bad_sequences = known_bad_sequences(); let mut findings = Vec::new(); - let mut search_from = 0; - // Collect (lowercase, original, char_start, char_end) for each token - let mut tokens: Vec<(String, String, usize, usize)> = Vec::new(); - - for token in text.split_whitespace() { - let offset = text[search_from..].find(token).unwrap_or(0); - let token_start = search_from + offset; - search_from = token_start + token.len(); - - let trimmed = token.trim_matches(|c: char| !c.is_alphabetic()); - if trimmed.is_empty() { - continue; - } - let word_offset = trimmed.as_ptr() as usize - token.as_ptr() as usize; - let word_start = text[..token_start + word_offset].chars().count(); - let word_end = word_start + trimmed.chars().count(); - - tokens.push((trimmed.to_lowercase(), trimmed.to_string(), word_start, word_end)); - } + let tokens = tokenize_words(text); // Check each consecutive pair against the bad-sequence list. for window in tokens.windows(2) { - let (lower1, original1, start, _) = &window[0]; - let (lower2, original2, _, end) = &window[1]; - let key = format!("{lower1} {lower2}"); + let (word1, start, _) = &window[0]; + let (word2, _, end) = &window[1]; + let key = format!("{} {}", word1.to_lowercase(), word2.to_lowercase()); if known_bad_sequences.contains(key.as_str()) { findings.push(TextCheckFinding { - fragment: format!("{original1} {original2}"), + fragment: format!("{word1} {word2}"), start: *start, end: *end, replacement: None, From 9e1b3176da35f24c3dd0c0daca796c04ee3b19d5 Mon Sep 17 00:00:00 2001 From: Johannes Pein Date: Fri, 28 Aug 2026 17:54:07 +0200 Subject: [PATCH 12/12] style: fmt --- .../src/validations/utils/text_check/grammar/mock_grammar.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs index 6f139ce7..755a659c 100644 --- a/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs +++ b/csaf-rs/src/validations/utils/text_check/grammar/mock_grammar.rs @@ -4,7 +4,7 @@ //! known incorrect two-word sequences. use super::{GrammarTextChecker, TextChecker}; -use crate::validations::utils::text_check::utils::{tokenize_words, TemporaryTextCheckQuality}; +use crate::validations::utils::text_check::utils::{TemporaryTextCheckQuality, tokenize_words}; use crate::validations::utils::text_check::{TextCheckFinding, TextCheckKind}; use std::collections::HashSet; @@ -66,4 +66,4 @@ fn grammar_check(text: &str) -> Vec { fn known_bad_sequences() -> HashSet<&'static str> { ["must followed", "for ensure", "a products"].iter().copied().collect() -} \ No newline at end of file +}