1- use std:: string:: ToString ;
1+ use std:: { string:: ToString , sync :: LazyLock } ;
22
33use bencher_json:: {
44 project:: { JsonProjectPatch , JsonProjectPatchNull , JsonUpdateProject , ProjectRole , Visibility } ,
55 DateTime , JsonNewProject , JsonProject , ProjectUuid , ResourceId , ResourceIdKind , ResourceName ,
66 Slug , Url ,
77} ;
88use bencher_rbac:: { project:: Permission , Organization , Project } ;
9- use diesel:: { ExpressionMethods , QueryDsl , RunQueryDsl } ;
9+ use diesel:: {
10+ BoolExpressionMethods , ExpressionMethods , QueryDsl , RunQueryDsl , TextExpressionMethods ,
11+ } ;
1012use dropshot:: HttpError ;
1113use project_role:: InsertProjectRole ;
14+ use regex:: Regex ;
1215use slog:: Logger ;
1316
1417use crate :: {
1518 conn_lock,
1619 context:: { DbConnection , Rbac } ,
1720 error:: {
18- assert_parentage, forbidden_error, resource_conflict_err , resource_not_found_err ,
19- resource_not_found_error, unauthorized_error, BencherResource ,
21+ assert_parentage, forbidden_error, issue_error , resource_conflict_err ,
22+ resource_not_found_err , resource_not_found_error, unauthorized_error, BencherResource ,
2023 } ,
2124 macros:: {
2225 fn_get:: { fn_from_uuid, fn_get, fn_get_uuid} ,
@@ -43,6 +46,10 @@ pub mod threshold;
4346
4447crate :: macros:: typed_id:: typed_id!( ProjectId ) ;
4548
49+ static UNIQUE_SUFFIX : LazyLock < Regex > = LazyLock :: new ( || {
50+ Regex :: new ( r"\((\d+)\)$" ) . expect ( "Failed to create regex for unique project suffix" )
51+ } ) ;
52+
4653#[ derive(
4754 Debug , Clone , diesel:: Queryable , diesel:: Identifiable , diesel:: Associations , diesel:: Selectable ,
4855) ]
@@ -124,6 +131,7 @@ impl QueryProject {
124131 }
125132
126133 let query_organization = QueryOrganization :: get_or_create ( context, auth_user) . await ?;
134+ let project_name = Self :: unique_name ( context, & query_organization, project_name) . await ?;
127135 let json_project = JsonNewProject {
128136 name : project_name,
129137 slug : Some ( project_slug) ,
@@ -133,6 +141,82 @@ impl QueryProject {
133141 Self :: create ( log, context, auth_user, & query_organization, json_project) . await
134142 }
135143
144+ async fn unique_name (
145+ context : & ApiContext ,
146+ query_organization : & QueryOrganization ,
147+ project_name : ResourceName ,
148+ ) -> Result < ResourceName , HttpError > {
149+ const SPACE_PAREN_LEN : usize = 3 ;
150+ let max_name_len = ResourceName :: MAX_LEN - i64:: MAX . to_string ( ) . len ( ) - SPACE_PAREN_LEN ;
151+
152+ // This needs to happen before we escape the project name
153+ // so we check the possibly truncated name for originality
154+ let name_str = if project_name. as_ref ( ) . len ( ) > max_name_len {
155+ const ELLIPSES_LEN : usize = 3 ;
156+ // The max length for a `usize` is 20 characters,
157+ // so we don't have to worry about the number suffix being too long.
158+ project_name
159+ . as_ref ( )
160+ . chars ( )
161+ . take ( max_name_len - ELLIPSES_LEN )
162+ . chain ( "." . repeat ( ELLIPSES_LEN ) . chars ( ) )
163+ . collect :: < String > ( )
164+ } else {
165+ project_name. to_string ( )
166+ } ;
167+
168+ // Escape the project name for use in a regex pattern
169+ let escaped_name = regex:: escape ( & name_str) ;
170+ // Create a regex pattern to match the original project name or any subsequent projects with the same name
171+ let pattern = format ! ( r"^{escaped_name} \(\d+\)$" ) ;
172+
173+ let Ok ( highest_name) = schema:: project:: table
174+ . filter ( schema:: project:: organization_id. eq ( query_organization. id ) )
175+ . filter (
176+ schema:: project:: name
177+ . eq ( & project_name)
178+ . or ( schema:: project:: name. like ( & pattern) ) ,
179+ )
180+ . select ( schema:: project:: name)
181+ . order ( schema:: project:: name. desc ( ) )
182+ . first :: < ResourceName > ( conn_lock ! ( context) )
183+ else {
184+ // The project name is already unique
185+ return Ok ( project_name) ;
186+ } ;
187+
188+ let next_number = if highest_name == project_name {
189+ 1
190+ } else if let Some ( caps) = UNIQUE_SUFFIX . captures ( highest_name. as_ref ( ) ) {
191+ let last_number: usize = caps
192+ . get ( 1 )
193+ . and_then ( |m| m. as_str ( ) . parse ( ) . ok ( ) )
194+ . ok_or_else ( || {
195+ issue_error (
196+ "Failed to parse project number" ,
197+ & format ! ( "Failed to parse number from project ({highest_name})" ) ,
198+ highest_name,
199+ )
200+ } ) ?;
201+ last_number + 1
202+ } else {
203+ return Err ( issue_error (
204+ "Failed to create new project number" ,
205+ & format ! ( "Failed to create new number for project ({project_name}) with highest project ({highest_name})" ) ,
206+ highest_name,
207+ ) ) ;
208+ } ;
209+
210+ let name_with_suffix = format ! ( "{name_str} ({next_number})" ) ;
211+ name_with_suffix. parse ( ) . map_err ( |e| {
212+ issue_error (
213+ "Failed to create new project name" ,
214+ & format ! ( "Failed to create new project name ({name_with_suffix})" , ) ,
215+ e,
216+ )
217+ } )
218+ }
219+
136220 pub async fn create (
137221 log : & Logger ,
138222 context : & ApiContext ,
0 commit comments