1use std::collections::HashMap;
2
3use codex_config::ConfigLayerSource;
4use codex_config::ConfigLayerStack;
5use codex_config::ConfigLayerStackOrdering;
6use codex_config::HookStateToml;
7use codex_config::TomlValue;
8
9pub fn hook_states_from_stack(
17 config_layer_stack: Option<&ConfigLayerStack>,
18) -> HashMap<String, HookStateToml> {
19 let Some(config_layer_stack) = config_layer_stack else {
20 return HashMap::new();
21 };
22
23 let mut states: HashMap<String, HookStateToml> = HashMap::new();
24 for layer in config_layer_stack.get_layers(
25 ConfigLayerStackOrdering::LowestPrecedenceFirst,
26 true,
27 ) {
28 if !matches!(
29 layer.name,
30 ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
31 ) {
32 continue;
33 }
34
35 let Some(state_value) = layer
36 .config
37 .get("hooks")
38 .and_then(|hooks| hooks.get("state"))
39 else {
40 continue;
41 };
42 let TomlValue::Table(state_by_key) = state_value else {
43 continue;
44 };
45
46 for (key, state_value) in state_by_key {
47 let state: HookStateToml = match state_value.clone().try_into() {
48 Ok(state) => state,
49 Err(_) => {
50 continue;
51 }
52 };
53 let key = key.trim();
54 if key.is_empty() {
55 continue;
56 }
57 let effective_state = states.entry(key.to_string()).or_default();
60 if let Some(enabled) = state.enabled {
61 effective_state.enabled = Some(enabled);
62 }
63 if let Some(trusted_hash) = state.trusted_hash {
64 effective_state.trusted_hash = Some(trusted_hash);
65 }
66 }
67 }
68
69 states
70}
71
72#[cfg(test)]
73mod tests {
74 use codex_config::ConfigLayerEntry;
75 use codex_config::TomlValue;
76 use codex_utils_absolute_path::test_support::PathBufExt;
77 use codex_utils_absolute_path::test_support::test_path_buf;
78 use pretty_assertions::assert_eq;
79
80 use super::*;
81
82 #[test]
83 fn hook_states_from_stack_respects_layer_precedence() {
84 let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
85 let stack = ConfigLayerStack::new(
86 vec![
87 ConfigLayerEntry::new(
88 ConfigLayerSource::User {
89 file: test_path_buf("/tmp/config.toml").abs(),
90 profile: None,
91 },
92 config_with_hook_override(key, Some(false)),
93 ),
94 ConfigLayerEntry::new(
95 ConfigLayerSource::SessionFlags,
96 config_with_hook_override(key, Some(true)),
97 ),
98 ],
99 Default::default(),
100 Default::default(),
101 )
102 .expect("config layer stack");
103
104 assert_eq!(
105 hook_states_from_stack(Some(&stack)),
106 HashMap::from([(
107 key.to_string(),
108 HookStateToml {
109 enabled: Some(true),
110 trusted_hash: None,
111 },
112 )])
113 );
114 }
115
116 #[test]
117 fn hook_states_from_stack_merges_fields_across_layers() {
118 let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
119 let stack = ConfigLayerStack::new(
120 vec![
121 ConfigLayerEntry::new(
122 ConfigLayerSource::User {
123 file: test_path_buf("/tmp/config.toml").abs(),
124 profile: None,
125 },
126 config_with_hook_state(
127 key,
128 HookStateToml {
129 enabled: Some(false),
130 trusted_hash: None,
131 },
132 ),
133 ),
134 ConfigLayerEntry::new(
135 ConfigLayerSource::SessionFlags,
136 config_with_hook_state(
137 key,
138 HookStateToml {
139 enabled: None,
140 trusted_hash: Some("sha256:trusted".to_string()),
141 },
142 ),
143 ),
144 ],
145 Default::default(),
146 Default::default(),
147 )
148 .expect("config layer stack");
149
150 assert_eq!(
151 hook_states_from_stack(Some(&stack)),
152 HashMap::from([(
153 key.to_string(),
154 HookStateToml {
155 enabled: Some(false),
156 trusted_hash: Some("sha256:trusted".to_string()),
157 },
158 )])
159 );
160 }
161
162 #[test]
163 fn hook_states_from_stack_ignores_malformed_hook_events() {
164 let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
165 let config: TomlValue = serde_json::from_value(serde_json::json!({
166 "hooks": {
167 "state": {
168 (key): {
169 "enabled": false,
170 },
171 },
172 "SessionStart": "not a matcher list",
173 },
174 }))
175 .expect("config TOML should deserialize");
176 let stack = ConfigLayerStack::new(
177 vec![ConfigLayerEntry::new(
178 ConfigLayerSource::User {
179 file: test_path_buf("/tmp/config.toml").abs(),
180 profile: None,
181 },
182 config,
183 )],
184 Default::default(),
185 Default::default(),
186 )
187 .expect("config layer stack");
188
189 assert_eq!(
190 hook_states_from_stack(Some(&stack)),
191 HashMap::from([(
192 key.to_string(),
193 HookStateToml {
194 enabled: Some(false),
195 trusted_hash: None,
196 },
197 )])
198 );
199 }
200
201 #[test]
202 fn hook_states_from_stack_ignores_malformed_state_entries() {
203 let key = "file:/tmp/hooks.json:pre_tool_use:0:0";
204 let config: TomlValue = serde_json::from_value(serde_json::json!({
205 "hooks": {
206 "state": {
207 (key): {
208 "enabled": false,
209 },
210 "malformed": {
211 "enabled": "not a bool",
212 },
213 },
214 },
215 }))
216 .expect("config TOML should deserialize");
217 let stack = ConfigLayerStack::new(
218 vec![ConfigLayerEntry::new(
219 ConfigLayerSource::User {
220 file: test_path_buf("/tmp/config.toml").abs(),
221 profile: None,
222 },
223 config,
224 )],
225 Default::default(),
226 Default::default(),
227 )
228 .expect("config layer stack");
229
230 assert_eq!(
231 hook_states_from_stack(Some(&stack)),
232 HashMap::from([(
233 key.to_string(),
234 HookStateToml {
235 enabled: Some(false),
236 trusted_hash: None,
237 },
238 )])
239 );
240 }
241
242 fn config_with_hook_override(key: &str, enabled: Option<bool>) -> TomlValue {
243 config_with_hook_state(
244 key,
245 HookStateToml {
246 enabled,
247 trusted_hash: None,
248 },
249 )
250 }
251
252 fn config_with_hook_state(key: &str, state: HookStateToml) -> TomlValue {
253 let hook_state = serde_json::to_value(state).expect("hook state should serialize");
254 serde_json::from_value(serde_json::json!({
255 "hooks": {
256 "state": {
257 (key): hook_state,
258 },
259 },
260 }))
261 .expect("config TOML should deserialize")
262 }
263}