Skip to main content

vane_core/compile/
merge.rs

1use std::path::PathBuf;
2
3use crate::error::Error;
4use crate::preset::RuleEntry;
5
6#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
7pub struct RawRuleFile {
8	/// Set by `crate::config::scan_rules_dir` from the on-disk filename.
9	/// User-authored rule JSON does not include this — the field defaults
10	/// to an empty `PathBuf` at parse time and the loader overwrites it.
11	#[serde(default)]
12	pub path: PathBuf,
13	#[serde(default)]
14	pub order: i32,
15	#[serde(default)]
16	pub rules: Vec<RuleEntry>,
17}
18
19#[derive(Debug, Clone)]
20pub struct MergedConfig {
21	/// Unexpanded entries — `RuleEntry::Preset(_)` invocations are still
22	/// in their authored form; `expand` runs the dispatcher and produces
23	/// the canonical `RawRule` slab.
24	pub rules: Vec<RuleEntry>,
25	pub source_files: Vec<PathBuf>,
26}
27
28/// Merge multiple rule files into a single canonical entry list.
29///
30/// Files are sorted by `(order asc, path lex)` then concatenated. The
31/// duplicate-name check moved to [`crate::compile::expand::expand`] —
32/// presets emit synthetic rule names like `<base>.main` and `<base>.ws`
33/// that aren't visible until after expansion, so checking here would
34/// either miss collisions or false-positive on legitimate preset
35/// emissions.
36///
37/// # Errors
38/// Currently infallible. The `Result` shape is preserved so future
39/// per-file validation (e.g. cross-file ordering rules) can surface
40/// here without a signature change.
41pub fn merge(mut files: Vec<RawRuleFile>) -> Result<MergedConfig, Error> {
42	files.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.path.cmp(&b.path)));
43
44	let mut rules: Vec<RuleEntry> = Vec::new();
45	let mut source_files: Vec<PathBuf> = Vec::with_capacity(files.len());
46	for file in files {
47		source_files.push(file.path);
48		rules.extend(file.rules);
49	}
50	Ok(MergedConfig { rules, source_files })
51}
52
53#[cfg(test)]
54mod tests {
55	use super::*;
56	use crate::rule::RawRule;
57
58	fn raw_rule(name: &str) -> RawRule {
59		let raw = serde_json::json!({
60			"name": name,
61			"listen": [":443"],
62			"terminate": { "type": "http_proxy" },
63		});
64		serde_json::from_value(raw).expect("parse rule")
65	}
66
67	fn entry(name: &str) -> RuleEntry {
68		RuleEntry::Raw(raw_rule(name))
69	}
70
71	fn file(path: &str, order: i32, rules: Vec<RuleEntry>) -> RawRuleFile {
72		RawRuleFile { path: PathBuf::from(path), order, rules }
73	}
74
75	fn entry_name(e: &RuleEntry) -> &str {
76		match e {
77			RuleEntry::Raw(r) => r.name.as_str(),
78			RuleEntry::Preset(inv) => inv.name.as_str(),
79		}
80	}
81
82	#[test]
83	fn sorts_by_order_then_path_stable() {
84		// spec/crates/core.md § _Compile pipeline_: stable-sort by (order asc, filename lex).
85		let files = vec![
86			file("b.json", 10, vec![entry("b")]),
87			file("a.json", 10, vec![entry("a")]),
88			file("0.json", 0, vec![entry("zero")]),
89		];
90		let merged = merge(files).expect("merge ok");
91		let names: Vec<_> = merged.rules.iter().map(entry_name).collect();
92		assert_eq!(names, vec!["zero", "a", "b"]);
93	}
94
95	#[test]
96	fn duplicate_names_pass_through_merge_without_check() {
97		// Dup detection now happens at expand time — merge intentionally
98		// does NOT short-circuit on identical names, since presets can
99		// reasonably collide pre-expansion.
100		let files =
101			vec![file("a.json", 0, vec![entry("same")]), file("b.json", 1, vec![entry("same")])];
102		let merged = merge(files).expect("merge ok — no dup check here");
103		assert_eq!(merged.rules.len(), 2);
104	}
105
106	#[test]
107	fn preserves_every_source_file_path() {
108		let files = vec![file("x.json", 0, vec![]), file("y.json", 0, vec![])];
109		let merged = merge(files).expect("merge ok");
110		assert_eq!(merged.source_files, vec![PathBuf::from("x.json"), PathBuf::from("y.json")]);
111	}
112}