use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use lanekeep_core::{Examples, Gates, Namespace, RuleCard, RuleId, Severity};
use lanekeep_js::{Limits, RuleRoot, RunClock, Sandbox};
use serde::Deserialize;
use thiserror::Error;
pub type Hash = [u8; 32];
mod json;
#[must_use]
pub fn hex(hash: &Hash) -> String {
use std::fmt::Write as _;
hash.iter()
.fold(String::with_capacity(64), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleSpec {
pub index: usize,
pub id: RuleId,
pub languages: Vec<String>,
pub severity: Severity,
pub card: RuleCard,
pub query: String,
pub gates: Gates,
pub timeout: Option<Duration>,
pub has_reduce: bool,
}
#[expect(
clippy::struct_field_names,
reason = "`ruleset_hash` and `config_hash` are the names docs/architecture.md §8.1 \
gives these two cache-key inputs. Renaming them to satisfy the lint would \
make the code and the specification disagree about the same thing, which \
costs more than the repetition saves."
)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub include: Vec<String>,
pub exclude: Vec<String>,
pub rules: Vec<RuleSpec>,
pub limits: Limits,
pub ruleset_hash: Hash,
pub config_hash: Hash,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ConfigError {
#[error("cannot load config `{path}`: {detail}")]
Unreadable {
path: String,
detail: String,
},
#[error("config `{path}` failed to evaluate\n{detail}")]
Evaluation {
path: String,
detail: String,
},
#[error("config `{path}` is not valid: {detail}")]
Shape {
path: String,
detail: String,
},
#[error("rule {position} in `{path}` is not valid: {detail}")]
Rule {
position: usize,
path: String,
detail: String,
},
}
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
include: Vec<String>,
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
namespaces: Vec<String>,
#[serde(default)]
severity: BTreeMap<String, String>,
#[serde(default)]
timeouts: RawTimeouts,
#[serde(default)]
rules: Vec<RawRule>,
}
#[derive(Debug, Default, Deserialize)]
struct RawTimeouts {
rule: Option<u64>,
global: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct RawRule {
id: Option<String>,
language: Option<RawLanguages>,
severity: Option<String>,
card: Option<RawCard>,
query: Option<String>,
#[serde(default)]
gates: Gates,
timeout: Option<u64>,
has_check: bool,
has_reduce: bool,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawLanguages {
One(String),
Many(Vec<String>),
}
impl RawLanguages {
fn into_vec(self) -> Vec<String> {
match self {
Self::One(language) => vec![language],
Self::Many(languages) => languages,
}
}
}
#[derive(Debug, Deserialize)]
struct RawCard {
message: Option<String>,
remediation: Option<String>,
examples: Option<RawExamples>,
}
#[derive(Debug, Deserialize)]
struct RawExamples {
bad: Option<String>,
good: Option<String>,
}
const ENTRY: &str = "__lanekeep_entry__.js";
const EXTRACT: &str = r"
(() => {
const c = globalThis.__lanekeepConfig;
if (c === null || typeof c !== 'object') return JSON.stringify(null);
const rules = Array.isArray(c.rules) ? c.rules : [];
return JSON.stringify({
include: c.include ?? [],
namespaces: c.namespaces ?? [],
exclude: c.exclude ?? [],
severity: c.severity ?? {},
timeouts: c.timeouts ?? {},
rules: rules.map((r) => ({
id: r?.id ?? null,
language: r?.language ?? null,
severity: r?.severity ?? null,
card: r?.card ?? null,
query: r?.query ?? null,
gates: r?.gates ?? {},
timeout: r?.timeout ?? null,
has_check: typeof r?.check === 'function',
has_reduce: typeof r?.reduce === 'function',
})),
});
})()
";
fn entry_source(root: &RuleRoot, config_path: &Path, display: &str) -> Result<String, ConfigError> {
if json::is_json(config_path) {
return json::entry_source(config_path);
}
let specifier =
relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
path: display.to_owned(),
detail: "the config file must sit inside the rules root".to_owned(),
})?;
Ok(format!(
"import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n"
))
}
pub fn evaluate_into(
sandbox: &Sandbox,
root: &RuleRoot,
config_path: &Path,
) -> Result<(), ConfigError> {
let display = config_path.display().to_string();
let entry = root.path().join(ENTRY);
let source = entry_source(root, config_path, &display)?;
sandbox
.eval_module(&entry.display().to_string(), &source)
.map_err(|e| ConfigError::Evaluation {
path: display,
detail: e.to_string(),
})
}
pub fn load(sandbox: &Sandbox, root: &RuleRoot, config_path: &Path) -> Result<Config, ConfigError> {
let display = config_path.display().to_string();
let entry = root.path().join(ENTRY);
let source = entry_source(root, config_path, &display)?;
sandbox
.eval_module(&entry.display().to_string(), &source)
.map_err(|e| ConfigError::Evaluation {
path: display.clone(),
detail: e.to_string(),
})?;
let json: String = sandbox.eval(EXTRACT).map_err(|e| ConfigError::Evaluation {
path: display.clone(),
detail: e.to_string(),
})?;
let raw: Option<RawConfig> = serde_json::from_str(&json).map_err(|e| ConfigError::Shape {
path: display.clone(),
detail: e.to_string(),
})?;
let raw = raw.ok_or_else(|| ConfigError::Shape {
path: display.clone(),
detail: "the default export is not an object — did you forget `export default`?".to_owned(),
})?;
build(sandbox, raw, &display)
}
fn build(sandbox: &Sandbox, raw: RawConfig, display: &str) -> Result<Config, ConfigError> {
let overrides = parse_severity_overrides(&raw.severity, display)?;
let mut declared = BTreeSet::new();
for namespace in &raw.namespaces {
RuleId::namespace_from_str(namespace).map_err(|e| ConfigError::Shape {
path: display.to_owned(),
detail: format!("`namespaces` contains an invalid entry: {e}"),
})?;
if namespace == Namespace::LANEKEEP {
return Err(ConfigError::Shape {
path: display.to_owned(),
detail: "`lanekeep` is reserved for rules shipped with lanekeep — a rule's \
origin should be readable from its ID"
.to_owned(),
});
}
declared.insert(namespace.clone());
}
let mut rules = Vec::with_capacity(raw.rules.len());
for (index, rule) in raw.rules.into_iter().enumerate() {
rules.push(build_rule(rule, index + 1, display, &overrides, &declared)?);
}
let mut limits = Limits::default();
if let Some(ms) = raw.timeouts.rule {
limits = limits.with_rule_timeout(Duration::from_millis(ms));
}
if let Some(ms) = raw.timeouts.global {
limits = limits.with_global_timeout(Duration::from_millis(ms));
}
let ruleset_hash = hash_ruleset(sandbox);
let config_hash = hash_config(&raw.include, &raw.exclude, &overrides, &limits);
Ok(Config {
include: raw.include,
exclude: raw.exclude,
rules,
limits,
ruleset_hash,
config_hash,
})
}
fn parse_severity_overrides(
raw: &BTreeMap<String, String>,
display: &str,
) -> Result<BTreeMap<RuleId, Severity>, ConfigError> {
raw.iter()
.map(|(id, severity)| {
let id = id.parse::<RuleId>().map_err(|e| ConfigError::Shape {
path: display.to_owned(),
detail: format!("in `severity`: {e}"),
})?;
let severity = severity
.parse::<Severity>()
.map_err(|e| ConfigError::Shape {
path: display.to_owned(),
detail: format!("in `severity` for `{id}`: {e}"),
})?;
Ok((id, severity))
})
.collect()
}
fn build_rule(
raw: RawRule,
position: usize,
display: &str,
overrides: &BTreeMap<RuleId, Severity>,
declared: &BTreeSet<String>,
) -> Result<RuleSpec, ConfigError> {
let fail = |detail: String| ConfigError::Rule {
position,
path: display.to_owned(),
detail,
};
let id = raw
.id
.ok_or_else(|| fail("missing `id`".to_owned()))?
.parse::<RuleId>()
.map_err(|e| fail(e.to_string()))?;
if !id.namespace().is_built_in() && !declared.contains(id.namespace().as_str()) {
let mut known: Vec<String> = Namespace::built_ins()
.iter()
.map(|n| format!("`{n}`"))
.collect();
known.extend(declared.iter().map(|n| format!("`{n}`")));
return Err(fail(format!(
"rule namespace `{}` is not declared — add it to `namespaces` in the config, \
or use one of {}",
id.namespace(),
known.join(", ")
)));
}
if !raw.has_check {
return Err(fail(format!(
"`{id}` has no `check` function — a rule without one can never report anything"
)));
}
let query = raw
.query
.ok_or_else(|| fail(format!("`{id}` has no `query`")))?;
if query.trim().is_empty() {
return Err(fail(format!("`{id}` has an empty `query`")));
}
let card = raw
.card
.ok_or_else(|| fail(format!("`{id}` has no `card`")))?;
let examples = card.examples.unwrap_or(RawExamples {
bad: None,
good: None,
});
let card = RuleCard {
message: card.message.unwrap_or_default(),
remediation: card.remediation.unwrap_or_default(),
examples: Examples {
bad: examples.bad.unwrap_or_default(),
good: examples.good.unwrap_or_default(),
},
};
card.validate()
.map_err(|problems| fail(format!("`{id}` has an unusable card: {problems:?}")))?;
let declared = raw
.severity
.map(|s| s.parse::<Severity>())
.transpose()
.map_err(|e| fail(format!("`{id}`: {e}")))?
.unwrap_or(Severity::Error);
Ok(RuleSpec {
index: position - 1,
severity: overrides.get(&id).copied().unwrap_or(declared),
id,
languages: raw.language.map_or_else(
|| vec!["typescript".to_owned(), "tsx".to_owned()],
RawLanguages::into_vec,
),
card,
query,
gates: raw.gates,
timeout: raw.timeout.map(Duration::from_millis),
has_reduce: raw.has_reduce,
})
}
fn hash_ruleset(sandbox: &Sandbox) -> Hash {
let mut hasher = blake3::Hasher::new();
hasher.update(b"lanekeep-ruleset-v1");
if let Some(loaded) = sandbox.loaded_modules() {
for (path, source) in loaded.borrow().iter() {
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(&[0]);
hasher.update(source.as_bytes());
hasher.update(&[0]);
}
}
*hasher.finalize().as_bytes()
}
fn hash_config(
include: &[String],
exclude: &[String],
severity: &BTreeMap<RuleId, Severity>,
limits: &Limits,
) -> Hash {
let mut hasher = blake3::Hasher::new();
hasher.update(b"lanekeep-config-v1");
for (label, globs) in [
(b"include".as_slice(), include),
(b"exclude".as_slice(), exclude),
] {
hasher.update(label);
let mut sorted: Vec<&String> = globs.iter().collect();
sorted.sort();
for glob in sorted {
hasher.update(glob.as_bytes());
hasher.update(&[0]);
}
}
hasher.update(b"severity");
for (id, level) in severity {
hasher.update(id.to_string().as_bytes());
hasher.update(&[0]);
hasher.update(level.as_str().as_bytes());
hasher.update(&[0]);
}
hasher.update(b"limits");
for value in [
limits.rule_timeout.as_millis(),
limits.global_timeout.as_millis(),
limits.memory_bytes as u128,
] {
hasher.update(&value.to_le_bytes());
}
*hasher.finalize().as_bytes()
}
fn relative_specifier(root: &Path, file: &Path) -> Option<String> {
let file = file.canonicalize().ok()?;
let relative = file.strip_prefix(root).ok()?;
let joined = relative
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
Some(format!("./{joined}"))
}
pub fn sandbox_for(
root: &RuleRoot,
typescript: std::sync::Arc<dyn lanekeep_js::Language>,
javascript: std::sync::Arc<dyn lanekeep_js::Language>,
) -> Result<Sandbox, ConfigError> {
let limits = Limits::default();
Sandbox::with_modules(
limits,
RunClock::start(limits.global_timeout),
root.clone(),
typescript,
javascript,
)
.map_err(|e| ConfigError::Unreadable {
path: root.path().display().to_string(),
detail: e.to_string(),
})
}
#[must_use]
pub fn default_config_paths(project_root: &Path) -> Vec<PathBuf> {
[
"lanekeep.json",
"lanekeep.config.ts",
"lanekeep.config.js",
"lanekeep.config.mjs",
]
.iter()
.map(|name| project_root.join(name))
.collect()
}
#[cfg(test)]
mod tests {
use std::fs;
use std::sync::Arc;
use lanekeep_lang_js::{JavaScript, TypeScript};
use super::*;
struct Fixture {
dir: PathBuf,
}
impl Fixture {
fn new(name: &str, files: &[(&str, &str)]) -> Self {
let dir = std::env::temp_dir().join(format!("lanekeep-config-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("creates dir");
let fixture = Self { dir };
fixture.write_all(files);
fixture
}
fn write_all(&self, files: &[(&str, &str)]) {
for (path, contents) in files {
let full = self.dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("creates parent");
}
fs::write(&full, contents).expect("writes");
}
}
fn load_config(&self) -> Result<Config, ConfigError> {
let root = RuleRoot::new(&self.dir).expect("canonicalizes");
let sandbox =
sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
load(&sandbox, &root, &self.dir.join("lanekeep.config.ts"))
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn rule(id: &str) -> String {
format!(
"import {{ defineRule }} from 'lanekeep';\n\
export default defineRule({{\n\
id: '{id}',\n\
query: '(identifier) @id',\n\
card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
check(ctx, m) {{ ctx.report(m.id); }},\n\
}});\n"
)
}
fn config_with(body: &str) -> String {
format!(
"import {{ defineConfig }} from 'lanekeep';\n\
import rule from './rule';\n\
export default defineConfig({{ {body} }});\n"
)
}
#[test]
fn loads_a_valid_config() {
let fixture = Fixture::new(
"valid",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(
"include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], rules: [rule]",
),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.include, ["src/**/*.ts"]);
assert_eq!(config.exclude, ["**/*.test.ts"]);
assert_eq!(config.rules.len(), 1);
assert_eq!(config.rules[0].id.to_string(), "local/example");
assert_eq!(config.rules[0].card.message, "no");
assert!(!config.rules[0].has_reduce);
}
#[test]
fn a_declared_namespace_is_accepted() {
let fixture = Fixture::new(
"declared-namespace",
&[
("rule.ts", &rule("pera/no-numeric-sizes")),
(
"lanekeep.config.ts",
&config_with("namespaces: ['pera'], rules: [rule]"),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].id.to_string(), "pera/no-numeric-sizes");
assert!(!config.rules[0].id.is_built_in());
}
#[test]
fn an_undeclared_namespace_is_rejected() {
let fixture = Fixture::new(
"undeclared-namespace",
&[
("rule.ts", &rule("lanekep/no-default-export")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let error = fixture
.load_config()
.expect_err("an undeclared namespace should be refused")
.to_string();
assert!(error.contains("lanekep"), "{error}");
assert!(
error.contains("namespaces"),
"should say how to fix it: {error}"
);
}
#[test]
fn the_lanekeep_namespace_cannot_be_claimed() {
let fixture = Fixture::new(
"reserved-namespace",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with("namespaces: ['lanekeep'], rules: [rule]"),
),
],
);
let error = fixture
.load_config()
.expect_err("claiming the reserved namespace should be refused")
.to_string();
assert!(error.contains("reserved"), "{error}");
}
#[test]
fn a_rule_defaults_to_both_typescript_dialects() {
let fixture = Fixture::new(
"default-languages",
&[
("rule.ts", &rule("local/example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].languages, ["typescript", "tsx"]);
}
#[test]
fn a_rule_may_declare_one_language_or_several() {
for (declaration, expected) in [
("language: 'tsx',", vec!["tsx"]),
(
"language: ['typescript', 'tsx'],",
vec!["typescript", "tsx"],
),
] {
let module = format!(
"import {{ defineRule }} from 'lanekeep';\n\
export default defineRule({{\n\
id: 'local/example',\n\
{declaration}\n\
query: '(identifier) @id',\n\
card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
check(ctx, m) {{ ctx.report(m.id); }},\n\
}});\n"
);
let fixture = Fixture::new(
"language-forms",
&[
("rule.ts", &module),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].languages, expected, "{declaration}");
}
}
#[test]
fn a_rule_without_a_check_function_is_rejected() {
let fixture = Fixture::new(
"no-check",
&[
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/typo',\n\
query: '(identifier) @id',\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
onMatch(ctx, m) {},\n\
});\n",
),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let err = fixture.load_config().expect_err("must be rejected");
let rendered = err.to_string();
assert!(rendered.contains("check"), "{rendered}");
assert!(rendered.contains("never report"), "{rendered}");
}
#[test]
fn a_rule_with_a_bare_id_is_rejected() {
let fixture = Fixture::new(
"bare-id",
&[
("rule.ts", &rule("example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let rendered = fixture
.load_config()
.expect_err("must be rejected")
.to_string();
assert!(rendered.contains("namespace"), "{rendered}");
}
#[test]
fn a_rule_with_an_unusable_card_is_rejected() {
let fixture = Fixture::new(
"bad-card",
&[
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
export default defineRule({\n\
id: 'local/empty',\n\
query: '(identifier) @id',\n\
card: { message: '', remediation: '', examples: { bad: '', good: '' } },\n\
check() {},\n\
});\n",
),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
assert!(fixture.load_config().is_err());
}
#[test]
fn a_missing_default_export_says_so() {
let fixture = Fixture::new(
"no-default",
&[
("rule.ts", &rule("local/x")),
("lanekeep.config.ts", "export const notDefault = 1;\n"),
],
);
let rendered = fixture
.load_config()
.expect_err("must be rejected")
.to_string();
assert!(rendered.contains("default"), "{rendered}");
}
#[test]
fn a_default_export_that_is_not_an_object_says_so() {
let fixture = Fixture::new(
"default-not-object",
&[
("rule.ts", &rule("local/x")),
("lanekeep.config.ts", "export default 42;\n"),
],
);
let rendered = fixture
.load_config()
.expect_err("must be rejected")
.to_string();
assert!(rendered.contains("export default"), "{rendered}");
}
#[test]
fn config_severity_overrides_what_the_rule_declares() {
let fixture = Fixture::new(
"severity",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with("rules: [rule], severity: { 'local/example': 'warn' }"),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.rules[0].severity, Severity::Warn);
}
#[test]
fn timeouts_fall_back_to_the_defaults() {
let fixture = Fixture::new(
"timeouts-default",
&[
("rule.ts", &rule("local/example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.limits, Limits::default());
}
#[test]
fn timeouts_can_be_overridden() {
let fixture = Fixture::new(
"timeouts-set",
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with("rules: [rule], timeouts: { rule: 2000, global: 30000 }"),
),
],
);
let config = fixture.load_config().expect("loads");
assert_eq!(config.limits.rule_timeout, Duration::from_secs(2));
assert_eq!(config.limits.global_timeout, Duration::from_secs(30));
}
#[test]
fn the_ruleset_hash_covers_an_imported_helper() {
let files: &[(&str, &str)] = &[
("helper.ts", "export const QUERY = '(identifier) @id';\n"),
(
"rule.ts",
"import { defineRule } from 'lanekeep';\n\
import { QUERY } from './helper';\n\
export default defineRule({\n\
id: 'local/example',\n\
query: QUERY,\n\
card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
check() {},\n\
});\n",
),
("lanekeep.config.ts", ""),
];
let fixture = Fixture::new("helper-hash", files);
fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
let before = fixture.load_config().expect("loads").ruleset_hash;
fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
let after = fixture.load_config().expect("loads").ruleset_hash;
assert_ne!(
hex(&before),
hex(&after),
"changing an imported helper must invalidate the ruleset hash"
);
}
#[test]
fn the_ruleset_hash_is_stable_when_nothing_changed() {
let fixture = Fixture::new(
"stable-hash",
&[
("rule.ts", &rule("local/example")),
("lanekeep.config.ts", &config_with("rules: [rule]")),
],
);
let first = fixture.load_config().expect("loads").ruleset_hash;
let second = fixture.load_config().expect("loads").ruleset_hash;
assert_eq!(hex(&first), hex(&second));
}
#[test]
fn the_config_hash_ignores_glob_order() {
let make = |globs: &str| {
Fixture::new(
&format!("glob-order-{}", globs.len()),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(&format!("rules: [rule], include: {globs}")),
),
],
)
.load_config()
.expect("loads")
.config_hash
};
assert_eq!(
hex(&make("['a/**', 'b/**']")),
hex(&make("['b/**', 'a/**' ]")),
"reordering globs must not change the config hash"
);
}
#[test]
fn the_config_hash_changes_with_severity() {
let make = |extra: &str, tag: &str| {
Fixture::new(
&format!("severity-hash-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(&format!("rules: [rule]{extra}")),
),
],
)
.load_config()
.expect("loads")
.config_hash
};
assert_ne!(
hex(&make("", "none")),
hex(&make(", severity: { 'local/example': 'warn' }", "warn")),
"changing a severity must invalidate"
);
}
#[test]
fn the_config_hash_changes_with_a_timeout() {
let make = |extra: &str, tag: &str| {
Fixture::new(
&format!("timeout-hash-{tag}"),
&[
("rule.ts", &rule("local/example")),
(
"lanekeep.config.ts",
&config_with(&format!("rules: [rule]{extra}")),
),
],
)
.load_config()
.expect("loads")
.config_hash
};
assert_ne!(
hex(&make("", "d")),
hex(&make(", timeouts: { rule: 5000 }", "t"))
);
}
#[test]
fn hex_renders_a_full_hash() {
assert_eq!(hex(&[0u8; 32]).len(), 64);
assert_eq!(hex(&[0xab; 32]), "ab".repeat(32));
}
}