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 Context<'a> {
rules: &'a [RuleSpec],
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 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,
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 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(_)));
}
}