use serde::Serialize;
use crate::domain::applicability;
use crate::errors::{Error, Result};
#[derive(Debug, Clone, Serialize)]
pub struct RuleSpec {
pub name: String,
pub description: String,
pub rationale: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub relevance: Option<String>,
pub require_line_attribution: bool,
pub files: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FileDiff {
pub file: String,
pub diff: String,
}
#[derive(Serialize)]
struct FileEntry<'a> {
file: &'a str,
mode: applicability::Mode,
rules: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
diff: Option<&'a str>,
}
#[derive(Serialize)]
struct RuleEntry<'a> {
#[serde(flatten)]
rule: &'a RuleSpec,
scope_mode: applicability::Mode,
scope_files: Vec<String>,
}
#[derive(Serialize)]
struct Context<'a> {
rules: &'a [RuleEntry<'a>],
files: &'a [String],
file_rules: &'a [FileEntry<'a>],
diffs: &'a [FileDiff],
rationales: bool,
relevance: bool,
line_attribution: bool,
}
pub fn render(
template: &str,
rules: &[RuleSpec],
files: &[String],
diffs: &[FileDiff],
rationales: bool,
relevance: bool,
line_attribution: bool,
) -> Result<String> {
let rule_entries: Vec<RuleEntry> = rules
.iter()
.map(|rule| {
let scope = applicability::per_rule(&rule.files, files);
RuleEntry {
rule,
scope_mode: scope.mode,
scope_files: scope.files,
}
})
.collect();
let pairs: Vec<(String, Vec<String>)> = rules
.iter()
.map(|r| (r.name.clone(), r.files.clone()))
.collect();
let applic = applicability::per_file(&pairs, files);
let file_rules: Vec<FileEntry> = applic
.iter()
.map(|fr| FileEntry {
file: &fr.file,
mode: fr.mode,
rules: &fr.rules,
diff: diffs
.iter()
.find(|d| d.file == fr.file)
.map(|d| d.diff.as_str()),
})
.collect();
let mut env = minijinja::Environment::new();
env.set_keep_trailing_newline(true);
let ctx = Context {
rules: &rule_entries,
files,
file_rules: &file_rules,
diffs,
rationales,
relevance,
line_attribution,
};
env.render_str(template, ctx)
.map_err(|e| Error::Template(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn rules() -> Vec<RuleSpec> {
vec![
RuleSpec {
name: "no_inline_sql".into(),
description: "true when no SQL is inline; false otherwise.".into(),
rationale: true,
relevance: None,
require_line_attribution: false,
files: vec!["src/a.rs".into(), "src/b.rs".into()],
},
RuleSpec {
name: "layered".into(),
description: "true when layered.".into(),
rationale: true,
relevance: None,
require_line_attribution: false,
files: vec!["src/a.rs".into(), "src/b.rs".into()],
},
]
}
#[test]
fn renders_rules_and_files() {
let tmpl = "Files:\n{% for f in files %}- {{ f }}\n{% endfor %}\
Rules:\n{% for r in rules %}* {{ r.name }}: {{ r.description }}\n{% endfor %}";
let out = render(
tmpl,
&rules(),
&["src/a.rs".into(), "src/b.rs".into()],
&[],
true,
false,
false,
)
.unwrap();
assert!(out.contains("- src/a.rs"));
assert!(out.contains("- src/b.rs"));
assert!(out.contains("* no_inline_sql: true when no SQL is inline"));
assert!(out.contains("* layered: true when layered."));
}
#[test]
fn diffs_block_is_gated_and_renders_per_file() {
let tmpl = "{% if diffs %}CHANGED\n{% for d in diffs %}{{ d.file }}:\n{{ d.diff }}\
{% endfor %}{% else %}WHOLE{% endif %}";
let off = render(
tmpl,
&rules(),
&["src/a.rs".into()],
&[],
true,
false,
false,
)
.unwrap();
assert!(off.contains("WHOLE"), "got: {off}");
let diffs = vec![FileDiff {
file: "src/a.rs".into(),
diff: "@@ -1 +1 @@\n-old\n+new\n".into(),
}];
let on = render(
tmpl,
&rules(),
&["src/a.rs".into()],
&diffs,
true,
false,
false,
)
.unwrap();
assert!(on.contains("CHANGED"), "got: {on}");
assert!(on.contains("src/a.rs:"), "got: {on}");
assert!(on.contains("+new"), "got: {on}");
}
#[test]
fn file_rules_inline_the_diff_for_a_changed_file_only() {
let tmpl = "{% for fr in file_rules %}{{ fr.file }}\
{% if fr.diff %} DIFF[{{ fr.diff }}]{% endif %}\n{% endfor %}";
let diffs = vec![FileDiff {
file: "src/a.rs".into(),
diff: "@@ -1 +1 @@\n+new\n".into(),
}];
let out = render(
tmpl,
&rules(),
&["src/a.rs".into(), "src/b.rs".into()],
&diffs,
false,
false,
false,
)
.unwrap();
assert!(
out.contains("src/a.rs DIFF[@@ -1 +1 @@\n+new\n]"),
"out:\n{out}"
);
assert!(out.contains("src/b.rs\n"), "out:\n{out}");
assert!(!out.contains("src/b.rs DIFF"), "out:\n{out}");
}
#[test]
fn rationales_flag_and_per_rule_rationale_are_in_scope() {
let tmpl = "{% if rationales %}WANT{% else %}SKIP{% endif %}\n\
{% for r in rules %}{{ r.name }}={{ r.rationale }}\n{% endfor %}";
let on = render(tmpl, &rules(), &[], &[], true, false, false).unwrap();
assert!(on.contains("WANT"));
assert!(on.contains("no_inline_sql=true"));
let off = render(tmpl, &rules(), &[], &[], false, false, false).unwrap();
assert!(off.contains("SKIP"));
}
#[test]
fn relevance_flag_and_per_rule_condition_are_in_scope() {
let tmpl = "{% if relevance %}GATE{% else %}NOGATE{% endif %}\n\
{% for r in rules %}{% if r.relevance %}{{ r.name }}: {{ r.relevance }}\n\
{% endif %}{% endfor %}";
let mut rs = rules();
rs[0].relevance = Some("the change touches SQL".into());
let on = render(tmpl, &rs, &[], &[], true, true, false).unwrap();
assert!(on.contains("GATE"));
assert!(on.contains("no_inline_sql: the change touches SQL"));
assert!(!on.contains("layered:"));
let off = render(tmpl, &rules(), &[], &[], true, false, false).unwrap();
assert!(off.contains("NOGATE"));
}
#[test]
fn line_attribution_flag_and_per_rule_marker_are_in_scope() {
let tmpl = "{% if line_attribution %}LOCALIZE{% else %}ANYWHERE{% endif %}\n\
{% for r in rules %}{% if r.require_line_attribution %}{{ r.name }} pinned\n\
{% endif %}{% endfor %}";
let mut rs = rules();
rs[0].require_line_attribution = true;
let on = render(tmpl, &rs, &[], &[], true, false, true).unwrap();
assert!(on.contains("LOCALIZE"));
assert!(on.contains("no_inline_sql pinned"));
assert!(!on.contains("layered pinned"));
let off = render(tmpl, &rules(), &[], &[], true, false, false).unwrap();
assert!(off.contains("ANYWHERE"));
}
#[test]
fn the_default_template_demands_completeness_for_every_flag_combination() {
for rationales in [false, true] {
for relevance in [false, true] {
for line_attribution in [false, true] {
let mut rs = rules();
rs[0].rationale = rationales;
rs[0].relevance = relevance.then(|| "the change touches SQL".into());
rs[0].require_line_attribution = line_attribution;
let out = render(
crate::io::assets::DEFAULT_TEMPLATE,
&rs,
&["src/a.rs".into(), "src/b.rs".into()],
&[],
rationales,
relevance,
line_attribution,
)
.unwrap_or_else(|e| {
panic!("default template failed to render ({rationales}/{relevance}/{line_attribution}): {e}")
});
assert!(
out.contains("report **every** distinct violation"),
"no completeness instruction ({rationales}/{relevance}/{line_attribution}):\n{out}"
);
assert!(
out.contains("**Judge each rule independently.**"),
"no rule-independence instruction:\n{out}"
);
assert_eq!(out.contains("## Line attribution"), line_attribution);
assert_eq!(out.contains("## Rationale"), rationales);
assert_eq!(out.contains("## Relevance"), relevance);
assert_eq!(
out.contains("When the property holds, keep it terse"),
rationales
);
assert_eq!(
out.contains("account for **every** site you found"),
rationales
);
assert_eq!(
out.contains("Every violation must cite a `file` and `line`."),
line_attribution
);
assert_eq!(
out.contains("exempt from the citation requirement only"),
line_attribution
);
assert_eq!(
out.contains("list must still be complete"),
line_attribution
);
}
}
}
}
#[test]
fn file_rules_expose_per_file_applicability() {
let rs = vec![
RuleSpec {
name: "only_a".into(),
description: "d".into(),
rationale: false,
relevance: None,
require_line_attribution: false,
files: vec!["src/a.rs".into()],
},
RuleSpec {
name: "only_b".into(),
description: "d".into(),
rationale: false,
relevance: None,
require_line_attribution: false,
files: vec!["src/b.rs".into()],
},
];
let tmpl = "{% for fr in file_rules %}{{ fr.file }}:{{ fr.mode }}:\
{% for r in fr.rules %}{{ r }} {% endfor %}\n{% endfor %}";
let out = render(
tmpl,
&rs,
&["src/a.rs".into(), "src/b.rs".into()],
&[],
false,
false,
false,
)
.unwrap();
assert!(out.contains("src/a.rs:include:only_a"), "out:\n{out}");
assert!(out.contains("src/b.rs:include:only_b"), "out:\n{out}");
}
#[test]
fn invalid_template_is_a_template_error() {
let err = render("{% for x in %}", &rules(), &[], &[], true, false, false).unwrap_err();
assert!(matches!(err, Error::Template(_)));
}
}