Skip to content

Commit e0fcccb

Browse files
authored
Re-creating already existing project uses project in bt setup
`bt setup`, choosing `+ Create new project` then typing the name of an already existing project makes the setup continue and use the existing project
1 parent 66f6b55 commit e0fcccb

3 files changed

Lines changed: 363 additions & 35 deletions

File tree

src/projects/create.rs

Lines changed: 248 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,67 @@ use std::time::Duration;
22

33
use anyhow::{bail, Result};
44
use dialoguer::Input;
5+
use reqwest::StatusCode;
56

6-
use crate::http::ApiClient;
7+
use crate::http::{ApiClient, HttpError};
78
use crate::ui::{
89
is_interactive, print_command_status, with_spinner, with_spinner_visible, CommandStatus,
910
};
1011

1112
use 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

3168
pub 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

Comments
 (0)