11use super :: results:: ImportConflict ;
2- use crate :: utils:: webhooks:: { Responder , ResponderMethod } ;
2+ use crate :: utils:: webhooks:: { Responder , ResponderLocation , ResponderMethod } ;
33use std:: collections:: { HashMap , HashSet } ;
44use uuid:: Uuid ;
55
@@ -32,16 +32,37 @@ fn methods_conflict(a: ResponderMethod, b: ResponderMethod) -> bool {
3232 a == b || a == ResponderMethod :: Any || b == ResponderMethod :: Any
3333}
3434
35+ /// Returns the string key used for location-based conflict comparison.
36+ /// When `strip_subdomain_prefix` is true and the location carries a prefix, the prefix
37+ /// portion is omitted so that imported responders with unsupported prefixes are compared
38+ /// as if they had no prefix.
39+ fn location_key ( location : & ResponderLocation , strip_subdomain_prefix : bool ) -> String {
40+ if strip_subdomain_prefix && location. subdomain_prefix . is_some ( ) {
41+ format ! (
42+ ":{}:{}" ,
43+ location. path_type,
44+ location. path. to_ascii_lowercase( )
45+ )
46+ } else {
47+ location. to_string ( )
48+ }
49+ }
50+
3551/// Detects responder conflicts by name **and** location+method.
3652///
3753/// A conflict is produced when an imported responder matches an existing one by:
3854/// - Same name, OR
3955/// - Same location AND conflicting method (equal, or either is `Any`)
4056///
4157/// If both criteria match the **same** existing responder, only one conflict is emitted.
58+ ///
59+ /// When `strip_subdomain_prefix` is true, subdomain prefixes on **imported** responder
60+ /// locations are ignored during comparison (existing responders never carry unsupported
61+ /// prefixes since the server validated them at creation time).
4262pub fn detect_responder_conflicts (
4363 import_items : & [ & Responder ] ,
4464 existing_items : & [ & Responder ] ,
65+ strip_subdomain_prefix : bool ,
4566) -> Vec < ImportConflict > {
4667 let existing_by_name: HashMap < & str , & Responder > = existing_items
4768 . iter ( )
@@ -51,26 +72,27 @@ pub fn detect_responder_conflicts(
5172 // Index existing responders by location string so we only check
5273 // location+method conflicts against responders that share the same path,
5374 // instead of scanning the entire existing list for every import.
75+ // Existing responders are never stripped - they were validated at creation time.
5476 let mut existing_by_location: HashMap < String , Vec < & Responder > > =
5577 HashMap :: with_capacity ( existing_items. len ( ) ) ;
5678 for & r in existing_items {
5779 existing_by_location
58- . entry ( r. location . to_string ( ) )
80+ . entry ( location_key ( & r. location , false ) )
5981 . or_default ( )
6082 . push ( r) ;
6183 }
6284
6385 let mut conflicts = Vec :: new ( ) ;
6486 for & imported in import_items {
6587 let mut seen_ids: HashSet < Uuid > = HashSet :: new ( ) ;
66- let import_loc = imported. location . to_string ( ) ;
88+ let import_loc = location_key ( & imported. location , strip_subdomain_prefix ) ;
6789
6890 // Check name conflict.
6991 if let Some ( existing) = existing_by_name. get ( imported. name . as_str ( ) ) {
7092 seen_ids. insert ( existing. id ) ;
7193 // If the same existing responder also conflicts on location+method,
7294 // renaming won't help - the location+method collision remains.
73- let also_location_conflict = existing. location . to_string ( ) == import_loc
95+ let also_location_conflict = location_key ( & existing. location , false ) == import_loc
7496 && methods_conflict ( imported. method , existing. method ) ;
7597 conflicts. push ( ImportConflict {
7698 source_id : imported. id ,
@@ -127,13 +149,23 @@ mod tests {
127149 }
128150
129151 fn make_responder ( id : u128 , name : & str , path : & str , method : ResponderMethod ) -> Responder {
152+ make_responder_with_prefix ( id, name, path, method, None )
153+ }
154+
155+ fn make_responder_with_prefix (
156+ id : u128 ,
157+ name : & str ,
158+ path : & str ,
159+ method : ResponderMethod ,
160+ subdomain_prefix : Option < & str > ,
161+ ) -> Responder {
130162 Responder {
131163 id : Uuid :: from_u128 ( id) ,
132164 name : name. to_string ( ) ,
133165 location : ResponderLocation {
134166 path_type : ResponderPathType :: Exact ,
135167 path : path. to_string ( ) ,
136- subdomain_prefix : None ,
168+ subdomain_prefix : subdomain_prefix . map ( str :: to_string ) ,
137169 } ,
138170 method,
139171 enabled : true ,
@@ -155,7 +187,7 @@ mod tests {
155187 fn responder_conflicts_finds_location_method_match ( ) {
156188 let imported = make_responder ( 1 , "new-name" , "/test" , ResponderMethod :: Get ) ;
157189 let existing = make_responder ( 100 , "old-name" , "/test" , ResponderMethod :: Get ) ;
158- let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] ) ;
190+ let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
159191 assert_eq ! ( conflicts. len( ) , 1 ) ;
160192 assert_eq ! ( conflicts[ 0 ] . source_id, Uuid :: from_u128( 1 ) ) ;
161193 assert_eq ! ( conflicts[ 0 ] . existing_id, Uuid :: from_u128( 100 ) ) ;
@@ -166,13 +198,13 @@ mod tests {
166198 fn responder_conflicts_any_method_conflicts_with_specific ( ) {
167199 let imported = make_responder ( 1 , "new-name" , "/test" , ResponderMethod :: Any ) ;
168200 let existing = make_responder ( 100 , "old-name" , "/test" , ResponderMethod :: Get ) ;
169- let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] ) ;
201+ let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
170202 assert_eq ! ( conflicts. len( ) , 1 ) ;
171203
172204 // And the reverse.
173205 let imported2 = make_responder ( 2 , "new-name2" , "/test" , ResponderMethod :: Post ) ;
174206 let existing2 = make_responder ( 200 , "old-name2" , "/test" , ResponderMethod :: Any ) ;
175- let conflicts2 = detect_responder_conflicts ( & [ & imported2] , & [ & existing2] ) ;
207+ let conflicts2 = detect_responder_conflicts ( & [ & imported2] , & [ & existing2] , false ) ;
176208 assert_eq ! ( conflicts2. len( ) , 1 ) ;
177209 }
178210
@@ -182,7 +214,7 @@ mod tests {
182214 // because renaming won't resolve the location+method collision.
183215 let imported = make_responder ( 1 , "same-name" , "/test" , ResponderMethod :: Get ) ;
184216 let existing = make_responder ( 100 , "same-name" , "/test" , ResponderMethod :: Get ) ;
185- let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] ) ;
217+ let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
186218 assert_eq ! ( conflicts. len( ) , 1 ) ;
187219 assert ! ( !conflicts[ 0 ] . rename_allowed) ;
188220 }
@@ -192,7 +224,7 @@ mod tests {
192224 // Same name but different location → rename IS allowed.
193225 let imported = make_responder ( 1 , "same-name" , "/path-a" , ResponderMethod :: Get ) ;
194226 let existing = make_responder ( 100 , "same-name" , "/path-b" , ResponderMethod :: Post ) ;
195- let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] ) ;
227+ let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
196228 assert_eq ! ( conflicts. len( ) , 1 ) ;
197229 assert ! ( conflicts[ 0 ] . rename_allowed) ;
198230 }
@@ -203,7 +235,8 @@ mod tests {
203235 let imported = make_responder ( 1 , "resp-a" , "/path-b" , ResponderMethod :: Get ) ;
204236 let existing_name = make_responder ( 100 , "resp-a" , "/other" , ResponderMethod :: Post ) ;
205237 let existing_loc = make_responder ( 200 , "resp-b" , "/path-b" , ResponderMethod :: Get ) ;
206- let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing_name, & existing_loc] ) ;
238+ let conflicts =
239+ detect_responder_conflicts ( & [ & imported] , & [ & existing_name, & existing_loc] , false ) ;
207240 assert_eq ! ( conflicts. len( ) , 2 ) ;
208241 // Name conflict allows rename, location conflict does not.
209242 let name_conflict = conflicts
@@ -222,15 +255,54 @@ mod tests {
222255 fn responder_conflicts_no_match_different_path ( ) {
223256 let imported = make_responder ( 1 , "new" , "/path-a" , ResponderMethod :: Get ) ;
224257 let existing = make_responder ( 100 , "old" , "/path-b" , ResponderMethod :: Get ) ;
225- let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] ) ;
258+ let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
226259 assert ! ( conflicts. is_empty( ) ) ;
227260 }
228261
229262 #[ test]
230263 fn responder_conflicts_no_match_different_method ( ) {
231264 let imported = make_responder ( 1 , "new" , "/test" , ResponderMethod :: Get ) ;
232265 let existing = make_responder ( 100 , "old" , "/test" , ResponderMethod :: Post ) ;
233- let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] ) ;
266+ let conflicts = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
234267 assert ! ( conflicts. is_empty( ) ) ;
235268 }
269+
270+ #[ test]
271+ fn strip_prefix_makes_imported_match_existing_without_prefix ( ) {
272+ // Imported has prefix "abc", existing has no prefix — same path+method.
273+ // Without stripping they don't conflict; with stripping they do.
274+ let imported =
275+ make_responder_with_prefix ( 1 , "new" , "/test" , ResponderMethod :: Get , Some ( "abc" ) ) ;
276+ let existing = make_responder ( 100 , "old" , "/test" , ResponderMethod :: Get ) ;
277+
278+ let without_strip = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
279+ assert ! ( without_strip. is_empty( ) ) ;
280+
281+ let with_strip = detect_responder_conflicts ( & [ & imported] , & [ & existing] , true ) ;
282+ assert_eq ! ( with_strip. len( ) , 1 ) ;
283+ assert ! ( !with_strip[ 0 ] . rename_allowed) ;
284+ }
285+
286+ #[ test]
287+ fn strip_prefix_no_effect_when_imported_has_no_prefix ( ) {
288+ let imported = make_responder ( 1 , "new" , "/test" , ResponderMethod :: Get ) ;
289+ let existing = make_responder ( 100 , "old" , "/test" , ResponderMethod :: Get ) ;
290+ let without_strip = detect_responder_conflicts ( & [ & imported] , & [ & existing] , false ) ;
291+ let with_strip = detect_responder_conflicts ( & [ & imported] , & [ & existing] , true ) ;
292+ assert_eq ! ( without_strip. len( ) , with_strip. len( ) ) ;
293+ }
294+
295+ #[ test]
296+ fn strip_prefix_collapses_different_prefixes_to_same_location ( ) {
297+ // Two imports with different prefixes but same path — after stripping both
298+ // conflict with the same existing responder.
299+ let imported_a =
300+ make_responder_with_prefix ( 1 , "a" , "/test" , ResponderMethod :: Get , Some ( "x" ) ) ;
301+ let imported_b =
302+ make_responder_with_prefix ( 2 , "b" , "/test" , ResponderMethod :: Get , Some ( "y" ) ) ;
303+ let existing = make_responder ( 100 , "old" , "/test" , ResponderMethod :: Get ) ;
304+
305+ let conflicts = detect_responder_conflicts ( & [ & imported_a, & imported_b] , & [ & existing] , true ) ;
306+ assert_eq ! ( conflicts. len( ) , 2 ) ;
307+ }
236308}
0 commit comments