bb_cli/workspace.rs
1//! Which Bitbucket workspace a command acts on.
2//!
3//! There is no api call left that discovers a user's workspaces —
4//! `GET /workspaces`, `GET /user/permissions/workspaces` and
5//! `GET /user/permissions/repositories` were all removed by Atlassian under
6//! CHANGE-2770 and now return 410 — so this resolves entirely from local input.
7
8use crate::api::models::Project;
9use crate::api::{workspace_path, Client};
10use crate::credentials;
11use crate::error::{BbError, Result};
12use crate::output::Format;
13use crate::repo;
14
15/// Splits a comma-separated `--workspace`/`BB_WORKSPACE` value into slugs:
16/// trims whitespace, drops empty segments, and deduplicates while preserving
17/// order.
18pub fn parse_list(raw: &str) -> Vec<String> {
19 let mut out = Vec::new();
20 for part in raw.split(',') {
21 let slug = part.trim();
22 if slug.is_empty() {
23 continue;
24 }
25 if !out.iter().any(|s: &String| s == slug) {
26 out.push(slug.to_string());
27 }
28 }
29 out
30}
31
32/// The workspaces to act on, in precedence order:
33///
34/// 1. `--workspace` (comma-separated).
35/// 2. `BB_WORKSPACE` (same syntax).
36/// 3. The workspace of the git remote in the current checkout, tried rather
37/// than required.
38/// 4. None of the above: a config error naming both `--workspace` and
39/// `BB_WORKSPACE`, rather than silently acting on nothing.
40pub fn resolve_list(explicit: Option<&str>) -> Result<Vec<String>> {
41 if let Some(raw) = explicit {
42 let slugs = parse_list(raw);
43 if !slugs.is_empty() {
44 return Ok(slugs);
45 }
46 }
47 if let Ok(raw) = std::env::var("BB_WORKSPACE") {
48 let slugs = parse_list(&raw);
49 if !slugs.is_empty() {
50 return Ok(slugs);
51 }
52 }
53 if let Ok(slug) = repo::resolve(None) {
54 return Ok(vec![slug.workspace]);
55 }
56 Err(BbError::Config(
57 "no workspace — pass --workspace, set BB_WORKSPACE, or run inside a bitbucket checkout"
58 .into(),
59 ))
60}
61
62/// The single workspace to act on. Commands that operate on exactly one
63/// workspace take the first slug of `resolve_list`, so `--workspace a,b` is a
64/// harmless superset rather than a second syntax to learn.
65///
66/// No empty-list guard here: `resolve_list` only ever returns a non-empty
67/// `Vec` or an `Err`, never `Ok(vec![])`, so there is nothing for one to
68/// catch. Do not "restore" it.
69pub fn resolve_one(explicit: Option<&str>) -> Result<String> {
70 let mut slugs = resolve_list(explicit)?;
71 Ok(slugs.remove(0))
72}
73
74/// The per-command context for workspace-scoped commands.
75///
76/// Deliberately a separate type from `Ctx` rather than `Ctx` with an
77/// `Option<RepoSlug>`: a repository that does not exist yet has no slug, and
78/// every existing consumer of `Ctx` would otherwise have to handle a `None`
79/// that cannot occur for it.
80pub struct WorkspaceCtx {
81 pub client: Client,
82 pub workspace: String,
83 pub format: Format,
84}
85
86impl WorkspaceCtx {
87 pub fn new(workspace: Option<&str>, format: Format) -> Result<Self> {
88 let creds = credentials::load()?;
89 let workspace = resolve_one(workspace)?;
90 let client = Client::from_env(creds)?;
91 Ok(Self {
92 client,
93 workspace,
94 format,
95 })
96 }
97
98 /// `/repositories/{workspace}{suffix}`, percent-encoded exactly once.
99 pub fn repos_path(&self, suffix: &str) -> String {
100 crate::api::workspace_repos_path(&self.workspace, suffix)
101 }
102
103 /// `/workspaces/{workspace}/projects{suffix}`
104 pub fn projects_path(&self, suffix: &str) -> String {
105 workspace_path(&self.workspace, &format!("/projects{suffix}"))
106 }
107}
108
109/// Every project in the workspace the token can see.
110///
111/// One fetch serves both `bb project list` and `bb repo create`'s picker, so
112/// the endpoint is written and tested once.
113pub async fn projects(ctx: &WorkspaceCtx) -> Result<Vec<Project>> {
114 ctx.client
115 .paginate(&ctx.projects_path("?pagelen=100"))
116 .await
117}
118
119#[cfg(test)]
120#[allow(clippy::unwrap_used)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn splits_trims_and_dedupes() {
126 assert_eq!(parse_list(" acme , , acme ,other"), vec!["acme", "other"]);
127 }
128
129 #[test]
130 fn empty_input_yields_no_slugs() {
131 assert!(parse_list(" , ").is_empty());
132 }
133
134 #[test]
135 fn resolve_one_prefers_the_explicit_value() {
136 // An explicit value must win without consulting the environment or git,
137 // which is what makes the resolver testable at all.
138 assert_eq!(resolve_one(Some("acme")).unwrap(), "acme");
139 }
140
141 #[test]
142 fn resolve_one_takes_the_first_of_a_list() {
143 assert_eq!(resolve_one(Some("first,second")).unwrap(), "first");
144 }
145
146 #[test]
147 fn resolve_one_rejects_a_blank_explicit_value_by_falling_through() {
148 // A `--workspace ""` must not resolve to an empty slug, which would
149 // build the url `/repositories/`. Falling through is correct; what must
150 // never happen is `Ok("")`.
151 assert_ne!(resolve_one(Some(" ")).ok().as_deref(), Some(""));
152 }
153}