From cf4a74850b50393969c17c739c0a8310a181eb00 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 08:49:06 +0000 Subject: [PATCH 1/6] feat(#760): Wire channel scope into rule matching (#751) Channel scope on DeviceIdentityConfig now filters events at runtime. Events with channels outside the binding's scope skip device-specific rules while any-device rules still apply. - Add ProcessedEvent::channel() extractor for all variants - Add channel_scopes HashMap on CompiledRuleSet (populated by compiler) - is_channel_in_scope() check in match_event() gates device-specific rules - Rule compiler builds scopes from Config.devices[].channels - TDD: 2 new tests (channel filtering, empty scope = all channels) 8 routing tests pass (6 existing + 2 new). Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/event_processor.rs | 22 ++++ conductor-core/src/rule_compiler.rs | 14 +- conductor-core/src/rule_set.rs | 37 +++++- .../tests/multi_device_rule_routing_test.rs | 121 ++++++++++++++++++ 4 files changed, 188 insertions(+), 6 deletions(-) diff --git a/conductor-core/src/event_processor.rs b/conductor-core/src/event_processor.rs index f801133f..84c38079 100644 --- a/conductor-core/src/event_processor.rs +++ b/conductor-core/src/event_processor.rs @@ -285,6 +285,28 @@ pub enum ProcessedEvent { Raw(InputEvent), } +impl ProcessedEvent { + /// Extract the MIDI channel from any variant (#751). + /// Returns None for Raw passthrough (and gamepad events which set channel=None). + pub fn channel(&self) -> Option { + match self { + Self::ShortPress { channel, .. } + | Self::MediumPress { channel, .. } + | Self::LongPress { channel, .. } + | Self::HoldDetected { channel, .. } + | Self::PadPressed { channel, .. } + | Self::PadReleased { channel, .. } + | Self::DoubleTap { channel, .. } + | Self::EncoderTurned { channel, .. } + | Self::CCReceived { channel, .. } + | Self::AftertouchChanged { channel, .. } + | Self::PitchBendMoved { channel, .. } + | Self::ChordDetected { channel, .. } => *channel, + Self::Raw(_) => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum VelocityLevel { Soft, diff --git a/conductor-core/src/rule_compiler.rs b/conductor-core/src/rule_compiler.rs index 4a4a17dc..92f596f8 100644 --- a/conductor-core/src/rule_compiler.rs +++ b/conductor-core/src/rule_compiler.rs @@ -32,12 +32,22 @@ pub fn compile(config: &Config, version: u64) -> CompiledRuleSet { let (global_device_rules, global_any_device_rules) = compile_mappings_split(&config.global_mappings); - CompiledRuleSet::new( + // Build channel scopes from device identity configs (#751) + let channel_scopes: HashMap> = config + .devices + .iter() + .filter(|d| !d.channels.is_empty()) + .map(|d| (d.alias.clone(), d.channels.clone())) + .collect(); + + let mut rule_set = CompiledRuleSet::new( mode_rules, global_device_rules, global_any_device_rules, version, - ) + ); + rule_set.set_channel_scopes(channel_scopes); + rule_set } /// Compile a single mode's mappings into a ModeRuleSet diff --git a/conductor-core/src/rule_set.rs b/conductor-core/src/rule_set.rs index 124e6b33..2d26b1b5 100644 --- a/conductor-core/src/rule_set.rs +++ b/conductor-core/src/rule_set.rs @@ -24,6 +24,8 @@ pub struct CompiledRuleSet { global_rules: GlobalRuleSet, /// Monotonic version for debugging/logging version: u64, + /// Channel scopes per device alias (#751). Empty vec = all channels. + channel_scopes: HashMap>, } /// Rules for a single mode, with per-device indexing for O(1) lookup @@ -76,6 +78,25 @@ impl CompiledRuleSet { any_device_rules: global_any_device_rules, }, version, + channel_scopes: HashMap::new(), + } + } + + /// Set channel scopes from device identity configs (#751) + pub(crate) fn set_channel_scopes(&mut self, scopes: HashMap>) { + self.channel_scopes = scopes; + } + + /// Check if an event's channel is in scope for a device (#751) + fn is_channel_in_scope(&self, device_id: &str, event: &ProcessedEvent) -> bool { + match self.channel_scopes.get(device_id) { + Some(channels) if !channels.is_empty() => { + match event.channel() { + Some(ch) => channels.contains(&ch), + None => true, // Non-MIDI events bypass channel filtering + } + } + _ => true, // No scope or empty scope = all channels } } @@ -92,10 +113,17 @@ impl CompiledRuleSet { mode_index: usize, device_id: Option<&str>, ) -> Option { + // Channel scope check (#751): if device has a channel scope and this + // event's channel is outside it, skip all device-specific matching. + // Any-device rules and global rules still apply (they're not device-scoped). + let device_in_scope = + device_id.is_none_or(|device| self.is_channel_in_scope(device, event)); + // Check mode rules if mode_index is valid if let Some(mode) = self.mode_rules.get(mode_index) { - // 1. Device-specific rules for this mode - if let Some(device) = device_id + // 1. Device-specific rules for this mode (skip if channel out of scope) + if device_in_scope + && let Some(device) = device_id && let Some(rules) = mode.device_rules.get(device) && let Some(action) = find_matching_action(rules, event) { @@ -108,8 +136,9 @@ impl CompiledRuleSet { } } - // 3. Global device-specific rules - if let Some(device) = device_id + // 3. Global device-specific rules (skip if channel out of scope) + if device_in_scope + && let Some(device) = device_id && let Some(rules) = self.global_rules.device_rules.get(device) && let Some(action) = find_matching_action(rules, event) { diff --git a/conductor-core/tests/multi_device_rule_routing_test.rs b/conductor-core/tests/multi_device_rule_routing_test.rs index 08c7058c..2c55cd02 100644 --- a/conductor-core/tests/multi_device_rule_routing_test.rs +++ b/conductor-core/tests/multi_device_rule_routing_test.rs @@ -324,3 +324,124 @@ fn test_multi_mode_device_routing() { let action_1 = rule_set.match_event(&event, 1, Some("pads")); assert!(matches!(action_1, Some(Action::Shell(_)))); } + +// #751: Channel scope filtering in rule matching +#[test] +fn test_channel_scope_filters_events() { + // Device "drums" only responds to channel 9 (drum channel) + let config = Config { + device: None, + devices: vec![DeviceIdentityConfig { + alias: "drums".to_string(), + matchers: vec![DeviceMatcher::name_contains("Drums")], + description: None, + enabled: true, + input: None, + output: None, + protocol: None, + channels: vec![9], // Only channel 10 (0-indexed) + }], + modes: vec![Mode { + name: "Default".to_string(), + color: None, + mappings: vec![Mapping { + trigger: Trigger::Note { + note: 36, + velocity_min: None, + channel: None, // Trigger matches any channel + device: Some("drums".to_string()), + }, + action: ActionConfig::Keystroke { + keys: "d".to_string(), + modifiers: vec![], + }, + description: Some("Drum hit".to_string()), + }], + }], + global_mappings: vec![], + advanced_settings: Default::default(), + led: None, + event_console: None, + default_mode: None, + last_selected_mode: None, + logging: None, + }; + + let rule_set = conductor_core::rule_compiler::compile(&config, 1); + + // Event on channel 9 → should match (in scope) + let event_ch9 = ProcessedEvent::PadPressed { + note: 36, + velocity: 100, + velocity_level: VelocityLevel::Hard, + channel: Some(9), + }; + let action = rule_set.match_event(&event_ch9, 0, Some("drums")); + assert!(action.is_some(), "Channel 9 should match (in scope)"); + + // Event on channel 0 → should NOT match (out of scope) + let event_ch0 = ProcessedEvent::PadPressed { + note: 36, + velocity: 100, + velocity_level: VelocityLevel::Hard, + channel: Some(0), + }; + let action = rule_set.match_event(&event_ch0, 0, Some("drums")); + assert!( + action.is_none(), + "Channel 0 should NOT match (out of scope)" + ); +} + +#[test] +fn test_empty_channel_scope_matches_all() { + // Device with no channel scope → matches all channels + let config = Config { + device: None, + devices: vec![DeviceIdentityConfig { + alias: "pads".to_string(), + matchers: vec![DeviceMatcher::name_contains("Pads")], + description: None, + enabled: true, + input: None, + output: None, + protocol: None, + channels: vec![], // Empty = all channels + }], + modes: vec![Mode { + name: "Default".to_string(), + color: None, + mappings: vec![Mapping { + trigger: Trigger::Note { + note: 60, + velocity_min: None, + channel: None, + device: Some("pads".to_string()), + }, + action: ActionConfig::Keystroke { + keys: "p".to_string(), + modifiers: vec![], + }, + description: None, + }], + }], + global_mappings: vec![], + advanced_settings: Default::default(), + led: None, + event_console: None, + default_mode: None, + last_selected_mode: None, + logging: None, + }; + + let rule_set = conductor_core::rule_compiler::compile(&config, 1); + + // Any channel should match + let event = ProcessedEvent::PadPressed { + note: 60, + velocity: 80, + velocity_level: VelocityLevel::Medium, + channel: Some(5), + }; + assert!(rule_set.match_event(&event, 0, Some("pads")).is_some()); +} From 8537cce346d529a73b348551f246d8cfcb561fc5 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 09:14:50 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20Address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20immutable=20construction,=20provenance=20scope,=20c?= =?UTF-8?q?omment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass channel_scopes into CompiledRuleSet::new() instead of setter - Apply channel scope check to match_event_with_provenance() too - Fix comment: channel 9 = 0-indexed (MIDI channel 10) Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/rule_compiler.rs | 7 +++--- conductor-core/src/rule_set.rs | 22 ++++++++++--------- .../tests/multi_device_rule_routing_test.rs | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/conductor-core/src/rule_compiler.rs b/conductor-core/src/rule_compiler.rs index 92f596f8..931463c4 100644 --- a/conductor-core/src/rule_compiler.rs +++ b/conductor-core/src/rule_compiler.rs @@ -40,14 +40,13 @@ pub fn compile(config: &Config, version: u64) -> CompiledRuleSet { .map(|d| (d.alias.clone(), d.channels.clone())) .collect(); - let mut rule_set = CompiledRuleSet::new( + CompiledRuleSet::new( mode_rules, global_device_rules, global_any_device_rules, version, - ); - rule_set.set_channel_scopes(channel_scopes); - rule_set + channel_scopes, + ) } /// Compile a single mode's mappings into a ModeRuleSet diff --git a/conductor-core/src/rule_set.rs b/conductor-core/src/rule_set.rs index 2d26b1b5..607ddc46 100644 --- a/conductor-core/src/rule_set.rs +++ b/conductor-core/src/rule_set.rs @@ -70,6 +70,7 @@ impl CompiledRuleSet { global_device_rules: HashMap>, global_any_device_rules: Vec, version: u64, + channel_scopes: HashMap>, ) -> Self { Self { mode_rules, @@ -78,15 +79,10 @@ impl CompiledRuleSet { any_device_rules: global_any_device_rules, }, version, - channel_scopes: HashMap::new(), + channel_scopes, } } - /// Set channel scopes from device identity configs (#751) - pub(crate) fn set_channel_scopes(&mut self, scopes: HashMap>) { - self.channel_scopes = scopes; - } - /// Check if an event's channel is in scope for a device (#751) fn is_channel_in_scope(&self, device_id: &str, event: &ProcessedEvent) -> bool { match self.channel_scopes.get(device_id) { @@ -181,10 +177,15 @@ impl CompiledRuleSet { ) -> Option { let mode_name = self.mode_name(mode_index).map(String::from); + // Channel scope check (#751) — same as match_event() + let device_in_scope = + device_id.is_none_or(|device| self.is_channel_in_scope(device, event)); + // Check mode rules if mode_index is valid if let Some(mode) = self.mode_rules.get(mode_index) { - // 1. Device-specific rules for this mode - if let Some(device) = device_id + // 1. Device-specific rules for this mode (skip if channel out of scope) + if device_in_scope + && let Some(device) = device_id && let Some(rules) = mode.device_rules.get(device) && let Some(envelope) = find_matching_envelope(rules, event, device_id, &mode_name) { @@ -199,8 +200,9 @@ impl CompiledRuleSet { } } - // 3. Global device-specific rules - if let Some(device) = device_id + // 3. Global device-specific rules (skip if channel out of scope) + if device_in_scope + && let Some(device) = device_id && let Some(rules) = self.global_rules.device_rules.get(device) && let Some(envelope) = find_matching_envelope(rules, event, device_id, &mode_name) { diff --git a/conductor-core/tests/multi_device_rule_routing_test.rs b/conductor-core/tests/multi_device_rule_routing_test.rs index 2c55cd02..0f1f433d 100644 --- a/conductor-core/tests/multi_device_rule_routing_test.rs +++ b/conductor-core/tests/multi_device_rule_routing_test.rs @@ -339,7 +339,7 @@ fn test_channel_scope_filters_events() { input: None, output: None, protocol: None, - channels: vec![9], // Only channel 10 (0-indexed) + channels: vec![9], // Only channel 9 (0-indexed, i.e., MIDI channel 10) }], modes: vec![Mode { name: "Default".to_string(), From 5ca0500893e5f8dad0ca913167e1140aba8a244f Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 09:59:18 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20Address=20round=202=20=E2=80=94=20sh?= =?UTF-8?q?ared=20channel=5Fin=5Fscope(),=20immutable=20semantics=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract channel_in_scope() as shared helper in rule_set.rs - DeviceIdentityConfig::is_channel_in_scope() delegates to shared helper - Document that missing key in channel_scopes = no restriction - Prevents logic drift between two implementations Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/config/types.rs | 8 +------- conductor-core/src/rule_compiler.rs | 4 +++- conductor-core/src/rule_set.rs | 22 +++++++++++++++------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/conductor-core/src/config/types.rs b/conductor-core/src/config/types.rs index b291887d..a4696e66 100644 --- a/conductor-core/src/config/types.rs +++ b/conductor-core/src/config/types.rs @@ -685,13 +685,7 @@ impl DeviceIdentityConfig { /// - `channel` is `None` = pass through (non-MIDI events like HID have no channel) /// - Otherwise, checks if the channel is in the configured list pub fn is_channel_in_scope(&self, channel: Option) -> bool { - if self.channels.is_empty() { - return true; // No scope restriction - } - match channel { - None => true, // Non-MIDI events bypass channel filtering - Some(ch) => self.channels.contains(&ch), - } + crate::rule_set::channel_in_scope(&self.channels, channel) } } diff --git a/conductor-core/src/rule_compiler.rs b/conductor-core/src/rule_compiler.rs index 931463c4..9e68e597 100644 --- a/conductor-core/src/rule_compiler.rs +++ b/conductor-core/src/rule_compiler.rs @@ -32,7 +32,9 @@ pub fn compile(config: &Config, version: u64) -> CompiledRuleSet { let (global_device_rules, global_any_device_rules) = compile_mappings_split(&config.global_mappings); - // Build channel scopes from device identity configs (#751) + // Build channel scopes from device identity configs (#751). + // Only devices with non-empty channels are included; missing key = all channels. + // This is intentional: is_channel_in_scope() treats missing key as "no restriction". let channel_scopes: HashMap> = config .devices .iter() diff --git a/conductor-core/src/rule_set.rs b/conductor-core/src/rule_set.rs index 607ddc46..fc05b6b8 100644 --- a/conductor-core/src/rule_set.rs +++ b/conductor-core/src/rule_set.rs @@ -86,13 +86,8 @@ impl CompiledRuleSet { /// Check if an event's channel is in scope for a device (#751) fn is_channel_in_scope(&self, device_id: &str, event: &ProcessedEvent) -> bool { match self.channel_scopes.get(device_id) { - Some(channels) if !channels.is_empty() => { - match event.channel() { - Some(ch) => channels.contains(&ch), - None => true, // Non-MIDI events bypass channel filtering - } - } - _ => true, // No scope or empty scope = all channels + Some(channels) => channel_in_scope(channels, event.channel()), + None => true, // Missing key = no restriction (all channels) } } @@ -248,3 +243,16 @@ fn find_matching_envelope( } None } + +/// Check if a MIDI channel is within a channel scope (#751). +/// Shared logic used by both CompiledRuleSet and DeviceIdentityConfig. +/// Empty scope = all channels. None channel (non-MIDI) = pass through. +pub(crate) fn channel_in_scope(channels: &[u8], channel: Option) -> bool { + if channels.is_empty() { + return true; + } + match channel { + Some(ch) => channels.contains(&ch), + None => true, // Non-MIDI events bypass channel filtering + } +} From 24deb967b13c5da000c1e4cdfebff5e9a7baad14 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 11:39:58 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20Clarify=20channel=5Fscopes=20doc=20?= =?UTF-8?q?=E2=80=94=20missing=20key=20=3D=20no=20restriction=20(round=203?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/rule_set.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/conductor-core/src/rule_set.rs b/conductor-core/src/rule_set.rs index fc05b6b8..a3f08826 100644 --- a/conductor-core/src/rule_set.rs +++ b/conductor-core/src/rule_set.rs @@ -24,7 +24,8 @@ pub struct CompiledRuleSet { global_rules: GlobalRuleSet, /// Monotonic version for debugging/logging version: u64, - /// Channel scopes per device alias (#751). Empty vec = all channels. + /// Channel scopes per device alias (#751). + /// Missing key = no restriction (all channels). Only non-empty scopes are stored. channel_scopes: HashMap>, } From 8ebeb216b8b5478610acd6bb8013d571d2c03e6b Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 12:43:19 +0000 Subject: [PATCH 5/6] test: Add test for any-device rules matching when channel out of scope Verifies that when a device's channel scope rejects an event, the any-device rules in the same mode still fire. This covers the interaction between channel scoping and rule priority ordering. 9 routing tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/multi_device_rule_routing_test.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/conductor-core/tests/multi_device_rule_routing_test.rs b/conductor-core/tests/multi_device_rule_routing_test.rs index 0f1f433d..21b23a94 100644 --- a/conductor-core/tests/multi_device_rule_routing_test.rs +++ b/conductor-core/tests/multi_device_rule_routing_test.rs @@ -445,3 +445,81 @@ fn test_empty_channel_scope_matches_all() { }; assert!(rule_set.match_event(&event, 0, Some("pads")).is_some()); } + +#[test] +fn test_channel_out_of_scope_still_matches_any_device_rules() { + // Device "drums" scoped to channel 9, but there's an any-device rule for note 36. + // An event on channel 0 should skip the device-specific rule but still match + // the any-device rule. + let config = Config { + device: None, + devices: vec![DeviceIdentityConfig { + alias: "drums".to_string(), + matchers: vec![DeviceMatcher::name_contains("Drums")], + description: None, + enabled: true, + input: None, + output: None, + protocol: None, + channels: vec![9], + }], + modes: vec![Mode { + name: "Default".to_string(), + color: None, + mappings: vec![ + // Device-specific rule — should NOT fire for ch.0 + Mapping { + trigger: Trigger::Note { + note: 36, + velocity_min: None, + channel: None, + device: Some("drums".to_string()), + }, + action: ActionConfig::Shell { + command: "device-action".to_string(), + }, + description: Some("Device rule".to_string()), + }, + // Any-device rule — SHOULD fire for ch.0 + Mapping { + trigger: Trigger::Note { + note: 36, + velocity_min: None, + channel: None, + device: None, // No device filter + }, + action: ActionConfig::Keystroke { + keys: "fallback".to_string(), + modifiers: vec![], + }, + description: Some("Any-device rule".to_string()), + }, + ], + }], + global_mappings: vec![], + advanced_settings: Default::default(), + led: None, + event_console: None, + default_mode: None, + last_selected_mode: None, + logging: None, + }; + + let rule_set = conductor_core::rule_compiler::compile(&config, 1); + + // Event on channel 0 (out of scope for "drums") + let event_ch0 = ProcessedEvent::PadPressed { + note: 36, + velocity: 100, + velocity_level: VelocityLevel::Hard, + channel: Some(0), + }; + + // Should match the any-device rule (Keystroke), NOT the device-specific rule (Shell) + let action = rule_set.match_event(&event_ch0, 0, Some("drums")); + assert!(action.is_some(), "Any-device rule should still match"); + assert!( + matches!(action, Some(Action::Keystroke { .. })), + "Should be Keystroke (any-device), not Shell (device-specific)" + ); +} From 72a9f9d931efd39ad6c2d58e3093ee6b576165b0 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 13:04:31 +0000 Subject: [PATCH 6/6] fix: Add mirror comments linking match_event and match_event_with_provenance (round 5) Both methods apply the same channel scope gating pattern. Comments cross-reference each other to prevent drift during future changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- conductor-core/src/rule_set.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/conductor-core/src/rule_set.rs b/conductor-core/src/rule_set.rs index a3f08826..9336ef06 100644 --- a/conductor-core/src/rule_set.rs +++ b/conductor-core/src/rule_set.rs @@ -105,9 +105,8 @@ impl CompiledRuleSet { mode_index: usize, device_id: Option<&str>, ) -> Option { - // Channel scope check (#751): if device has a channel scope and this - // event's channel is outside it, skip all device-specific matching. - // Any-device rules and global rules still apply (they're not device-scoped). + // Channel scope check (#751) — mirrored in match_event_with_provenance(). + // If either changes, update both. let device_in_scope = device_id.is_none_or(|device| self.is_channel_in_scope(device, event)); @@ -173,7 +172,7 @@ impl CompiledRuleSet { ) -> Option { let mode_name = self.mode_name(mode_index).map(String::from); - // Channel scope check (#751) — same as match_event() + // Channel scope check (#751) — mirrored in match_event(). If either changes, update both. let device_in_scope = device_id.is_none_or(|device| self.is_channel_in_scope(device, event));