use anyhow::{Context, Result};
use atlassian_cli_api::pagination::{fetch_paged, BitbucketPage, PageLimits};
use atlassian_cli_api::ApiClient;
use atlassian_cli_output::{OutputFormat, OutputRenderer};
use serde::{Deserialize, Serialize};
use url::form_urlencoded;
use super::utils::{encode_path_segment, page_size, warn_if_truncated, BitbucketContext};
use crate::commands::common::{render_success, MutationResult};
#[derive(Deserialize)]
struct Workspace {
slug: String,
name: String,
#[serde(default)]
uuid: Option<String>,
#[serde(rename = "type", default)]
workspace_type: Option<String>,
}
#[derive(Deserialize)]
struct Project {
key: String,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
is_private: bool,
#[serde(default)]
uuid: Option<String>,
}
pub async fn list_workspaces(ctx: &BitbucketContext<'_>, limit: usize) -> Result<()> {
let query = form_urlencoded::Serializer::new(String::new())
.append_pair("pagelen", &page_size(limit).to_string())
.finish();
let path = format!("/2.0/workspaces?{query}");
let (workspaces, page) = fetch_paged::<BitbucketPage<Workspace>>(
&ctx.client,
&path,
PageLimits::from_cli_limit(limit),
)
.await
.context("Failed to list workspaces")?;
warn_if_truncated(&page, workspaces.len(), "workspaces");
#[derive(Serialize)]
struct Row<'a> {
slug: &'a str,
name: &'a str,
workspace_type: &'a str,
}
let rows: Vec<Row<'_>> = workspaces
.iter()
.map(|ws| Row {
slug: ws.slug.as_str(),
name: ws.name.as_str(),
workspace_type: ws.workspace_type.as_deref().unwrap_or(""),
})
.collect();
if rows.is_empty() {
tracing::info!("No workspaces found");
}
ctx.renderer
.render_list_or_empty(&rows, "No workspaces found")
}
pub async fn get_workspace(ctx: &BitbucketContext<'_>, workspace: &str) -> Result<()> {
let path = format!("/2.0/workspaces/{workspace}");
let ws: Workspace = ctx
.client
.get(&path)
.await
.with_context(|| format!("Failed to fetch workspace {workspace}"))?;
#[derive(Serialize)]
struct View<'a> {
slug: &'a str,
name: &'a str,
uuid: &'a str,
workspace_type: &'a str,
}
let view = View {
slug: ws.slug.as_str(),
name: ws.name.as_str(),
uuid: ws.uuid.as_deref().unwrap_or(""),
workspace_type: ws.workspace_type.as_deref().unwrap_or(""),
};
ctx.renderer.render(&view)
}
pub async fn list_projects(
ctx: &BitbucketContext<'_>,
workspace: &str,
limit: usize,
) -> Result<()> {
let query = form_urlencoded::Serializer::new(String::new())
.append_pair("pagelen", &page_size(limit).to_string())
.finish();
let path = format!("/2.0/workspaces/{workspace}/projects?{query}");
let (projects, page) = fetch_paged::<BitbucketPage<Project>>(
&ctx.client,
&path,
PageLimits::from_cli_limit(limit),
)
.await
.with_context(|| format!("Failed to list projects in workspace {workspace}"))?;
warn_if_truncated(&page, projects.len(), "projects");
#[derive(Serialize)]
struct Row<'a> {
key: &'a str,
name: &'a str,
description: &'a str,
visibility: &'a str,
}
let rows: Vec<Row<'_>> = projects
.iter()
.map(|proj| Row {
key: proj.key.as_str(),
name: proj.name.as_str(),
description: proj.description.as_deref().unwrap_or(""),
visibility: if proj.is_private { "private" } else { "public" },
})
.collect();
if rows.is_empty() {
tracing::info!(workspace, "No projects found");
}
ctx.renderer
.render_list_or_empty(&rows, "No projects found")
}
pub async fn get_project(
ctx: &BitbucketContext<'_>,
workspace: &str,
project_key: &str,
) -> Result<()> {
let path = format!(
"/2.0/workspaces/{workspace}/projects/{}",
encode_path_segment(project_key)?
);
let project: Project = ctx.client.get(&path).await.with_context(|| {
format!("Failed to fetch project {project_key} in workspace {workspace}")
})?;
#[derive(Serialize)]
struct View<'a> {
key: &'a str,
name: &'a str,
description: &'a str,
visibility: &'a str,
uuid: &'a str,
}
let view = View {
key: project.key.as_str(),
name: project.name.as_str(),
description: project.description.as_deref().unwrap_or(""),
visibility: if project.is_private {
"private"
} else {
"public"
},
uuid: project.uuid.as_deref().unwrap_or(""),
};
ctx.renderer.render(&view)
}
pub async fn create_project(
ctx: &BitbucketContext<'_>,
workspace: &str,
key: &str,
name: &str,
description: Option<&str>,
is_private: bool,
) -> Result<()> {
let mut payload = serde_json::json!({
"key": key,
"name": name,
"is_private": is_private
});
if let Some(desc) = description {
payload["description"] = serde_json::json!(desc);
}
let path = format!("/2.0/workspaces/{workspace}/projects");
let project: Project = ctx
.client
.post(&path, &payload)
.await
.with_context(|| format!("Failed to create project in workspace {workspace}"))?;
tracing::info!(
project_key = project.key.as_str(),
workspace,
"Project created successfully"
);
#[derive(Serialize)]
struct Created<'a> {
key: &'a str,
name: &'a str,
visibility: &'a str,
}
let created = Created {
key: project.key.as_str(),
name: project.name.as_str(),
visibility: if project.is_private {
"private"
} else {
"public"
},
};
ctx.renderer.render(&created)
}
pub async fn update_project(
ctx: &BitbucketContext<'_>,
workspace: &str,
project_key: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<()> {
let mut payload = serde_json::json!({});
if let Some(n) = name {
payload["name"] = serde_json::json!(n);
}
if let Some(d) = description {
payload["description"] = serde_json::json!(d);
}
let path = format!(
"/2.0/workspaces/{workspace}/projects/{}",
encode_path_segment(project_key)?
);
let project: Project = ctx.client.put(&path, &payload).await.with_context(|| {
format!("Failed to update project {project_key} in workspace {workspace}")
})?;
tracing::info!(
project_key = project.key.as_str(),
workspace,
"Project updated successfully"
);
#[derive(Serialize)]
struct Updated<'a> {
key: &'a str,
name: &'a str,
description: &'a str,
}
let updated = Updated {
key: project.key.as_str(),
name: project.name.as_str(),
description: project.description.as_deref().unwrap_or(""),
};
ctx.renderer.render(&updated)
}
pub async fn delete_project(
ctx: &BitbucketContext<'_>,
workspace: &str,
project_key: &str,
force: bool,
) -> Result<()> {
if !force {
use std::io::{self, Write};
print!("Are you sure you want to delete project {project_key} from {workspace}? [y/N]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
tracing::info!("Project deletion cancelled");
return Ok(());
}
}
let path = format!(
"/2.0/workspaces/{workspace}/projects/{}",
encode_path_segment(project_key)?
);
let _: serde_json::Value = ctx.client.delete(&path).await.with_context(|| {
format!("Failed to delete project {project_key} from workspace {workspace}")
})?;
tracing::info!(project_key, workspace, "Project deleted successfully");
render_success(
ctx.renderer,
&format!("✅ Project {project_key} deleted from workspace {workspace}"),
&MutationResult::with_id(
format!("Project {project_key} deleted from workspace {workspace}"),
project_key,
),
)
}
#[derive(Deserialize)]
struct BitbucketUser {
username: String,
display_name: String,
account_id: String,
uuid: String,
}
pub async fn whoami(
client: &ApiClient,
is_bearer: bool,
renderer: &OutputRenderer,
profile_name: Option<&str>,
) -> Result<()> {
if is_bearer {
#[derive(Deserialize)]
struct WorkspaceList {
#[serde(default)]
values: Vec<serde_json::Value>,
}
let data: WorkspaceList = client
.get("/2.0/workspaces")
.await
.context("Failed to fetch workspaces from Bitbucket API (Bearer auth)")?;
#[derive(Serialize)]
struct WorkspaceRef<'a> {
slug: &'a str,
name: &'a str,
}
#[derive(Serialize)]
struct BearerView<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<&'a str>,
product: &'a str,
auth_type: &'a str,
note: &'a str,
workspaces: Vec<WorkspaceRef<'a>>,
}
let view = BearerView {
profile: profile_name,
product: "Bitbucket",
auth_type: "Bearer (access token)",
note: "/2.0/user is not available for access tokens",
workspaces: data
.values
.iter()
.map(|ws| WorkspaceRef {
slug: ws["slug"].as_str().unwrap_or("?"),
name: ws["name"].as_str().unwrap_or("?"),
})
.collect(),
};
return match renderer.format() {
OutputFormat::Json | OutputFormat::Yaml => renderer.render(&view),
_ => {
if let Some(profile) = view.profile {
println!("Profile: {profile}");
println!("Product: {}", view.product);
}
println!("Auth type: {}", view.auth_type);
println!("{}.", view.note);
println!("Accessible workspaces:");
for workspace in &view.workspaces {
println!(" {} ({})", workspace.slug, workspace.name);
}
Ok(())
}
};
}
let user: BitbucketUser = client
.get("/2.0/user")
.await
.context("Failed to fetch current user from Bitbucket API")?;
#[derive(Serialize)]
struct UserView<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<&'a str>,
product: &'a str,
username: &'a str,
display_name: &'a str,
account_id: &'a str,
uuid: &'a str,
}
let view = UserView {
profile: profile_name,
product: "Bitbucket",
username: &user.username,
display_name: &user.display_name,
account_id: &user.account_id,
uuid: &user.uuid,
};
match renderer.format() {
OutputFormat::Json | OutputFormat::Yaml => renderer.render(&view),
_ => {
if let Some(profile) = view.profile {
println!("Profile: {profile}");
println!("Product: {}", view.product);
}
println!("Username: {}", view.username);
println!("Display Name: {}", view.display_name);
println!("Account ID: {}", view.account_id);
println!("UUID: {}", view.uuid);
Ok(())
}
}
}