Skip to main content

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}