use serde_json::Value;
use super::model::{CompilePolicy, ContextAction, ContextFragment};
#[derive(Debug, Clone, Copy)]
pub(crate) struct RuleMatch {
pub id: &'static str,
pub action: ContextAction,
pub critical: bool,
pub reason: &'static str,
}
struct Rule {
id: &'static str,
action: ContextAction,
critical: bool,
reason: &'static str,
applies: fn(&ContextFragment) -> bool,
}
const RULES: &[Rule] = &[
Rule {
id: "preserve.marked_verbatim",
action: ContextAction::Preserve,
critical: true,
reason: "caller marked this fragment verbatim",
applies: is_marked_verbatim,
},
Rule {
id: "cache.stable_prefix",
action: ContextAction::Cache,
critical: true,
reason: "caller marked this fragment cacheable; it forms the stable prefix",
applies: is_marked_cache,
},
Rule {
id: "preserve.code_fence",
action: ContextAction::Preserve,
critical: true,
reason: "code must survive verbatim",
applies: is_code,
},
Rule {
id: "preserve.negative_constraint",
action: ContextAction::Preserve,
critical: true,
reason: "negative constraints must never be weakened",
applies: has_negative_constraint,
},
Rule {
id: "abstract.log_dedup",
action: ContextAction::Abstract,
critical: false,
reason: "repeated log lines collapse into one annotated line",
applies: is_repetitive_log,
},
Rule {
id: "preserve.exact_values",
action: ContextAction::Preserve,
critical: true,
reason: "numbers, dates and identifiers must survive verbatim",
applies: is_value_dense,
},
Rule {
id: "preserve.url",
action: ContextAction::Preserve,
critical: true,
reason: "URLs must survive verbatim",
applies: has_url,
},
Rule {
id: "preserve.default",
action: ContextAction::Preserve,
critical: false,
reason: "prose kept subject to budget",
applies: |_| true,
},
];
const TERMINAL_RULE_ID: &str = "preserve.default";
pub(crate) fn classify(fragment: &ContextFragment, policy: &CompilePolicy) -> RuleMatch {
RULES
.iter()
.filter(|rule| {
rule.id == TERMINAL_RULE_ID || !policy.disabled_rules.iter().any(|d| d == rule.id)
})
.find(|rule| (rule.applies)(fragment))
.map_or_else(|| to_match(&RULES[RULES.len() - 1]), to_match)
}
fn to_match(rule: &Rule) -> RuleMatch {
RuleMatch {
id: rule.id,
action: rule.action,
critical: rule.critical,
reason: rule.reason,
}
}
fn is_marked_verbatim(fragment: &ContextFragment) -> bool {
bool_meta(fragment, "verbatim")
}
fn is_marked_cache(fragment: &ContextFragment) -> bool {
bool_meta(fragment, "cache")
}
fn bool_meta(fragment: &ContextFragment, key: &str) -> bool {
fragment
.metadata
.as_ref()
.and_then(|meta| meta.get(key))
.is_some_and(|value| matches!(value, Value::Bool(true)))
}
fn is_code(fragment: &ContextFragment) -> bool {
fragment.kind.as_deref() == Some("code") || fragment.content.contains("```")
}
fn has_negative_constraint(fragment: &ContextFragment) -> bool {
const MARKERS: &[&str] = &[
"never ",
"must not",
"do not",
"don't",
"ne pas",
"ne jamais",
"jamais ",
];
fragment.content.lines().any(|line| {
let lowered = word_bounded(&line.to_lowercase());
MARKERS.iter().any(|marker| lowered.contains(marker))
})
}
fn word_bounded(line: &str) -> String {
let mut normalized: String = line
.chars()
.map(|c| {
if c.is_ascii_punctuation() && c != '\'' {
' '
} else {
c
}
})
.collect();
normalized.push(' ');
normalized
}
fn is_repetitive_log(fragment: &ContextFragment) -> bool {
if fragment.kind.as_deref() != Some("log") {
return false;
}
let mut seen = std::collections::BTreeSet::new();
fragment
.content
.lines()
.any(|line| !line.trim().is_empty() && !seen.insert(line))
}
fn is_value_dense(fragment: &ContextFragment) -> bool {
fragment
.content
.split_whitespace()
.filter(|token| token.bytes().any(|byte| byte.is_ascii_digit()))
.count()
>= 3
}
fn has_url(fragment: &ContextFragment) -> bool {
fragment.content.contains("http://") || fragment.content.contains("https://")
}
pub(crate) fn collapse_repeated_lines(content: &str) -> String {
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for line in content.lines() {
*counts.entry(line).or_insert(0) += 1;
}
let mut emitted: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut lines: Vec<String> = Vec::new();
for line in content.lines() {
if emitted.insert(line) {
lines.push(annotated(line, counts[line]));
}
}
lines.join("\n")
}
fn annotated(line: &str, count: usize) -> String {
if count > 1 {
format!("{line} (x{count})")
} else {
line.to_owned()
}
}
#[cfg(test)]
#[path = "classify_tests.rs"]
mod tests;