597 lines
21 KiB
Rust
597 lines
21 KiB
Rust
//! Cascade engine. Selects a handler by configured glob rule (with built-in
|
|
//! defaults), and falls through on `NotApplicable`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use glob::Pattern;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::format::{JsonHandler, TomlHandler};
|
|
use crate::format_extra::{MarkdownHandler, ProseHandler, XmlHandler, YamlHandler};
|
|
use crate::handler::{MergeHandler, MergeResult, MergeStatus};
|
|
use crate::plugin::{PluginConfig, PluginHandler};
|
|
use crate::textual::TextualHandler;
|
|
use crate::tree_sitter_handler::{Lang, TreeSitterHandler};
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct MergeConfig {
|
|
#[serde(default)]
|
|
pub schema_version: u32,
|
|
#[serde(default, rename = "rule")]
|
|
pub rules: Vec<MergeRule>,
|
|
#[serde(default, rename = "plugin")]
|
|
pub plugins: Vec<MergePluginEntry>,
|
|
#[serde(default)]
|
|
pub policy: Option<MergePolicy>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct MergeRule {
|
|
pub glob: String,
|
|
pub handler: String,
|
|
}
|
|
|
|
/// A `[[plugin]]` entry in `.levcs/merge.toml` (§6.6.2).
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct MergePluginEntry {
|
|
pub name: String,
|
|
/// URL or local path the plugin can be fetched from. Not consulted by
|
|
/// the engine itself — callers fetch the bytes and pass them in.
|
|
#[serde(default)]
|
|
pub source: String,
|
|
/// Hex-encoded BLAKE3 of the WASM module. May appear with or without the
|
|
/// `blake3:` prefix in the TOML; both are accepted.
|
|
pub hash: String,
|
|
}
|
|
|
|
/// `policy.allowed_handlers` from §6.6.2 — the repository's view of which
|
|
/// handler names may legally appear in merge metadata.
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct MergePolicy {
|
|
#[serde(default)]
|
|
pub allowed_handlers: Vec<String>,
|
|
}
|
|
|
|
/// Handler-aggressiveness rank used by §6.6.3 to enforce the
|
|
/// "per-user config can demote, never promote" rule. The four tiers
|
|
/// match the spec's ordering exactly:
|
|
///
|
|
/// * `0` — textual fallback (least aggressive)
|
|
/// * `1` — format-aware (json, yaml, toml, xml, markdown, prose)
|
|
/// * `2` — tree-sitter:* built-ins
|
|
/// * `3` — anything else (assumed to be a plugin)
|
|
///
|
|
/// Unknown handler names are treated as plugins (rank 3) so an
|
|
/// unknown-named override always counts as a *promotion* attempt and
|
|
/// can never sneak past the demote-only check by being unrecognised.
|
|
pub fn handler_rank(name: &str) -> u8 {
|
|
if name == "textual" {
|
|
return 0;
|
|
}
|
|
if matches!(
|
|
name,
|
|
"json" | "yaml" | "toml" | "xml" | "markdown" | "prose"
|
|
) {
|
|
return 1;
|
|
}
|
|
if name.starts_with("tree-sitter:") && BUILTIN_HANDLERS.contains(&name) {
|
|
return 2;
|
|
}
|
|
// Flow-control labels (ours-only / theirs-only / delete / no-auto /
|
|
// none) never reach the cascade — they're synthesised by the merge
|
|
// driver itself. Rank them at 0 so they're conservatively accepted
|
|
// anywhere.
|
|
if FLOW_HANDLERS.contains(&name) {
|
|
return 0;
|
|
}
|
|
3
|
|
}
|
|
|
|
/// Layer a per-user override on top of a repository config (§6.6.3).
|
|
/// The override may add new rules and may *demote* a rule's handler
|
|
/// (replace it with a lower-rank one), but MUST NOT *promote* — the
|
|
/// motivating example is "I don't trust the Rust handler today, force
|
|
/// `*.rs` back to textual." Returns the merged config or, on
|
|
/// promotion attempt, the offending glob.
|
|
pub fn layer_local_over(repo: &MergeConfig, local: &MergeConfig) -> Result<MergeConfig, String> {
|
|
let mut merged = repo.clone();
|
|
for local_rule in &local.rules {
|
|
let local_rank = handler_rank(&local_rule.handler);
|
|
// For each matching glob in the repo config, the override
|
|
// rank MUST be ≤ the existing rank. If the glob is new,
|
|
// anything goes — there's no prior rank to compare against,
|
|
// and the worst case is a path that previously fell to the
|
|
// built-in default cascade.
|
|
if let Some(existing) = repo.rules.iter().find(|r| r.glob == local_rule.glob) {
|
|
let existing_rank = handler_rank(&existing.handler);
|
|
if local_rank > existing_rank {
|
|
return Err(format!(
|
|
"merge.local.toml may not promote handler aggressiveness: \
|
|
glob {:?} would go from rank {existing_rank} ({}) to \
|
|
rank {local_rank} ({})",
|
|
local_rule.glob, existing.handler, local_rule.handler
|
|
));
|
|
}
|
|
}
|
|
// Replace the matching rule, or append.
|
|
if let Some(slot) = merged.rules.iter_mut().find(|r| r.glob == local_rule.glob) {
|
|
slot.handler = local_rule.handler.clone();
|
|
} else {
|
|
merged.rules.push(local_rule.clone());
|
|
}
|
|
}
|
|
// Per-user config does NOT touch policy.allowed_handlers or
|
|
// [[plugin]] entries — those live in repo config exclusively.
|
|
// Local plugin sources would be a separate trust escalation.
|
|
Ok(merged)
|
|
}
|
|
|
|
pub struct CascadeEngine {
|
|
/// Handlers indexed by name.
|
|
handlers: Vec<Arc<dyn MergeHandler>>,
|
|
/// User-supplied rules; checked first in order.
|
|
rules: Vec<MergeRule>,
|
|
/// Always-applicable last-resort handler (textual).
|
|
fallback: Arc<dyn MergeHandler>,
|
|
}
|
|
|
|
impl Default for CascadeEngine {
|
|
fn default() -> Self {
|
|
let textual: Arc<dyn MergeHandler> = Arc::new(TextualHandler);
|
|
let mut handlers: Vec<Arc<dyn MergeHandler>> = vec![
|
|
Arc::new(JsonHandler),
|
|
Arc::new(TomlHandler),
|
|
Arc::new(YamlHandler),
|
|
Arc::new(MarkdownHandler),
|
|
Arc::new(ProseHandler),
|
|
Arc::new(XmlHandler),
|
|
];
|
|
for lang in Lang::all() {
|
|
handlers.push(Arc::new(TreeSitterHandler::new(*lang)));
|
|
}
|
|
handlers.push(textual.clone());
|
|
Self {
|
|
handlers,
|
|
rules: Vec::new(),
|
|
fallback: textual,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CascadeEngine {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn with_config(mut self, cfg: MergeConfig) -> Self {
|
|
self.rules = cfg.rules;
|
|
self
|
|
}
|
|
|
|
/// Configure rules and load any `[[plugin]]` entries from disk. The
|
|
/// callback `fetch` is invoked with `(name, source)` and must return the
|
|
/// raw WASM bytes; this lets callers control where plugin modules come
|
|
/// from (local cache, registered instance, etc.) without tying the merge
|
|
/// engine to a fetch transport.
|
|
pub fn with_config_and_plugins<F>(
|
|
mut self,
|
|
cfg: MergeConfig,
|
|
mut fetch: F,
|
|
) -> Result<Self, String>
|
|
where
|
|
F: FnMut(&MergePluginEntry) -> Result<Vec<u8>, String>,
|
|
{
|
|
self.rules = cfg.rules;
|
|
for entry in &cfg.plugins {
|
|
let bytes = fetch(entry)?;
|
|
let hash = parse_hash(&entry.hash)?;
|
|
let plugin = PluginHandler::new(
|
|
PluginConfig {
|
|
name: entry.name.clone(),
|
|
hash,
|
|
},
|
|
&bytes,
|
|
)
|
|
.map_err(|e| format!("plugin {}: {e}", entry.name))?;
|
|
self.handlers.push(Arc::new(plugin));
|
|
}
|
|
Ok(self)
|
|
}
|
|
|
|
pub fn register(&mut self, h: Arc<dyn MergeHandler>) {
|
|
self.handlers.push(h);
|
|
}
|
|
|
|
/// Locate the handler that should run for `path`, considering rules then
|
|
/// extension defaults.
|
|
fn pick(&self, path: &Path) -> Option<Arc<dyn MergeHandler>> {
|
|
let path_str = path.to_string_lossy();
|
|
for rule in &self.rules {
|
|
if let Ok(pat) = Pattern::new(&rule.glob) {
|
|
if pat.matches(&path_str) {
|
|
if let Some(h) = self.handlers.iter().find(|h| h.name() == rule.handler) {
|
|
return Some(h.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Default cascade by extension.
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
let pref: String = match ext {
|
|
"json" => "json".into(),
|
|
"toml" => "toml".into(),
|
|
"yaml" | "yml" => "yaml".into(),
|
|
"xml" | "svg" | "html" => "xml".into(),
|
|
"md" | "markdown" => "markdown".into(),
|
|
"txt" => "prose".into(),
|
|
other => match Lang::from_extension(other) {
|
|
Some(lang) => lang.handler_name().to_string(),
|
|
None => "textual".into(),
|
|
},
|
|
};
|
|
self.handlers.iter().find(|h| h.name() == pref).cloned()
|
|
}
|
|
|
|
pub fn merge_file(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
|
|
if let Some(h) = self.pick(path) {
|
|
if h.applicable(path, base, ours, theirs) {
|
|
let result = h.merge(path, base, ours, theirs);
|
|
if !matches!(result.status, MergeStatus::NotApplicable) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
// Fall through to textual.
|
|
if self.fallback.applicable(path, base, ours, theirs) {
|
|
return self.fallback.merge(path, base, ours, theirs);
|
|
}
|
|
MergeResult {
|
|
handler: "none".into(),
|
|
status: MergeStatus::Conflict {
|
|
regions: vec![],
|
|
partial: ours.to_vec(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Names of every handler that ships with the engine. Used by instance and
|
|
/// repository policy checks (§6.6.4) to expand the "builtin" alias and to
|
|
/// reject merge-record entries that reference unknown handlers.
|
|
pub const BUILTIN_HANDLERS: &[&str] = &[
|
|
"json",
|
|
"yaml",
|
|
"toml",
|
|
"xml",
|
|
"markdown",
|
|
"prose",
|
|
"textual",
|
|
"tree-sitter:rust",
|
|
"tree-sitter:python",
|
|
"tree-sitter:javascript",
|
|
"tree-sitter:typescript",
|
|
"tree-sitter:go",
|
|
"tree-sitter:c",
|
|
"tree-sitter:cpp",
|
|
"tree-sitter:java",
|
|
"tree-sitter:ruby",
|
|
"tree-sitter:shell",
|
|
];
|
|
|
|
/// Names emitted by the merge driver itself for trivial flow-control
|
|
/// outcomes (kept only on one side, deletion honoured, etc.). They label
|
|
/// merge-record entries that did not actually invoke a handler, so they
|
|
/// always pass policy regardless of the allow list.
|
|
pub const FLOW_HANDLERS: &[&str] = &["ours-only", "theirs-only", "delete", "no-auto", "none"];
|
|
|
|
pub fn is_builtin_handler(name: &str) -> bool {
|
|
BUILTIN_HANDLERS.iter().any(|b| *b == name) || FLOW_HANDLERS.iter().any(|b| *b == name)
|
|
}
|
|
|
|
/// Decide whether a `(handler, handler_hash)` tuple is permitted by the
|
|
/// supplied allow list. Semantics:
|
|
///
|
|
/// - Empty list → permissive (no enforcement).
|
|
/// - List containing `"builtin"` → all built-in handlers pass; explicit
|
|
/// `"name:blake3:<hex>"` entries also pass for plugins that pin to that
|
|
/// exact hash.
|
|
/// - List without `"builtin"` → only entries that match the explicit form
|
|
/// pass; built-ins are blocked (useful for an instance that wants to
|
|
/// forbid anything other than a vetted plugin set).
|
|
/// Validate every file entry of a merge-record against an allow list. Returns
|
|
/// the names of any handlers that violate the policy, in original order.
|
|
pub fn validate_record_against_policy(
|
|
record: &crate::record::MergeRecord,
|
|
allowed: &[String],
|
|
) -> Vec<String> {
|
|
let mut bad = Vec::new();
|
|
for fr in &record.files {
|
|
if !check_handler_allowed(&fr.handler, &fr.handler_hash, allowed) {
|
|
bad.push(fr.handler.clone());
|
|
}
|
|
}
|
|
bad
|
|
}
|
|
|
|
pub fn check_handler_allowed(handler: &str, handler_hash: &str, allowed: &[String]) -> bool {
|
|
if allowed.is_empty() {
|
|
return true;
|
|
}
|
|
let allows_builtin = allowed.iter().any(|s| s == "builtin");
|
|
if allows_builtin && is_builtin_handler(handler) {
|
|
return true;
|
|
}
|
|
let needle_with_hash = if handler_hash.is_empty() {
|
|
None
|
|
} else {
|
|
let hash = handler_hash.strip_prefix("blake3:").unwrap_or(handler_hash);
|
|
Some(format!("{handler}:blake3:{hash}"))
|
|
};
|
|
allowed
|
|
.iter()
|
|
.any(|s| s == handler || needle_with_hash.as_deref().map(|n| s == n).unwrap_or(false))
|
|
}
|
|
|
|
fn parse_hash(s: &str) -> Result<[u8; 32], String> {
|
|
let trimmed = s.strip_prefix("blake3:").unwrap_or(s);
|
|
if trimmed.len() != 64 {
|
|
return Err(format!(
|
|
"expected 64-char blake3 hash, got {} chars",
|
|
trimmed.len()
|
|
));
|
|
}
|
|
let mut out = [0u8; 32];
|
|
for (i, byte) in out.iter_mut().enumerate() {
|
|
let hi = (trimmed.as_bytes()[i * 2] as char)
|
|
.to_digit(16)
|
|
.ok_or_else(|| format!("invalid hex at byte {i}"))?;
|
|
let lo = (trimmed.as_bytes()[i * 2 + 1] as char)
|
|
.to_digit(16)
|
|
.ok_or_else(|| format!("invalid hex at byte {i}"))?;
|
|
*byte = ((hi << 4) | lo) as u8;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn _path_unused(_: PathBuf) {}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_hash_accepts_both_forms() {
|
|
let h = "0".repeat(64);
|
|
assert!(parse_hash(&h).is_ok());
|
|
let prefixed = format!("blake3:{h}");
|
|
assert_eq!(parse_hash(&h).unwrap(), parse_hash(&prefixed).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_hash_rejects_short_input() {
|
|
assert!(parse_hash("abc").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_alias_admits_spec_handlers() {
|
|
let allow = vec!["builtin".to_string()];
|
|
assert!(check_handler_allowed("json", "", &allow));
|
|
assert!(check_handler_allowed("tree-sitter:rust", "", &allow));
|
|
assert!(!check_handler_allowed("tree-sitter:protobuf", "", &allow));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_plugin_pin_admits_only_matching_hash() {
|
|
let allow = vec![
|
|
"builtin".into(),
|
|
"tree-sitter:protobuf:blake3:abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000".into(),
|
|
];
|
|
let good = "abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000";
|
|
let bad = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
|
|
assert!(check_handler_allowed("tree-sitter:protobuf", good, &allow));
|
|
assert!(!check_handler_allowed("tree-sitter:protobuf", bad, &allow));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_allow_list_is_permissive() {
|
|
assert!(check_handler_allowed("anything", "", &[]));
|
|
}
|
|
|
|
#[test]
|
|
fn flow_control_handler_names_always_pass() {
|
|
let allow = vec!["builtin".into()];
|
|
assert!(check_handler_allowed("ours-only", "", &allow));
|
|
assert!(check_handler_allowed("delete", "", &allow));
|
|
}
|
|
|
|
#[test]
|
|
fn handler_rank_matches_spec_tiers() {
|
|
assert_eq!(handler_rank("textual"), 0);
|
|
assert_eq!(handler_rank("json"), 1);
|
|
assert_eq!(handler_rank("toml"), 1);
|
|
assert_eq!(handler_rank("yaml"), 1);
|
|
assert_eq!(handler_rank("xml"), 1);
|
|
assert_eq!(handler_rank("markdown"), 1);
|
|
assert_eq!(handler_rank("prose"), 1);
|
|
assert_eq!(handler_rank("tree-sitter:rust"), 2);
|
|
assert_eq!(handler_rank("tree-sitter:python"), 2);
|
|
// Unknown / plugin-shaped names always rank as plugins so an
|
|
// attacker can't sneak a promotion past the check by typoing.
|
|
assert_eq!(handler_rank("tree-sitter:protobuf"), 3);
|
|
assert_eq!(handler_rank("custom-plugin"), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn layer_local_demotes_rs_to_textual() {
|
|
// Spec example verbatim: user pins `*.rs` back to textual when
|
|
// they don't trust the Rust handler today.
|
|
let repo = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "*.rs".into(),
|
|
handler: "tree-sitter:rust".into(),
|
|
}],
|
|
plugins: vec![],
|
|
policy: None,
|
|
};
|
|
let local = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "*.rs".into(),
|
|
handler: "textual".into(),
|
|
}],
|
|
plugins: vec![],
|
|
policy: None,
|
|
};
|
|
let merged = layer_local_over(&repo, &local).expect("demote allowed");
|
|
assert_eq!(merged.rules.len(), 1);
|
|
assert_eq!(merged.rules[0].handler, "textual");
|
|
}
|
|
|
|
#[test]
|
|
fn layer_local_rejects_promotion() {
|
|
// Repo config pins `*.rs` to textual; user tries to promote
|
|
// it to tree-sitter. Spec says this MUST be rejected.
|
|
let repo = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "*.rs".into(),
|
|
handler: "textual".into(),
|
|
}],
|
|
plugins: vec![],
|
|
policy: None,
|
|
};
|
|
let local = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "*.rs".into(),
|
|
handler: "tree-sitter:rust".into(),
|
|
}],
|
|
plugins: vec![],
|
|
policy: None,
|
|
};
|
|
let err = layer_local_over(&repo, &local).expect_err("must reject");
|
|
assert!(
|
|
err.contains("promote"),
|
|
"error must mention promotion: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn layer_local_appends_new_glob() {
|
|
// No matching repo rule means the user's override establishes
|
|
// a new policy. Anything goes — we accept it because there's
|
|
// nothing to compare against.
|
|
let repo = MergeConfig::default();
|
|
let local = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "vendored/**".into(),
|
|
handler: "textual".into(),
|
|
}],
|
|
plugins: vec![],
|
|
policy: None,
|
|
};
|
|
let merged = layer_local_over(&repo, &local).unwrap();
|
|
assert_eq!(merged.rules.len(), 1);
|
|
assert_eq!(merged.rules[0].glob, "vendored/**");
|
|
}
|
|
|
|
#[test]
|
|
fn layer_local_rejects_promotion_to_plugin() {
|
|
let repo = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "*.proto".into(),
|
|
handler: "textual".into(),
|
|
}],
|
|
plugins: vec![],
|
|
policy: None,
|
|
};
|
|
let local = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "*.proto".into(),
|
|
handler: "tree-sitter:protobuf".into(),
|
|
}],
|
|
plugins: vec![],
|
|
policy: None,
|
|
};
|
|
assert!(layer_local_over(&repo, &local).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_config_parses_plugin_block() {
|
|
let toml_src = r#"
|
|
schema_version = 1
|
|
[[rule]]
|
|
glob = "*.proto"
|
|
handler = "tree-sitter:protobuf"
|
|
[[plugin]]
|
|
name = "tree-sitter:protobuf"
|
|
source = "https://example.com/p.wasm"
|
|
hash = "blake3:0000000000000000000000000000000000000000000000000000000000000000"
|
|
"#;
|
|
let cfg: MergeConfig = toml::from_str(toml_src).unwrap();
|
|
assert_eq!(cfg.rules.len(), 1);
|
|
assert_eq!(cfg.plugins.len(), 1);
|
|
assert_eq!(cfg.plugins[0].name, "tree-sitter:protobuf");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod plugin_routing_tests {
|
|
use super::*;
|
|
|
|
const RETURN_OURS_WAT: &str = r#"
|
|
(module
|
|
(memory (export "memory") 1)
|
|
(global $top (mut i32) (i32.const 1024))
|
|
(func (export "alloc") (param i32) (result i32)
|
|
(local $p i32)
|
|
(local.set $p (global.get $top))
|
|
(global.set $top (i32.add (global.get $top) (local.get 0)))
|
|
(local.get $p))
|
|
(func (export "merge")
|
|
(param $bp i32) (param $bl i32)
|
|
(param $op i32) (param $ol i32)
|
|
(param $tp i32) (param $tl i32)
|
|
(param $pp i32) (param $pl i32)
|
|
(result i64)
|
|
(i64.or
|
|
(i64.shl (i64.extend_i32_u (local.get $ol)) (i64.const 32))
|
|
(i64.extend_i32_u (local.get $op)))))
|
|
"#;
|
|
|
|
#[test]
|
|
fn rule_routes_path_to_registered_plugin() {
|
|
let bytes = wat::parse_str(RETURN_OURS_WAT).unwrap();
|
|
let hash = blake3::hash(&bytes);
|
|
let cfg = MergeConfig {
|
|
schema_version: 1,
|
|
rules: vec![MergeRule {
|
|
glob: "*.proto".into(),
|
|
handler: "test:plugin".into(),
|
|
}],
|
|
plugins: vec![MergePluginEntry {
|
|
name: "test:plugin".into(),
|
|
source: String::new(),
|
|
hash: format!("blake3:{}", hash.to_hex()),
|
|
}],
|
|
policy: None,
|
|
};
|
|
let bytes_clone = bytes.clone();
|
|
let engine = CascadeEngine::default()
|
|
.with_config_and_plugins(cfg, move |_| Ok(bytes_clone.clone()))
|
|
.expect("plugin loads with valid hash");
|
|
let res = engine.merge_file(Path::new("schema.proto"), b"b", b"o", b"t");
|
|
assert_eq!(res.handler, "test:plugin");
|
|
match res.status {
|
|
MergeStatus::Merged { content, .. } => assert_eq!(content, b"o"),
|
|
other => panic!("expected Merged, got {other:?}"),
|
|
}
|
|
}
|
|
}
|