Skip to main content

ai_session_search/
corrections.rs

1// SPDX-FileCopyrightText: 2026 Andrew Hundt
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure correction-policy parsing, compilation, and classification.
5//!
6//! This module turns a message-classification capability document into compiled, ordered
7//! categories and
8//! classifies one message's text against them. Following the contract [`crate::analysis_pipeline`]
9//! sets, it does not discover providers, read session files, query SQLite, or publish artifacts:
10//! callers hand it bytes and it hands back a policy. Keeping it pure is what lets the CLI, Python,
11//! MCP, and downstream Rust adapters share one definition of what a correction *is*.
12//!
13//! # Why this is not [`crate::analysis_pipeline::AnalysisPolicySpec`]
14//!
15//! The two look alike — named labels over regexes — and an earlier design deserialized corrections
16//! straight into `AnalysisPolicySpec`. Reading the source shows that would silently change results:
17//!
18//! * [`crate::analysis_pipeline::AnalysisPolicy::compile`] **sorts** rules by `(dimension, label)`,
19//!   so declaration order is destroyed. Corrections are ordered on purpose — `other` is a
20//!   deliberately last catch-all — and a sorted list would evaluate `incomplete` first and `other`
21//!   fourth.
22//! * `AnalysisPolicy::classify` returns **every** matching rule with weights, over a session
23//!   aggregate. Corrections return the **first** matching category for an **individual message**,
24//!   plus the exact substring that matched.
25//!
26//! Sharing the validators is DRY; sharing the type would be a bug. So [`require_name`] and
27//! [`compile_nonempty_regex`] are reused and the policy type is its own.
28
29use anyhow::{bail, Context, Result};
30use regex::Regex;
31use serde::{Deserialize, Serialize};
32use std::collections::BTreeSet;
33use std::path::PathBuf;
34
35use crate::analysis_pipeline::{compile_nonempty_regex, require_name};
36use crate::hashing::sha256;
37
38/// The only `schema_version` this build understands.
39///
40/// Bumping it is a breaking change to `policy.toml`; an unknown value is rejected by name rather
41/// than ignored, so a policy written for a newer `aise` fails loudly instead of silently losing
42/// categories.
43pub const CORRECTION_POLICY_SCHEMA_VERSION: u32 = 1;
44
45/// Where a resolved policy's bytes came from, for provenance and diagnostics.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum CorrectionPolicySource {
49    /// Compiled into the executable; always resolvable and never affected by a damaged install.
50    Embedded,
51    /// Read from a discovered skill directory.
52    File { path: PathBuf },
53    /// Supplied as typed parameters for one run of a selected skill.
54    Inline,
55}
56
57/// A message-classification capability document, before compilation.
58///
59/// `deny_unknown_fields` makes a typo a hard error rather than a silently ignored key: a
60/// misspelled `patterns` would otherwise leave a category with no rules and no complaint.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct CorrectionPolicySpec {
64    pub schema_version: u32,
65    pub name: String,
66    pub version: String,
67    pub categories: Vec<CorrectionCategorySpec>,
68}
69
70/// One named category. Patterns within a category are ORed; the category is the reported label.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct CorrectionCategorySpec {
74    pub name: String,
75    pub patterns: Vec<String>,
76}
77
78/// Name, version, and source-appropriate digest of the rules that produced a result.
79///
80/// File-backed policies digest exact document bytes. In-memory policies digest a canonical,
81/// length-delimited encoding of their executable semantics. A name and version alone are not
82/// reproducible because rules can change without either value changing.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct CorrectionPolicyIdentity {
85    pub name: String,
86    pub version: String,
87    pub sha256: String,
88    pub source: CorrectionPolicySource,
89}
90
91/// A compiled, ordered correction policy.
92///
93/// Category order and within-category pattern order are both preserved from the document and are
94/// the evaluation order.
95#[derive(Debug, Clone)]
96pub struct CorrectionPolicy {
97    identity: CorrectionPolicyIdentity,
98    /// Flattened `(category, regex)` in declaration order. Flattened because that is exactly the
99    /// shape the classifier scans and the shape `Db::find_corrections` already accepts, so the hot
100    /// loop keeps its current form and its parallel-vs-sequential equivalence test keeps holding.
101    rules: Vec<(String, Regex)>,
102    category_count: usize,
103}
104
105/// One classified message: which category matched, and the text that matched it.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct CorrectionHit<'policy> {
108    pub category: &'policy str,
109    /// The matched substring, not the rule that matched. See [`CorrectionPolicy::classify`].
110    pub matched_text: String,
111    pub match_start_char: usize,
112    pub match_end_char_exclusive: usize,
113}
114
115impl CorrectionPolicySpec {
116    /// Compile a spec that has no file behind it, deriving the digest from the spec itself.
117    ///
118    /// Use this whenever the bytes are constructed rather than read: the embedded default, the
119    /// legacy-config bridge, and any caller assembling categories in code. The digest then still
120    /// identifies exactly these rules, so an in-memory policy is as reproducible as a file one.
121    ///
122    /// # Errors
123    ///
124    /// Same as [`CorrectionPolicySpec::compile`].
125    pub(crate) fn compile_in_memory(
126        self,
127        source: CorrectionPolicySource,
128    ) -> Result<CorrectionPolicy> {
129        let digest = sha256(&canonical_digest_input(&self));
130        self.compile(source, digest)
131    }
132
133    /// Validate and compile against a digest the caller already has — typically of the exact file
134    /// bytes — preserving declaration order. Prefer [`CorrectionPolicySpec::compile_in_memory`]
135    /// when there is no such file.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error naming the offending category or pattern when the schema version is
140    /// unknown, a name or version is empty, there are no categories, a category name repeats, a
141    /// category has no patterns, or a pattern is empty, invalid, or matches empty text.
142    pub fn compile(
143        self,
144        source: CorrectionPolicySource,
145        digest: String,
146    ) -> Result<CorrectionPolicy> {
147        if self.schema_version != CORRECTION_POLICY_SCHEMA_VERSION {
148            bail!(
149                "unsupported correction policy schema_version {}; this build understands {}. \
150                 Upgrade aise, or set schema_version = {}",
151                self.schema_version,
152                CORRECTION_POLICY_SCHEMA_VERSION,
153                CORRECTION_POLICY_SCHEMA_VERSION
154            );
155        }
156        require_name("correction policy name", &self.name)?;
157        require_name("correction policy version", &self.version)?;
158        if self.categories.is_empty() {
159            bail!(
160                "correction policy '{}' defines no categories; add at least one [[categories]] \
161                 table with a name and one or more patterns",
162                self.name
163            );
164        }
165
166        let mut seen = BTreeSet::new();
167        let mut rules = Vec::new();
168        for category in &self.categories {
169            require_name("correction category name", &category.name)?;
170            if !seen.insert(category.name.clone()) {
171                bail!(
172                    "duplicate correction category '{}'; merge its patterns into one \
173                     [[categories]] table, because the first match wins and the second would \
174                     never be reached",
175                    category.name
176                );
177            }
178            if category.patterns.is_empty() {
179                bail!(
180                    "correction category '{}' has no patterns; give it at least one, or remove \
181                     the category",
182                    category.name
183                );
184            }
185            // Validate each pattern on its own FIRST, so a failure names the offending pattern
186            // rather than an opaque joined blob the author never wrote.
187            for pattern in &category.patterns {
188                compile_nonempty_regex("correction", &category.name, pattern)?;
189            }
190
191            // Then compile ONE case-insensitive alternation per category, byte-for-byte the shape
192            // `analytics.rs:compile_category_patterns:120` already produces:
193            // `Regex::new(&format!("(?i){}", patterns.join("|")))`.
194            //
195            // This is not a stylistic choice — compiling each pattern separately and trying them
196            // in order changes results two ways:
197            //   * Case. The existing `(?i)` prefix makes every built-in category
198            //     case-insensitive, so "You Forgot" classifies today and would stop.
199            //   * Position. One alternation returns the LEFTMOST match in the message; separate
200            //     regexes tried in order return the first *pattern's* match wherever it sits. For
201            //     "you missed X, you forgot Y" with patterns ordered [forgot, missed], the
202            //     alternation reports "you missed" and per-pattern iteration reports "you forgot".
203            //     Both the reported `matched_text` and any position-sensitive downstream use would
204            //     differ.
205            let joined = format!("(?i){}", category.patterns.join("|"));
206            let regex = Regex::new(&joined).with_context(|| {
207                format!(
208                    "correction category '{}' does not compile once its {} patterns are combined; \
209                     each is valid alone, so check for an unbalanced group spanning them",
210                    category.name,
211                    category.patterns.len()
212                )
213            })?;
214            if regex.is_match("") {
215                bail!(
216                    "correction category '{}' matches empty text after its patterns are combined; \
217                     every category must require at least one input character",
218                    category.name
219                );
220            }
221            rules.push((category.name.clone(), regex));
222        }
223
224        Ok(CorrectionPolicy {
225            identity: CorrectionPolicyIdentity {
226                name: self.name,
227                version: self.version,
228                sha256: digest,
229                source,
230            },
231            rules,
232            category_count: self.categories.len(),
233        })
234    }
235}
236
237impl CorrectionPolicy {
238    /// Parse and compile a `policy.toml` document, digesting the exact bytes supplied.
239    ///
240    /// The digest covers `source_text` verbatim rather than the re-serialized spec, so a
241    /// whitespace-only or comment-only edit still produces a different identity. That is the
242    /// point: the question a digest answers is "were these the same bytes?", not "did the parsed
243    /// values happen to match?".
244    ///
245    /// # Errors
246    ///
247    /// Returns an error when the document is not valid TOML, carries an unknown field, or fails
248    /// any check in [`CorrectionPolicySpec::compile`].
249    #[cfg(test)]
250    pub fn parse_toml(source_text: &str, source: CorrectionPolicySource) -> Result<Self> {
251        let spec: CorrectionPolicySpec = toml::from_str(source_text).with_context(|| {
252            format!(
253                "failed to parse correction policy from {}",
254                describe_source(&source)
255            )
256        })?;
257        let digest = sha256(source_text.as_bytes());
258        spec.compile(source, digest)
259    }
260
261    /// Classify one message's text, returning the FIRST matching category.
262    ///
263    /// Categories are tried in declaration order and patterns within a category in their own
264    /// order, so a deliberately-last catch-all such as `other` only fires when nothing earlier
265    /// matched. `matched_text` is the substring the regex matched, not the regex source: for the
266    /// rule `\byou forgot\b` over "ok you forgot the tests" the value is `you forgot`.
267    pub fn classify(&self, text: &str) -> Option<CorrectionHit<'_>> {
268        self.classify_fragments(std::iter::once((0, text)))
269    }
270
271    /// Classify disjoint attributable fragments without creating matches across their boundaries.
272    ///
273    /// Rules remain outermost so category priority is identical to [`Self::classify`]. Fragments
274    /// are tried in source order within each rule; `fragment_start_char` keeps returned coordinates
275    /// absolute in the original message while the regex sees only one attributable fragment.
276    fn classify_fragments<'policy, 'text, I>(
277        &'policy self,
278        fragments: I,
279    ) -> Option<CorrectionHit<'policy>>
280    where
281        I: Iterator<Item = (usize, &'text str)> + Clone,
282    {
283        self.rules.iter().find_map(|(category, regex)| {
284            fragments.clone().find_map(|(fragment_start_char, text)| {
285                regex.find(text).map(|matched| {
286                    let match_start_char =
287                        fragment_start_char.saturating_add(text[..matched.start()].chars().count());
288                    let matched_text = matched.as_str().to_string();
289                    let match_end_char_exclusive =
290                        match_start_char.saturating_add(matched_text.chars().count());
291                    CorrectionHit {
292                        category: category.as_str(),
293                        matched_text,
294                        match_start_char,
295                        match_end_char_exclusive,
296                    }
297                })
298            })
299        })
300    }
301
302    /// Name, version, digest, and origin of the bytes this policy was compiled from.
303    pub fn identity(&self) -> &CorrectionPolicyIdentity {
304        &self.identity
305    }
306
307    /// Declared categories, in evaluation order.
308    pub fn category_count(&self) -> usize {
309        self.category_count
310    }
311
312    /// Compiled `(category, regex)` rules, in evaluation order.
313    pub fn rules(&self) -> &[(String, Regex)] {
314        &self.rules
315    }
316}
317
318/// Reserved name of the policy compiled into the executable.
319///
320/// It is always resolvable and cannot be shadowed by a discovered directory: a damaged, stale, or
321/// hostile skill install must never be able to redefine what the product means by "a correction".
322pub const EMBEDDED_POLICY_NAME: &str = "corrections";
323
324/// The ordered set of policies one `corrections` call evaluates, with provenance.
325#[derive(Debug, Clone)]
326pub struct ResolvedCorrectionPolicySet {
327    policies: Vec<CorrectionPolicy>,
328}
329
330impl ResolvedCorrectionPolicySet {
331    /// Use exactly these already-compiled policies, in selection order.
332    ///
333    /// Skill identity, duplicate detection, path authorization, and capability compilation happen
334    /// before this internal execution type is constructed.
335    pub fn from_policies(policies: Vec<CorrectionPolicy>) -> Self {
336        Self { policies }
337    }
338
339    /// Classify one message against every selected policy, in order, returning the first hit.
340    ///
341    /// Policies are tried in selection order and categories within a policy in declaration order,
342    /// so "which policy did this come from" is answerable and stable.
343    pub fn classify(&self, text: &str) -> Option<(&CorrectionPolicyIdentity, CorrectionHit<'_>)> {
344        self.policies
345            .iter()
346            .find_map(|policy| policy.classify(text).map(|hit| (policy.identity(), hit)))
347    }
348
349    /// Classify disjoint source-ordered fragments while preserving policy and category priority.
350    pub(crate) fn classify_fragments<'policy, 'text, I>(
351        &'policy self,
352        fragments: I,
353    ) -> Option<(&'policy CorrectionPolicyIdentity, CorrectionHit<'policy>)>
354    where
355        I: Iterator<Item = (usize, &'text str)> + Clone,
356    {
357        self.policies.iter().find_map(|policy| {
358            policy
359                .classify_fragments(fragments.clone())
360                .map(|hit| (policy.identity(), hit))
361        })
362    }
363
364    /// Provenance for every selected policy, in evaluation order.
365    ///
366    /// Carried once per report rather than repeated on every match: the digest is what makes a
367    /// result reproducible, and it is a property of the run, not of each row.
368    pub fn receipts(&self) -> Vec<CapabilityReceipt> {
369        self.policies
370            .iter()
371            .map(|policy| {
372                let identity = policy.identity();
373                CapabilityReceipt {
374                    name: identity.name.clone(),
375                    version: identity.version.clone(),
376                    sha256: identity.sha256.clone(),
377                }
378            })
379            .collect()
380    }
381
382    /// Selected policies, in evaluation order.
383    pub fn policies(&self) -> &[CorrectionPolicy] {
384        &self.policies
385    }
386
387    /// True when nothing was selected, so `corrections` must return no matches.
388    pub fn is_empty(&self) -> bool {
389        self.policies.is_empty()
390    }
391}
392
393/// Name, version, and digest of one message-classification capability evaluated for a report.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct CapabilityReceipt {
396    pub name: String,
397    pub version: String,
398    /// Exact file-byte digest or canonical in-memory semantic digest. Version alone is not reproducible.
399    pub sha256: String,
400}
401
402/// The result of one message-classification run: what matched, and which rules were in force.
403///
404/// The receipts are not derivable from the matches — a policy that matched nothing still shaped
405/// the result, and its digest is what makes the run reproducible — so they travel together.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct MessageClassificationReport {
408    /// Every evaluated message-classification capability, including those that matched nothing.
409    ///
410    /// Carried once per report rather than repeated per match: name, version, and digest describe
411    /// the run. Each match separately names only the policy it came from.
412    pub policies: Vec<CapabilityReceipt>,
413    /// Matches in newest-first order, after `filters.offset` is skipped and `filters.limit` taken.
414    pub matches: Vec<crate::models::MessageClassificationMatch>,
415}
416
417/// The product-default correction policy, compiled into the executable.
418///
419/// Embedded rather than read from an installed skill directory so that a damaged, stale, or
420/// missing install cannot change what `aise` measures, and so install itself needs no network, no
421/// data directory, and no package-relative lookup. Same mechanism `config.example.toml` already
422/// uses (`config.rs:CONFIG_EXAMPLE_TOML`).
423///
424/// `include_str!` resolves relative to THIS source file, so it reads the crate-local mirror at
425/// `rust/ai-session-search-core/skills/`, not the repo-root `skills/` a human edits. The two are
426/// held byte-identical by `tests/test_repository_contracts.py`.
427///
428/// The digest covers the exact file bytes, so editing a comment in the policy changes the reported
429/// SHA-256 even when the compiled rules are identical. That is the intended reading of
430/// "reproducible": the receipt identifies the bytes that ran.
431pub(crate) fn embedded_policy() -> Result<CorrectionPolicy> {
432    crate::message_classification::MessageClassificationPolicySpec::parse_toml(
433        EMBEDDED_POLICY_TOML,
434    )?
435    .compile(
436        EMBEDDED_POLICY_NAME.to_string(),
437        env!("CARGO_PKG_VERSION").to_string(),
438        CorrectionPolicySource::Embedded,
439        EMBEDDED_POLICY_TOML.as_bytes(),
440    )
441}
442
443/// Bytes of the bundled `ai-session-search/aise-capability.toml`.
444pub(crate) const EMBEDDED_POLICY_TOML: &str =
445    include_str!("../skills/ai-session-search/aise-capability.toml");
446
447/// Length-delimited encoding of a spec, for policies that have no file to digest.
448///
449/// Length prefixes rather than separators, so no category or pattern containing the separator can
450/// collide with a different spec — `["a:b", "c"]` and `["a", "b:c"]` must not digest alike.
451pub(crate) fn canonical_digest_input_len(spec: &CorrectionPolicySpec) -> usize {
452    let mut bytes = 0_usize;
453    let mut add = |value: &str| {
454        bytes = bytes.saturating_add(std::mem::size_of::<u64>() + value.len());
455    };
456    add(&spec.schema_version.to_string());
457    add(&spec.name);
458    add(&spec.version);
459    for category in &spec.categories {
460        add(&category.name);
461        for pattern in &category.patterns {
462            add(pattern);
463        }
464    }
465    bytes
466}
467
468fn canonical_digest_input(spec: &CorrectionPolicySpec) -> Vec<u8> {
469    let mut out = Vec::new();
470    let mut push = |value: &str| {
471        out.extend_from_slice(&(value.len() as u64).to_le_bytes());
472        out.extend_from_slice(value.as_bytes());
473    };
474    push(&spec.schema_version.to_string());
475    push(&spec.name);
476    push(&spec.version);
477    for category in &spec.categories {
478        push(&category.name);
479        for pattern in &category.patterns {
480            push(pattern);
481        }
482    }
483    out
484}
485
486#[cfg(test)]
487fn describe_source(source: &CorrectionPolicySource) -> String {
488    match source {
489        CorrectionPolicySource::Embedded => "the policy embedded in this executable".to_string(),
490        CorrectionPolicySource::File { path } => path.display().to_string(),
491        CorrectionPolicySource::Inline => "typed inline capability parameters".to_string(),
492    }
493}
494
495#[cfg(test)]
496mod tests {
497
498    /// Boundary category counts. One and many must both work; zero must be refused.
499    #[test]
500    fn a_policy_may_have_one_category_or_many_but_never_zero() {
501        let spec = |count: usize| CorrectionPolicySpec {
502            schema_version: CORRECTION_POLICY_SCHEMA_VERSION,
503            name: "boundary".to_string(),
504            version: "1.0.0".to_string(),
505            categories: (0..count)
506                .map(|index| CorrectionCategorySpec {
507                    name: format!("c{index}"),
508                    patterns: vec![format!(r"\bmarker{index}\b")],
509                })
510                .collect(),
511        };
512
513        let error = spec(0)
514            .compile_in_memory(CorrectionPolicySource::Embedded)
515            .expect_err("a policy with no categories matches nothing, silently");
516        assert!(format!("{error:#}").contains("[[categories]]"), "{error:#}");
517
518        for count in [1_usize, 64] {
519            let policy = spec(count)
520                .compile_in_memory(CorrectionPolicySource::Embedded)
521                .unwrap_or_else(|error| panic!("{count} categories must compile: {error:#}"));
522            assert_eq!(policy.category_count(), count);
523            // The LAST category is still reachable: order is preserved, not truncated.
524            let hit = policy
525                .classify(&format!("marker{}", count - 1))
526                .expect("the final category still matches");
527            assert_eq!(hit.category, format!("c{}", count - 1));
528        }
529    }
530
531    /// A pattern that matches everything would label every message, so it is refused at compile
532    /// time rather than discovered when every user message becomes a "correction".
533    #[test]
534    fn a_pattern_matching_empty_text_is_refused() {
535        let spec = CorrectionPolicySpec {
536            schema_version: CORRECTION_POLICY_SCHEMA_VERSION,
537            name: "greedy".to_string(),
538            version: "1.0.0".to_string(),
539            categories: vec![CorrectionCategorySpec {
540                name: "everything".to_string(),
541                patterns: vec![".*".to_string()],
542            }],
543        };
544        let error = spec
545            .compile_in_memory(CorrectionPolicySource::Embedded)
546            .expect_err("a rule that matches the empty string matches every message");
547        let message = format!("{error:#}");
548        assert!(
549            message.contains(".*"),
550            "name the offending pattern: {message}"
551        );
552    }
553
554    /// Whitespace-only is not a value. Each blank field is refused by name, so a caller does not
555    /// have to guess which of three strings was the empty one.
556    #[test]
557    fn blank_names_and_versions_are_refused_by_field() {
558        for (name, version, category, expected) in [
559            ("", "1.0.0", "c", "correction policy name"),
560            ("   ", "1.0.0", "c", "correction policy name"),
561            ("ok", "", "c", "correction policy version"),
562            ("ok", "  ", "c", "correction policy version"),
563            ("ok", "1.0.0", "  ", "correction category name"),
564        ] {
565            let spec = CorrectionPolicySpec {
566                schema_version: CORRECTION_POLICY_SCHEMA_VERSION,
567                name: name.to_string(),
568                version: version.to_string(),
569                categories: vec![CorrectionCategorySpec {
570                    name: category.to_string(),
571                    patterns: vec![r"\bx\b".to_string()],
572                }],
573            };
574            let error = spec
575                .compile_in_memory(CorrectionPolicySource::Embedded)
576                .expect_err(&format!(
577                    "{name:?}/{version:?}/{category:?} must be refused"
578                ));
579            let message = format!("{error:#}");
580            assert!(
581                message.contains(expected),
582                "the message must name WHICH field is blank: {message}"
583            );
584        }
585    }
586
587    use super::*;
588
589    fn policy(body: &str) -> String {
590        format!(
591            "schema_version = 1\nname = \"fixture\"\nversion = \"1.0.0\"\n\n{body}",
592            body = body
593        )
594    }
595
596    fn compile(body: &str) -> Result<CorrectionPolicy> {
597        CorrectionPolicy::parse_toml(&policy(body), CorrectionPolicySource::Embedded)
598    }
599
600    /// Render a rejected policy the way `main.rs:20` renders it for the user: `{:#}`, which walks
601    /// the whole `anyhow` chain. Plain `to_string()` shows only the outermost context, so an
602    /// assertion written against it would pass while the detail the caller needs -- which key was
603    /// misspelled, which regex failed -- sat unread in a source error.
604    fn message(result: Result<CorrectionPolicy>) -> String {
605        format!(
606            "{:#}",
607            result.expect_err("policy should have been rejected")
608        )
609    }
610
611    // THE reason this type exists rather than reusing `AnalysisPolicySpec`. That type's `compile`
612    // sorts by (dimension, label), which would reorder these categories alphabetically to
613    // [incomplete, other, regression] and make the catch-all fire before the specific rule.
614    #[test]
615    fn declaration_order_is_evaluation_order() {
616        let compiled = compile(
617            r#"
618[[categories]]
619name = "regression"
620patterns = ['''\byou broke\b''']
621
622[[categories]]
623name = "incomplete"
624patterns = ['''\bstill need\b''']
625
626[[categories]]
627name = "other"
628patterns = ['''\bstop\b''']
629"#,
630        )
631        .unwrap();
632
633        let order: Vec<&str> = compiled
634            .rules()
635            .iter()
636            .map(|(category, _)| category.as_str())
637            .collect();
638        assert_eq!(
639            order,
640            ["regression", "incomplete", "other"],
641            "categories must evaluate in declaration order, not sorted order"
642        );
643    }
644
645    #[test]
646    fn first_matching_category_wins_and_later_ones_are_not_reported() {
647        let compiled = compile(
648            r#"
649[[categories]]
650name = "specific"
651patterns = ['''\byou broke the build\b''']
652
653[[categories]]
654name = "catch_all"
655patterns = ['''\bbroke\b''']
656"#,
657        )
658        .unwrap();
659
660        let hit = compiled.classify("you broke the build again").unwrap();
661        assert_eq!(hit.category, "specific");
662        assert_eq!(hit.matched_text, "you broke the build");
663
664        // The catch-all still fires when nothing earlier matches.
665        let hit = compiled.classify("the deploy broke overnight").unwrap();
666        assert_eq!(hit.category, "catch_all");
667    }
668
669    #[test]
670    fn patterns_within_a_category_are_ored() {
671        let compiled = compile(
672            r#"
673[[categories]]
674name = "skip_step"
675patterns = ['''\byou forgot\b''', '''\byou missed\b''', '''\byou skipped\b''']
676"#,
677        )
678        .unwrap();
679
680        for (text, expected) in [
681            ("you forgot the tests", "you forgot"),
682            ("you missed a step", "you missed"),
683            ("you skipped the lint", "you skipped"),
684        ] {
685            let hit = compiled.classify(text).unwrap();
686            assert_eq!(hit.category, "skip_step");
687            assert_eq!(hit.matched_text, expected);
688        }
689        assert!(compiled.classify("everything looks fine").is_none());
690    }
691
692    // Two properties that fall out of compiling ONE alternation per category rather than one
693    // regex per pattern. Both are existing behavior from
694    // `analytics.rs:compile_category_patterns:120`, and both would have broken silently under
695    // per-pattern iteration — no test covered either before.
696    #[test]
697    fn categories_are_case_insensitive_and_report_the_leftmost_match() {
698        let compiled = compile(
699            r#"
700[[categories]]
701name = "skip_step"
702patterns = ['''\byou forgot\b''', '''\byou missed\b''']
703"#,
704        )
705        .unwrap();
706
707        // (?i) is prefixed to the joined alternation, so capitalization does not change results.
708        assert_eq!(
709            compiled
710                .classify("You Forgot the tests")
711                .unwrap()
712                .matched_text,
713            "You Forgot",
714            "matching is case-insensitive, and matched_text preserves the ORIGINAL casing"
715        );
716
717        // "you missed" appears first in the text but SECOND in the pattern list. One alternation
718        // reports the leftmost match; trying patterns in order would report "you forgot" instead.
719        assert_eq!(
720            compiled
721                .classify("you missed X, and you forgot Y")
722                .unwrap()
723                .matched_text,
724            "you missed",
725            "within a category the leftmost match in the message wins, not the first pattern"
726        );
727    }
728
729    // The value is the OUTPUT substring, not the rule that produced it. Naming it after the rule
730    // is exactly the S16 defect this plan renamed away from.
731    #[test]
732    fn matched_text_is_the_substring_not_the_rule() {
733        let compiled = compile(
734            r#"
735[[categories]]
736name = "regression"
737patterns = ['''\byou (deleted|removed|reverted)\b''']
738"#,
739        )
740        .unwrap();
741
742        let hit = compiled.classify("é wait, you reverted my fix").unwrap();
743        assert_eq!(hit.matched_text, "you reverted");
744        assert_eq!(hit.match_start_char, 8);
745        assert_eq!(hit.match_end_char_exclusive, 20);
746        assert_ne!(
747            hit.matched_text, r"\byou (deleted|removed|reverted)\b",
748            "the field must not carry the regex source"
749        );
750    }
751
752    #[test]
753    fn identity_digests_the_exact_source_bytes() {
754        let body = r#"
755[[categories]]
756name = "regression"
757patterns = ['''\byou broke\b''']
758"#;
759        let first = compile(body).unwrap();
760        let second = compile(body).unwrap();
761        assert_eq!(
762            first.identity().sha256,
763            second.identity().sha256,
764            "identical bytes must digest identically"
765        );
766
767        // A comment-only edit parses to the same spec but is NOT the same bytes, and the digest
768        // must say so — that is what makes a result reproducible after an untracked edit.
769        let commented = compile(&format!("# a note\n{body}")).unwrap();
770        assert_ne!(
771            first.identity().sha256,
772            commented.identity().sha256,
773            "a byte-level edit must change the digest even when the parsed values match"
774        );
775        assert_eq!(first.identity().name, "fixture");
776        assert_eq!(first.identity().version, "1.0.0");
777        assert_eq!(first.identity().source, CorrectionPolicySource::Embedded);
778    }
779
780    #[test]
781    fn unknown_schema_version_is_rejected_by_name() {
782        let source = "schema_version = 2\nname = \"f\"\nversion = \"1\"\n\n[[categories]]\nname = \"c\"\npatterns = ['''x''']\n";
783        let err = CorrectionPolicy::parse_toml(source, CorrectionPolicySource::Embedded);
784        let err = message(err);
785        assert!(err.contains("schema_version 2"), "{err}");
786        assert!(
787            err.contains("Upgrade aise"),
788            "must say what to do, not only what is wrong: {err}"
789        );
790    }
791
792    #[test]
793    fn unknown_field_is_a_hard_error_not_a_silent_drop() {
794        let err = compile(
795            r#"
796[[categories]]
797name = "regression"
798pattern = ['''\byou broke\b''']
799"#,
800        );
801        let err = message(err);
802        assert!(
803            err.contains("pattern"),
804            "a misspelled key must be named, not ignored: {err}"
805        );
806    }
807
808    #[test]
809    fn duplicate_category_names_are_rejected_because_the_second_is_unreachable() {
810        let err = compile(
811            r#"
812[[categories]]
813name = "regression"
814patterns = ['''\byou broke\b''']
815
816[[categories]]
817name = "regression"
818patterns = ['''\byou deleted\b''']
819"#,
820        );
821        let err = message(err);
822        assert!(
823            err.contains("duplicate correction category 'regression'"),
824            "{err}"
825        );
826        assert!(err.contains("never be reached"), "must explain why: {err}");
827    }
828
829    #[test]
830    fn structural_emptiness_is_rejected_at_every_level() {
831        let no_categories = CorrectionPolicy::parse_toml(
832            "schema_version = 1\nname = \"f\"\nversion = \"1\"\ncategories = []\n",
833            CorrectionPolicySource::Embedded,
834        );
835        let no_categories = message(no_categories);
836        assert!(
837            no_categories.contains("defines no categories"),
838            "{no_categories}"
839        );
840
841        let no_patterns = message(compile(
842            "[[categories]]\nname = \"regression\"\npatterns = []\n",
843        ));
844        assert!(no_patterns.contains("has no patterns"), "{no_patterns}");
845
846        let blank_name = message(compile(
847            "[[categories]]\nname = \"   \"\npatterns = ['''x''']\n",
848        ));
849        assert!(blank_name.contains("must not be empty"), "{blank_name}");
850
851        let blank_policy_name = message(CorrectionPolicy::parse_toml(
852            "schema_version = 1\nname = \"\"\nversion = \"1\"\n\n[[categories]]\nname = \"c\"\npatterns = ['''x''']\n",
853            CorrectionPolicySource::Embedded,
854        ));
855        assert!(
856            blank_policy_name.contains("must not be empty"),
857            "{blank_policy_name}"
858        );
859    }
860
861    #[test]
862    fn invalid_and_empty_matching_regexes_are_rejected() {
863        let invalid = message(compile(
864            "[[categories]]\nname = \"c\"\npatterns = ['''(unclosed''']\n",
865        ));
866        assert!(invalid.contains("invalid correction regex"), "{invalid}");
867
868        // A pattern matching empty text would classify EVERY message into that category and
869        // starve every later one, because the first match wins.
870        let empty_matching = message(compile(
871            "[[categories]]\nname = \"c\"\npatterns = ['''a*''']\n",
872        ));
873        assert!(
874            empty_matching.contains("matches empty text"),
875            "{empty_matching}"
876        );
877        assert!(
878            empty_matching.contains("a*"),
879            "and it names WHICH pattern, since a category may hold several: {empty_matching}"
880        );
881
882        let empty_pattern = message(compile(
883            "[[categories]]\nname = \"c\"\npatterns = ['''''']\n",
884        ));
885        assert!(
886            empty_pattern.contains("must not be empty"),
887            "{empty_pattern}"
888        );
889    }
890
891    // THE behavioral-equivalence contract. The embedded capability must
892    // compile to exactly what `analytics::compile_patterns` produces today -- same categories, in
893    // the same order, with the same regex source. Comparing at the COMPILED level rather than
894    // comparing output text is what makes this robust: it fails if a category is renamed,
895    // reordered, dropped, or has a pattern edited, without needing a session fixture.
896    #[test]
897    fn the_default_resolution_compiles_to_todays_built_in_rules() {
898        let config = crate::config::Config::default();
899        let resolved = ResolvedCorrectionPolicySet::from_policies(vec![embedded_policy().unwrap()]);
900
901        let new_rules: Vec<(String, String)> = resolved.policies()[0]
902            .rules()
903            .iter()
904            .map(|(category, regex)| (category.clone(), regex.as_str().to_string()))
905            .collect();
906        let old_rules: Vec<(String, String)> = crate::analytics::compile_patterns(&config)
907            .unwrap()
908            .iter()
909            .map(|(category, regex)| (category.clone(), regex.as_str().to_string()))
910            .collect();
911
912        assert_eq!(
913            new_rules, old_rules,
914            "the embedded policy must be indistinguishable from the built-ins it replaces"
915        );
916        assert_eq!(resolved.receipts()[0].name, EMBEDDED_POLICY_NAME);
917    }
918
919    #[test]
920    fn malformed_toml_names_where_it_came_from() {
921        let err = CorrectionPolicy::parse_toml(
922            "schema_version = ",
923            CorrectionPolicySource::File {
924                path: PathBuf::from("/skills/mine/corrections/policy.toml"),
925            },
926        );
927        let err = message(err);
928        assert!(
929            err.contains("/skills/mine/corrections/policy.toml"),
930            "a parse failure must name the file: {err}"
931        );
932    }
933}