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;