@@ -6,14 +6,19 @@ use merge_json::merge_json;
66
77/// Fetches the given prefixes from the API and merges them into a single Value. (It's not
88/// expected that given prefixes would overlap, but if they do, later ones take precedence.)
9- pub async fn get_prefixes < P > ( socket_path : P , prefixes : Vec < String > ) -> Result < serde_json:: Value >
9+ /// Excludes any keys matching the exclude prefixes from the final result.
10+ pub async fn get_prefixes < P > (
11+ socket_path : P ,
12+ include : Vec < String > ,
13+ exclude : Vec < String > ,
14+ ) -> Result < serde_json:: Value >
1015where
1116 P : AsRef < Path > ,
1217{
13- let mut results: Vec < serde_json:: Value > = Vec :: with_capacity ( prefixes . len ( ) ) ;
18+ let mut results: Vec < serde_json:: Value > = Vec :: with_capacity ( include . len ( ) ) ;
1419
1520 // Fetch all given prefixes into separate Values.
16- for prefix in prefixes {
21+ for prefix in include {
1722 let uri = format ! ( "/?prefix={prefix}" ) ;
1823 let method = "GET" ;
1924 let ( _status, body) = crate :: raw_request ( & socket_path, & uri, method, None )
@@ -24,13 +29,79 @@ where
2429 }
2530
2631 // Merge results together.
27- results
32+ let mut merged = results
2833 . into_iter ( )
2934 . reduce ( |mut merge_into, merge_from| {
3035 merge_json ( & mut merge_into, merge_from) ;
3136 merge_into
3237 } )
33- . context ( error:: NoPrefixesSnafu )
38+ . context ( error:: NoPrefixesSnafu ) ?;
39+
40+ // Remove excluded prefixes
41+ if !exclude. is_empty ( ) {
42+ remove_prefixes ( & mut merged, & exclude) ;
43+ }
44+
45+ Ok ( merged)
46+ }
47+
48+ /// Removes keys matching any of the given prefixes from the JSON value.
49+ /// Uses dotted key notation (e.g., "settings.network" matches "settings.network.hostname").
50+ /// Prefixes without trailing dots match any key starting with that prefix.
51+ /// Prefixes with trailing dots only match keys with that exact prefix followed by more path segments.
52+ fn remove_prefixes ( value : & mut serde_json:: Value , prefixes : & [ String ] ) {
53+ remove_prefixes_recursive ( value, "" , prefixes) ;
54+ }
55+
56+ /// Recursively removes keys from JSON, tracking the dotted path as we traverse.
57+ /// Returns true if the object is empty after removing any prefix matches, and should be removed.
58+ /// Returns false otherwise.
59+ fn remove_prefixes_recursive (
60+ value : & mut serde_json:: Value ,
61+ parent_path : & str ,
62+ prefixes : & [ String ] ,
63+ ) -> bool {
64+ let serde_json:: Value :: Object ( map) = value else {
65+ // Non-object values shouldn't be removed and can't have children
66+ return false ;
67+ } ;
68+
69+ // Build paths once and determine which keys to remove
70+ let mut keys_to_remove = Vec :: new ( ) ;
71+ let mut keys_to_recurse = Vec :: new ( ) ;
72+
73+ for key in map. keys ( ) {
74+ let full_path = if parent_path. is_empty ( ) {
75+ key. to_string ( )
76+ } else {
77+ format ! ( "{}.{}" , parent_path, key)
78+ } ;
79+
80+ let should_remove = prefixes. iter ( ) . any ( |prefix| full_path. starts_with ( prefix) ) ;
81+
82+ if should_remove {
83+ keys_to_remove. push ( key. clone ( ) ) ;
84+ } else {
85+ keys_to_recurse. push ( ( key. clone ( ) , full_path) ) ;
86+ }
87+ }
88+
89+ // Remove matching keys
90+ for key in keys_to_remove {
91+ map. remove ( & key) ;
92+ }
93+
94+ // Recursively process remaining nested objects and remove if empty
95+ for ( key, full_path) in keys_to_recurse {
96+ if let Some ( nested_value) = map. get_mut ( & key) {
97+ let should_remove = remove_prefixes_recursive ( nested_value, & full_path, prefixes) ;
98+ if should_remove {
99+ map. remove ( & key) ;
100+ }
101+ }
102+ }
103+
104+ map. is_empty ( )
34105}
35106
36107/// Fetches the given URI from the API and returns the result as an untyped Value.
@@ -71,3 +142,195 @@ mod error {
71142}
72143pub use error:: Error ;
73144pub type Result < T > = std:: result:: Result < T , error:: Error > ;
145+
146+ #[ cfg( test) ]
147+ mod test {
148+ use super :: * ;
149+ use serde_json:: json;
150+
151+ #[ test]
152+ fn test_remove_prefixes_simple ( ) {
153+ let mut value = json ! ( {
154+ "settings" : {
155+ "motd" : "hello" ,
156+ "network" : { "hostname" : "test" } ,
157+ "kubernetes" : { "cluster-name" : "dev" }
158+ }
159+ } ) ;
160+
161+ remove_prefixes ( & mut value, & [ "settings.network" . to_string ( ) ] ) ;
162+
163+ assert_eq ! (
164+ value,
165+ json!( {
166+ "settings" : {
167+ "motd" : "hello" ,
168+ "kubernetes" : { "cluster-name" : "dev" }
169+ }
170+ } )
171+ ) ;
172+ }
173+
174+ #[ test]
175+ fn test_remove_prefixes_nested ( ) {
176+ let mut value = json ! ( {
177+ "settings" : {
178+ "motd" : "hello" ,
179+ "network" : { "hostname" : "test" } ,
180+ "host-containers" : {
181+ "admin" : {
182+ "enabled" : true ,
183+ "source" : "example.com/admin:v1"
184+ } ,
185+ "control" : {
186+ "enabled" : true ,
187+ "source" : "example.com/control:v1"
188+ }
189+ } ,
190+ "kubernetes" : {
191+ "cluster-name" : "dev"
192+ }
193+ }
194+ } ) ;
195+
196+ remove_prefixes (
197+ & mut value,
198+ & [
199+ "settings.network" . to_string ( ) ,
200+ "settings.host-containers.admin" . to_string ( ) ,
201+ ] ,
202+ ) ;
203+
204+ assert_eq ! (
205+ value,
206+ json!( {
207+ "settings" : {
208+ "motd" : "hello" ,
209+ "host-containers" : {
210+ "control" : {
211+ "enabled" : true ,
212+ "source" : "example.com/control:v1"
213+ }
214+ } ,
215+ "kubernetes" : {
216+ "cluster-name" : "dev"
217+ }
218+ }
219+ } )
220+ ) ;
221+ }
222+
223+ #[ test]
224+ fn test_remove_prefixes_no_match ( ) {
225+ let mut value = json ! ( {
226+ "settings" : {
227+ "motd" : "hello"
228+ }
229+ } ) ;
230+
231+ let expected = value. clone ( ) ;
232+ remove_prefixes ( & mut value, & [ "settings.network" . to_string ( ) ] ) ;
233+
234+ assert_eq ! ( value, expected) ;
235+ }
236+
237+ #[ test]
238+ fn test_remove_prefixes_exact_dotted_path ( ) {
239+ // Test the specific case: {"settings":{"network":{"hostname":"foo"}}}
240+ // with exclude prefix "settings.network" should remove the entire network subtree
241+ let mut value = json ! ( {
242+ "settings" : {
243+ "network" : {
244+ "hostname" : "foo"
245+ }
246+ }
247+ } ) ;
248+
249+ remove_prefixes ( & mut value, & [ "settings.network" . to_string ( ) ] ) ;
250+
251+ // After removing "network", "settings" becomes empty and is also removed
252+ assert_eq ! ( value, json!( { } ) ) ;
253+ }
254+
255+ #[ test]
256+ fn test_remove_prefixes_trailing_dot ( ) {
257+ // Test that "settings.network." with trailing dot only matches children, not the key itself
258+ let mut value = json ! ( {
259+ "settings" : {
260+ "network" : {
261+ "hostname" : "foo"
262+ } ,
263+ "network-connections" : {
264+ "eth0" : "up"
265+ } ,
266+ "motd" : "hello"
267+ }
268+ } ) ;
269+
270+ remove_prefixes ( & mut value, & [ "settings.network." . to_string ( ) ] ) ;
271+
272+ // Trailing dot means only children of "network" are removed
273+ // Since "network" becomes empty, it should also be removed
274+ assert_eq ! (
275+ value,
276+ json!( {
277+ "settings" : {
278+ "network-connections" : {
279+ "eth0" : "up"
280+ } ,
281+ "motd" : "hello"
282+ }
283+ } )
284+ ) ;
285+ }
286+
287+ #[ test]
288+ fn test_remove_prefixes_similar_names ( ) {
289+ // Test that "settings.network" (no trailing dot) matches both "network" and "network-connections"
290+ let mut value = json ! ( {
291+ "settings" : {
292+ "network" : {
293+ "hostname" : "foo"
294+ } ,
295+ "network-connections" : {
296+ "eth0" : "up"
297+ } ,
298+ "motd" : "hello"
299+ }
300+ } ) ;
301+
302+ remove_prefixes ( & mut value, & [ "settings.network" . to_string ( ) ] ) ;
303+
304+ // Without trailing dot, matches "network" exactly and "network-connections" by prefix
305+ assert_eq ! (
306+ value,
307+ json!( {
308+ "settings" : {
309+ "motd" : "hello"
310+ }
311+ } )
312+ ) ;
313+ }
314+
315+ #[ test]
316+ fn test_remove_prefixes_trailing_dot_non_map ( ) {
317+ // Test that "settings.motd." with trailing dot should NOT remove "settings.motd" when motd is a string
318+ let mut value = json ! ( {
319+ "settings" : {
320+ "motd" : "hello"
321+ }
322+ } ) ;
323+
324+ remove_prefixes ( & mut value, & [ "settings.motd." . to_string ( ) ] ) ;
325+
326+ // motd is not a map, so "settings.motd." should not match it
327+ assert_eq ! (
328+ value,
329+ json!( {
330+ "settings" : {
331+ "motd" : "hello"
332+ }
333+ } )
334+ ) ;
335+ }
336+ }
0 commit comments