@@ -2,30 +2,67 @@ use std::time::Duration;
22
33use anyhow:: { bail, Result } ;
44use dialoguer:: Input ;
5+ use reqwest:: StatusCode ;
56
6- use crate :: http:: ApiClient ;
7+ use crate :: http:: { ApiClient , HttpError } ;
78use crate :: ui:: {
89 is_interactive, print_command_status, with_spinner, with_spinner_visible, CommandStatus ,
910} ;
1011
1112use super :: api;
1213
13- pub ( crate ) async fn create_project_checked ( client : & ApiClient , name : & str ) -> Result < api:: Project > {
14+ #[ derive( Debug ) ]
15+ pub ( crate ) enum CreateProjectOutcome {
16+ Created ( api:: Project ) ,
17+ Existing ( api:: Project ) ,
18+ }
19+
20+ pub ( crate ) async fn create_project_checked (
21+ client : & ApiClient ,
22+ name : & str ,
23+ ) -> Result < CreateProjectOutcome > {
1424 let exists = with_spinner (
1525 "Checking project..." ,
1626 api:: get_project_by_name ( client, name) ,
1727 )
1828 . await ?;
19- if exists . is_some ( ) {
20- bail ! ( " project '{name}' already exists" ) ;
29+ if let Some ( project ) = exists {
30+ return Ok ( CreateProjectOutcome :: Existing ( project) ) ;
2131 }
2232
23- with_spinner_visible (
33+ match with_spinner_visible (
2434 "Creating project..." ,
2535 api:: create_project ( client, name) ,
2636 Duration :: from_millis ( 300 ) ,
2737 )
2838 . await
39+ {
40+ Ok ( project) => Ok ( CreateProjectOutcome :: Created ( project) ) ,
41+ Err ( err) => {
42+ if !is_conflict_error ( & err) {
43+ return Err ( err) ;
44+ }
45+
46+ if let Some ( project) = with_spinner (
47+ "Checking project..." ,
48+ api:: get_project_by_name ( client, name) ,
49+ )
50+ . await ?
51+ {
52+ return Ok ( CreateProjectOutcome :: Existing ( project) ) ;
53+ }
54+
55+ Err ( err)
56+ }
57+ }
58+ }
59+
60+ fn is_conflict_error ( err : & anyhow:: Error ) -> bool {
61+ err. chain ( ) . any ( |source| {
62+ source
63+ . downcast_ref :: < HttpError > ( )
64+ . is_some_and ( |http_err| http_err. status == StatusCode :: CONFLICT )
65+ } )
2966}
3067
3168pub async fn run ( client : & ApiClient , name : Option < & str > ) -> Result < ( ) > {
@@ -40,16 +77,221 @@ pub async fn run(client: &ApiClient, name: Option<&str>) -> Result<()> {
4077 } ;
4178
4279 match create_project_checked ( client, & name) . await {
43- Ok ( _ ) => {
80+ Ok ( CreateProjectOutcome :: Created ( _ ) ) => {
4481 print_command_status (
4582 CommandStatus :: Success ,
4683 & format ! ( "Successfully created '{name}'" ) ,
4784 ) ;
4885 Ok ( ( ) )
4986 }
87+ Ok ( CreateProjectOutcome :: Existing ( _) ) => {
88+ print_command_status ( CommandStatus :: Error , & format ! ( "Failed to create '{name}'" ) ) ;
89+ bail ! ( "project '{name}' already exists" )
90+ }
5091 Err ( e) => {
5192 print_command_status ( CommandStatus :: Error , & format ! ( "Failed to create '{name}'" ) ) ;
5293 Err ( e)
5394 }
5495 }
5596}
97+
98+ #[ cfg( test) ]
99+ mod tests {
100+ use std:: net:: TcpListener ;
101+ use std:: sync:: { Arc , Mutex } ;
102+
103+ use actix_web:: { web, App , HttpResponse , HttpServer } ;
104+ use braintrust_sdk_rust:: LoginState ;
105+ use serde:: Deserialize ;
106+
107+ use super :: * ;
108+ use crate :: auth:: LoginContext ;
109+
110+ #[ derive( Clone ) ]
111+ struct MockProject {
112+ id : String ,
113+ name : String ,
114+ org_id : String ,
115+ }
116+
117+ impl MockProject {
118+ fn new ( id : & str , name : & str ) -> Self {
119+ Self {
120+ id : id. to_string ( ) ,
121+ name : name. to_string ( ) ,
122+ org_id : "test-org" . to_string ( ) ,
123+ }
124+ }
125+ }
126+
127+ #[ derive( Clone , Copy ) ]
128+ enum CreateBehavior {
129+ Create ,
130+ ConflictThenReveal ,
131+ }
132+
133+ struct MockState {
134+ projects : Mutex < Vec < MockProject > > ,
135+ create_behavior : CreateBehavior ,
136+ }
137+
138+ struct MockServer {
139+ base_url : String ,
140+ handle : actix_web:: dev:: ServerHandle ,
141+ }
142+
143+ impl MockServer {
144+ async fn start ( state : Arc < MockState > ) -> Self {
145+ let listener = TcpListener :: bind ( ( "127.0.0.1" , 0 ) ) . expect ( "bind mock server" ) ;
146+ let addr = listener. local_addr ( ) . expect ( "mock server addr" ) ;
147+ let base_url = format ! ( "http://{addr}" ) ;
148+ let data = web:: Data :: new ( state) ;
149+
150+ let server = HttpServer :: new ( move || {
151+ App :: new ( )
152+ . app_data ( data. clone ( ) )
153+ . route ( "/v1/project" , web:: get ( ) . to ( mock_list_projects) )
154+ . route ( "/v1/project" , web:: post ( ) . to ( mock_create_project) )
155+ } )
156+ . workers ( 1 )
157+ . listen ( listener)
158+ . expect ( "listen mock server" )
159+ . run ( ) ;
160+ let handle = server. handle ( ) ;
161+ tokio:: spawn ( server) ;
162+
163+ Self { base_url, handle }
164+ }
165+
166+ async fn stop ( & self ) {
167+ self . handle . stop ( true ) . await ;
168+ }
169+ }
170+
171+ async fn setup_test (
172+ projects : Vec < MockProject > ,
173+ create_behavior : CreateBehavior ,
174+ ) -> ( MockServer , ApiClient ) {
175+ let state = Arc :: new ( MockState {
176+ projects : Mutex :: new ( projects) ,
177+ create_behavior,
178+ } ) ;
179+ let server = MockServer :: start ( state) . await ;
180+ let client = ApiClient :: new ( & LoginContext {
181+ login : LoginState {
182+ api_key : "test-key" . to_string ( ) ,
183+ org_id : "org-1" . to_string ( ) ,
184+ org_name : "test-org" . to_string ( ) ,
185+ api_url : Some ( server. base_url . clone ( ) ) ,
186+ } ,
187+ api_url : server. base_url . clone ( ) ,
188+ app_url : "https://app.example.com" . to_string ( ) ,
189+ } )
190+ . expect ( "build client" ) ;
191+ ( server, client)
192+ }
193+
194+ #[ derive( Deserialize ) ]
195+ struct ListProjectsQuery {
196+ project_name : Option < String > ,
197+ }
198+
199+ async fn mock_list_projects (
200+ state : web:: Data < Arc < MockState > > ,
201+ query : web:: Query < ListProjectsQuery > ,
202+ ) -> HttpResponse {
203+ let projects = state. projects . lock ( ) . expect ( "projects lock" ) . clone ( ) ;
204+ let objects: Vec < _ > = projects
205+ . into_iter ( )
206+ . filter ( |p| query. project_name . as_deref ( ) . is_none_or ( |n| p. name == n) )
207+ . map ( |p| serde_json:: json!( { "id" : p. id, "name" : p. name, "org_id" : p. org_id } ) )
208+ . collect ( ) ;
209+ HttpResponse :: Ok ( ) . json ( serde_json:: json!( { "objects" : objects } ) )
210+ }
211+
212+ #[ derive( Deserialize ) ]
213+ struct CreateProjectRequest {
214+ name : String ,
215+ }
216+
217+ async fn mock_create_project (
218+ state : web:: Data < Arc < MockState > > ,
219+ body : web:: Json < CreateProjectRequest > ,
220+ ) -> HttpResponse {
221+ match state. create_behavior {
222+ CreateBehavior :: Create => {
223+ let mut projects = state. projects . lock ( ) . expect ( "projects lock" ) ;
224+ let id = format ! ( "proj-created-{}" , projects. len( ) + 1 ) ;
225+ let created = MockProject :: new ( & id, & body. name ) ;
226+ projects. push ( created. clone ( ) ) ;
227+ HttpResponse :: Ok ( ) . json (
228+ serde_json:: json!( { "id" : created. id, "name" : created. name, "org_id" : created. org_id } ) ,
229+ )
230+ }
231+ CreateBehavior :: ConflictThenReveal => {
232+ state
233+ . projects
234+ . lock ( )
235+ . expect ( "projects lock" )
236+ . push ( MockProject :: new ( "proj-race" , & body. name ) ) ;
237+ HttpResponse :: Conflict ( )
238+ . json ( serde_json:: json!( { "error" : "project already exists" } ) )
239+ }
240+ }
241+ }
242+
243+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
244+ async fn create_project_checked_returns_existing_project ( ) {
245+ let ( server, client) = setup_test (
246+ vec ! [ MockProject :: new( "proj-1" , "existing-project" ) ] ,
247+ CreateBehavior :: Create ,
248+ )
249+ . await ;
250+
251+ let outcome = create_project_checked ( & client, "existing-project" )
252+ . await
253+ . expect ( "reuse existing project" ) ;
254+
255+ let CreateProjectOutcome :: Existing ( project) = outcome else {
256+ panic ! ( "expected existing project outcome" ) ;
257+ } ;
258+ assert_eq ! ( project. id, "proj-1" ) ;
259+ assert_eq ! ( project. name, "existing-project" ) ;
260+
261+ server. stop ( ) . await ;
262+ }
263+
264+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
265+ async fn create_project_checked_falls_back_to_lookup_after_create_conflict ( ) {
266+ let ( server, client) = setup_test ( vec ! [ ] , CreateBehavior :: ConflictThenReveal ) . await ;
267+
268+ let outcome = create_project_checked ( & client, "race-project" )
269+ . await
270+ . expect ( "resolve project after create conflict" ) ;
271+
272+ let CreateProjectOutcome :: Existing ( project) = outcome else {
273+ panic ! ( "expected existing project outcome after create conflict" ) ;
274+ } ;
275+ assert_eq ! ( project. id, "proj-race" ) ;
276+ assert_eq ! ( project. name, "race-project" ) ;
277+
278+ server. stop ( ) . await ;
279+ }
280+
281+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 2 ) ]
282+ async fn create_project_checked_returns_created_project ( ) {
283+ let ( server, client) = setup_test ( vec ! [ ] , CreateBehavior :: Create ) . await ;
284+
285+ let outcome = create_project_checked ( & client, "new-project" )
286+ . await
287+ . expect ( "create new project" ) ;
288+
289+ let CreateProjectOutcome :: Created ( project) = outcome else {
290+ panic ! ( "expected created project outcome" ) ;
291+ } ;
292+ assert_eq ! ( project. id, "proj-created-1" ) ;
293+ assert_eq ! ( project. name, "new-project" ) ;
294+
295+ server. stop ( ) . await ;
296+ }
297+ }
0 commit comments