1use crate::api::models::User;
2use crate::api::{repo_path, Client};
3use crate::error::{BbError, Result};
4use crate::output;
5use crate::repo::RepoSlug;
6use serde::Deserialize;
7
8#[derive(Debug, Deserialize)]
11struct Membership {
12 user: Option<User>,
13}
14
15#[derive(Debug, Deserialize)]
20struct RepoPermission {
21 user: Option<User>,
22}
23
24pub async fn current_user(client: &Client) -> Result<User> {
25 client.get_json("/user").await
26}
27
28async fn candidates(client: &Client, slug: &RepoSlug, extra: &[User]) -> Result<(Vec<User>, bool)> {
33 let mut pool: Vec<User> = Vec::new();
34 let mut pool_incomplete = false;
35
36 match client
40 .paginate::<Membership>(&format!(
41 "/workspaces/{}/members?pagelen=100",
42 slug.workspace
43 ))
44 .await
45 {
46 Ok(memberships) => pool.extend(memberships.into_iter().filter_map(|m| m.user)),
47 Err(BbError::Api { status: 403, .. }) | Err(BbError::Auth) => {
48 pool_incomplete = true;
49 }
50 Err(other) => return Err(other),
51 }
52
53 match client
61 .paginate::<RepoPermission>(&repo_path(slug, "/permissions-config/users?pagelen=100"))
62 .await
63 {
64 Ok(permissions) => pool.extend(permissions.into_iter().filter_map(|p| p.user)),
65 Err(BbError::Api { status: 403, .. }) | Err(BbError::Auth) | Err(BbError::NotFound) => {
66 pool_incomplete = true;
67 }
68 Err(other) => return Err(other),
69 }
70
71 let defaults: Vec<User> = client
72 .paginate(&repo_path(slug, "/default-reviewers?pagelen=100"))
73 .await?;
74 pool.extend(defaults);
75
76 for user in extra {
77 pool.push(User {
78 uuid: user.uuid.clone(),
79 account_id: user.account_id.clone(),
80 display_name: user.display_name.clone(),
81 nickname: user.nickname.clone(),
82 });
83 }
84
85 let mut seen: Vec<String> = Vec::new();
86 pool.retain(|user| match user.uuid.as_deref() {
87 Some(uuid) => {
88 let fresh = !seen.iter().any(|s| s == uuid);
89 if fresh {
90 seen.push(uuid.to_string());
91 }
92 fresh
93 }
94 None => true,
95 });
96
97 Ok((pool, pool_incomplete))
98}
99
100fn matches(user: &User, needle: &str) -> bool {
101 [user.display_name.as_deref(), user.nickname.as_deref()]
102 .into_iter()
103 .flatten()
104 .any(|field| field.to_lowercase().contains(needle))
105}
106
107fn is_exact(user: &User, needle: &str) -> bool {
108 [user.display_name.as_deref(), user.nickname.as_deref()]
109 .into_iter()
110 .flatten()
111 .any(|field| field.to_lowercase() == needle)
112}
113
114pub async fn resolve_user(
122 client: &Client,
123 slug: &RepoSlug,
124 query: &str,
125 extra: &[User],
126) -> Result<User> {
127 let query = query.trim();
128 if query.is_empty() {
129 return Err(BbError::Config("empty user name".into()));
130 }
131 if query.starts_with('{') && query.ends_with('}') {
132 return Ok(User {
133 uuid: Some(query.to_string()),
134 account_id: None,
135 display_name: None,
136 nickname: None,
137 });
138 }
139
140 let needle = query.to_lowercase();
141 let (pool, pool_incomplete) = candidates(client, slug, extra).await?;
142
143 let mut found: Vec<User> = pool.into_iter().filter(|u| matches(u, &needle)).collect();
146 if found.iter().any(|u| is_exact(u, &needle)) {
147 found.retain(|u| is_exact(u, &needle));
148 }
149
150 match found.len() {
151 1 => Ok(found.remove(0)),
152 0 => {
153 if pool_incomplete {
154 output::warn(
155 "some user lists could not be read, so the name search may be \
156 incomplete — pass a `{uuid}` to be exact",
157 );
158 }
159 Err(BbError::Config(format!(
160 "no user matching `{query}` — pass a `{{uuid}}` to be exact"
161 )))
162 }
163 _ => {
164 let names: Vec<&str> = found.iter().map(|u| u.name()).collect();
165 Err(BbError::Config(format!(
166 "`{query}` matches {} people: {} — pass a `{{uuid}}` to be exact",
167 names.len(),
168 names.join(", ")
169 )))
170 }
171 }
172}