Skip to main content

agent_command_knowledge/
merge.rs

1//! Overlay/merge logic for extending or replacing knowledge base entries.
2//!
3//! [`KnowledgeOverlay`] is the user-facing config type: it specifies commands
4//! and wrappers to add, merge, or remove from a [`KnowledgeBase`].
5//! [`CommandOverlay`] mirrors [`CommandKnowledge`] but with optional fields so
6//! that unspecified values don't clobber base entries during merge.
7
8use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::types::{
13    CommandKnowledge, CommandProperties, Effect, EnvGate, FlagSchema, KnowledgeBase, PathSpec,
14    SubcommandMap, WrapperKnowledge,
15};
16
17/// Overlay for a single wrapper — optional fields so unset values don't
18/// clobber base values during merge.
19///
20/// Mirrors the [`CommandOverlay`] pattern: fields are `Option` so that
21/// omitting a field in TOML preserves the base value instead of silently
22/// resetting it to a serde default.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct WrapperOverlay {
25    /// Override floor_effect. `None` preserves the base value.
26    #[serde(default)]
27    pub floor_effect: Option<Effect>,
28    /// Override clears_env. `None` preserves the base value.
29    #[serde(default)]
30    pub clears_env: Option<bool>,
31    /// Override escalates_privilege. `None` preserves the base value.
32    #[serde(default)]
33    pub escalates_privilege: Option<bool>,
34}
35
36impl WrapperOverlay {
37    /// Apply this overlay onto an existing base wrapper, mutating it in place.
38    ///
39    /// Only fields that are `Some` override the base value; `None` fields
40    /// are left untouched. The `name` field is never modified — it's an
41    /// identity field derived from the HashMap key.
42    pub(crate) fn apply_to(self, base: &mut WrapperKnowledge) {
43        if let Some(floor_effect) = self.floor_effect {
44            base.floor_effect = floor_effect;
45        }
46        if let Some(clears_env) = self.clears_env {
47            base.clears_env = clears_env;
48        }
49        if let Some(escalates_privilege) = self.escalates_privilege {
50            base.escalates_privilege = escalates_privilege;
51        }
52    }
53
54    /// Convert this overlay into a full [`WrapperKnowledge`] for insertion as a
55    /// new wrapper. `floor_effect` defaults to [`Effect::Unknown`] (fail-closed)
56    /// when not specified; booleans default to `false`.
57    pub(crate) fn into_knowledge(self, key: String) -> WrapperKnowledge {
58        WrapperKnowledge {
59            name: key,
60            floor_effect: self.floor_effect.unwrap_or(Effect::Unknown),
61            clears_env: self.clears_env.unwrap_or(false),
62            escalates_privilege: self.escalates_privilege.unwrap_or(false),
63        }
64    }
65}
66
67/// Overlay config for extending/modifying a [`KnowledgeBase`].
68///
69/// Applied via [`KnowledgeBase::merge`]: removals are processed first, then
70/// additions and overrides.
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct KnowledgeOverlay {
73    /// Commands to add or merge into the base.
74    #[serde(default)]
75    pub commands: HashMap<String, CommandOverlay>,
76    /// Wrappers to add or merge into the base.
77    #[serde(default)]
78    pub wrappers: HashMap<String, WrapperOverlay>,
79    /// Command names to remove from the base.
80    #[serde(default)]
81    pub remove_commands: Vec<String>,
82    /// Wrapper names to remove from the base.
83    #[serde(default)]
84    pub remove_wrappers: Vec<String>,
85}
86
87/// Overlay for a single command — optional fields so unset values don't
88/// clobber base values during merge.
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct CommandOverlay {
91    /// Override the base effect. `None` preserves the base value.
92    #[serde(default)]
93    pub effect: Option<Effect>,
94    /// Subcommands to merge into the base (overlay wins on conflict).
95    #[serde(default)]
96    pub subcommands: SubcommandMap,
97    /// Flags to append to the base.
98    #[serde(default)]
99    pub flags: FlagSchema,
100    /// Env gates to append to the base.
101    #[serde(default)]
102    pub env_gates: Vec<EnvGate>,
103    /// Paths to replace base paths. `None` preserves the base value.
104    #[serde(default)]
105    pub paths: Option<PathSpec>,
106    /// Properties to replace base properties. `None` preserves the base value.
107    #[serde(default)]
108    pub properties: Option<CommandProperties>,
109    /// Subcommand patterns to remove from the base before merging.
110    /// Patterns are space-separated strings matching the key format used by
111    /// `SubcommandMap::insert` (e.g. `"pr create"`, not `"pr"` and `"create"`
112    /// as separate entries).
113    #[serde(default)]
114    pub remove_subcommands: Vec<String>,
115}
116
117impl CommandOverlay {
118    /// Create an overlay that only sets the effect, leaving everything else defaulted.
119    #[cfg(test)]
120    pub fn with_effect(effect: Effect) -> Self {
121        Self {
122            effect: Some(effect),
123            ..Default::default()
124        }
125    }
126
127    /// Apply this overlay onto an existing base command, mutating it in place.
128    ///
129    /// - Override effect only when specified.
130    /// - Remove subcommands listed in `remove_subcommands`, then merge overlay
131    ///   subcommands (overlay wins on conflict).
132    /// - Append flags and env gates.
133    /// - Replace paths and properties only when `Some`.
134    pub(crate) fn apply_to(self, base: &mut CommandKnowledge) {
135        if let Some(effect) = self.effect {
136            base.effect = effect;
137        }
138
139        for pattern in &self.remove_subcommands {
140            base.subcommands.remove(pattern);
141        }
142        base.subcommands.extend(self.subcommands);
143
144        base.flags.extend(self.flags);
145        base.env_gates.extend(self.env_gates);
146
147        if let Some(paths) = self.paths {
148            base.paths = paths;
149        }
150        if let Some(properties) = self.properties {
151            base.properties = properties;
152        }
153    }
154
155    /// Convert this overlay into a full [`CommandKnowledge`] for insertion as a
156    /// new command. Effect defaults to [`Effect::Unknown`] (fail-closed) when
157    /// not specified.
158    pub(crate) fn into_knowledge(self, key: String) -> CommandKnowledge {
159        CommandKnowledge {
160            name: key,
161            effect: self.effect.unwrap_or(Effect::Unknown),
162            subcommands: self.subcommands,
163            flags: self.flags,
164            env_gates: self.env_gates,
165            paths: self.paths.unwrap_or_default(),
166            properties: self.properties.unwrap_or_default(),
167        }
168    }
169}
170
171impl KnowledgeBase {
172    /// Merge an overlay into this knowledge base.
173    ///
174    /// Processing order:
175    /// 1. **Removals** — commands and wrappers listed in `remove_commands` /
176    ///    `remove_wrappers` are deleted from the base.
177    /// 2. **Additions / overrides** — overlay commands are merged into existing
178    ///    entries (via [`CommandOverlay::apply_to`]) or inserted as new commands
179    ///    (via [`CommandOverlay::into_knowledge`]). Overlay wrappers are merged
180    ///    into existing entries (via [`WrapperOverlay::apply_to`]) or inserted
181    ///    as new wrappers (via [`WrapperOverlay::into_knowledge`]).
182    pub fn merge(&mut self, overlay: KnowledgeOverlay) {
183        // 1. Removals first.
184        for key in &overlay.remove_commands {
185            self.commands.remove(key);
186        }
187        for key in &overlay.remove_wrappers {
188            self.wrappers.remove(key);
189        }
190
191        // 2. Additions / overrides.
192        for (key, cmd_overlay) in overlay.commands {
193            if let Some(base) = self.commands.get_mut(&key) {
194                cmd_overlay.apply_to(base);
195            } else {
196                self.commands
197                    .insert(key.clone(), cmd_overlay.into_knowledge(key));
198            }
199        }
200
201        for (key, wrapper_overlay) in overlay.wrappers {
202            if let Some(base) = self.wrappers.get_mut(&key) {
203                wrapper_overlay.apply_to(base);
204            } else {
205                self.wrappers
206                    .insert(key.clone(), wrapper_overlay.into_knowledge(key));
207            }
208        }
209    }
210}
211
212#[cfg(test)]
213#[path = "merge_tests.rs"]
214mod merge_tests;
215
216#[cfg(test)]
217#[path = "merge_proptest.rs"]
218mod merge_proptest;