1
- use std:: string:: ToString ;
1
+ use std:: { string:: ToString , sync :: LazyLock } ;
2
2
3
3
use bencher_json:: {
4
4
project:: { JsonProjectPatch , JsonProjectPatchNull , JsonUpdateProject , ProjectRole , Visibility } ,
5
5
DateTime , JsonNewProject , JsonProject , ProjectUuid , ResourceId , ResourceIdKind , ResourceName ,
6
6
Slug , Url ,
7
7
} ;
8
8
use bencher_rbac:: { project:: Permission , Organization , Project } ;
9
- use diesel:: { ExpressionMethods , QueryDsl , RunQueryDsl } ;
9
+ use diesel:: {
10
+ BoolExpressionMethods , ExpressionMethods , QueryDsl , RunQueryDsl , TextExpressionMethods ,
11
+ } ;
10
12
use dropshot:: HttpError ;
11
13
use project_role:: InsertProjectRole ;
14
+ use regex:: Regex ;
12
15
use slog:: Logger ;
13
16
14
17
use crate :: {
15
18
conn_lock,
16
19
context:: { DbConnection , Rbac } ,
17
20
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 ,
20
23
} ,
21
24
macros:: {
22
25
fn_get:: { fn_from_uuid, fn_get, fn_get_uuid} ,
@@ -43,6 +46,10 @@ pub mod threshold;
43
46
44
47
crate :: macros:: typed_id:: typed_id!( ProjectId ) ;
45
48
49
+ static UNIQUE_SUFFIX : LazyLock < Regex > = LazyLock :: new ( || {
50
+ Regex :: new ( r"\((\d+)\)$" ) . expect ( "Failed to create regex for unique project suffix" )
51
+ } ) ;
52
+
46
53
#[ derive(
47
54
Debug , Clone , diesel:: Queryable , diesel:: Identifiable , diesel:: Associations , diesel:: Selectable ,
48
55
) ]
@@ -124,6 +131,7 @@ impl QueryProject {
124
131
}
125
132
126
133
let query_organization = QueryOrganization :: get_or_create ( context, auth_user) . await ?;
134
+ let project_name = Self :: unique_name ( context, & query_organization, project_name) . await ?;
127
135
let json_project = JsonNewProject {
128
136
name : project_name,
129
137
slug : Some ( project_slug) ,
@@ -133,6 +141,82 @@ impl QueryProject {
133
141
Self :: create ( log, context, auth_user, & query_organization, json_project) . await
134
142
}
135
143
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
+
136
220
pub async fn create (
137
221
log : & Logger ,
138
222
context : & ApiContext ,
0 commit comments