use crate::api::models::{PullRequest, Repository};
use crate::api::BitbucketClient;
use crate::core::{AuthError, Context};
use crate::render::percent_encode;
use clap::{Args, Subcommand};
#[derive(Args, Debug)]
pub struct SearchArgs {
#[command(subcommand)]
command: SearchCommands,
}
#[derive(Subcommand, Debug)]
enum SearchCommands {
Repos(WsQuery),
Code(WsQuery),
Prs(Query),
}
#[derive(Args, Debug)]
pub struct WsQuery {
#[arg(value_name = "QUERY")]
pub query: String,
#[arg(long, value_name = "WS")]
pub workspace: Option<String>,
#[arg(long, short = 'L', default_value_t = 30)]
pub limit: usize,
}
#[derive(Args, Debug)]
pub struct Query {
#[arg(value_name = "QUERY")]
pub query: String,
#[arg(long, short = 'L', default_value_t = 30)]
pub limit: usize,
}
#[derive(serde::Deserialize)]
struct CodeResult {
file: Option<CodeFile>,
}
#[derive(serde::Deserialize)]
struct CodeFile {
#[serde(default)]
path: Option<String>,
}
pub fn run(ctx: &Context, args: SearchArgs) -> anyhow::Result<()> {
let host = ctx.host();
let Some(header) = crate::auth::header_for(ctx.config.as_ref(), &host) else {
return Err(AuthError::new(host).into());
};
let client = BitbucketClient::new(ctx.transport.clone(), Some(header));
match args.command {
SearchCommands::Repos(a) => repos(ctx, &client, &a),
SearchCommands::Code(a) => code(ctx, &client, &a),
SearchCommands::Prs(a) => prs(ctx, &client, &a),
}
}
fn workspace(ctx: &Context, explicit: &Option<String>) -> anyhow::Result<String> {
match explicit {
Some(ws) => Ok(ws.clone()),
None => Ok(ctx.base_repo()?.workspace().to_owned()),
}
}
fn repos(ctx: &Context, client: &BitbucketClient, args: &WsQuery) -> anyhow::Result<()> {
let ws = workspace(ctx, &args.workspace)?;
let pagelen = args.limit.clamp(1, 50);
let q = percent_encode(&format!("name~\"{}\"", args.query));
let path = format!("/repositories/{ws}?q={q}&pagelen={pagelen}");
let hits: Vec<Repository> = client.paginate(&path, Some(args.limit))?;
if hits.is_empty() {
ctx.io.println("No repositories match.");
return Ok(());
}
for r in hits {
let name = r.full_name.as_deref().unwrap_or_default();
let desc = r.description.as_deref().unwrap_or_default();
ctx.io.println(&format!("{name}\t{desc}"));
}
Ok(())
}
fn code(ctx: &Context, client: &BitbucketClient, args: &WsQuery) -> anyhow::Result<()> {
let ws = match &args.workspace {
Some(ws) => ws.clone(),
None => {
let repo = ctx.base_repo()?;
ctx.io.eprintln(&format!(
"note: code search is workspace-wide on Bitbucket; the repo in -R only scopes the workspace ({})",
repo.workspace()
));
repo.workspace().to_owned()
}
};
let pagelen = args.limit.clamp(1, 50);
let sq = percent_encode(&args.query);
let path = format!("/workspaces/{ws}/search/code?search_query={sq}&pagelen={pagelen}");
let hits: Vec<CodeResult> = client.paginate(&path, Some(args.limit))?;
let paths: Vec<String> = hits
.into_iter()
.filter_map(|h| h.file.and_then(|f| f.path))
.collect();
if paths.is_empty() {
ctx.io.println("No code matches.");
return Ok(());
}
for p in paths {
ctx.io.println(&p);
}
Ok(())
}
fn prs(ctx: &Context, client: &BitbucketClient, args: &Query) -> anyhow::Result<()> {
let repo = ctx.base_repo()?;
let pagelen = args.limit.clamp(1, 50);
let q = percent_encode(&format!("title~\"{}\"", args.query));
let path = format!(
"/repositories/{}/{}/pullrequests?q={q}&pagelen={pagelen}",
repo.workspace(),
repo.slug()
);
let hits: Vec<PullRequest> = client.paginate(&path, Some(args.limit))?;
if hits.is_empty() {
ctx.io.println("No pull requests match.");
return Ok(());
}
for pr in hits {
let title = pr.title.as_deref().unwrap_or_default();
ctx.io.println(&format!("#{}\t{title}", pr.id));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::api::testing::FakeTransport;
use crate::config::FileConfig;
use crate::core::{ConfigProvider, GitClient, Method, RepoId, Transport};
use crate::git::{ShellGit, StubRunner};
use super::*;
use crate::testsupport::{test_context, ScriptedPrompter};
const HOST: &str = "bitbucket.org";
fn authed_config() -> Arc<dyn ConfigProvider> {
let cfg = FileConfig::blank();
cfg.set(HOST, "auth_type", "app_password").unwrap();
cfg.set(HOST, "username", "u").unwrap();
cfg.set(HOST, "token", "t").unwrap();
Arc::new(cfg)
}
fn git() -> Arc<dyn GitClient> {
Arc::new(ShellGit::new(Arc::new(StubRunner::new())))
}
fn ctx_with(
http: Arc<FakeTransport>,
config: Arc<dyn ConfigProvider>,
) -> (Context, crate::core::TestBuffers) {
let transport: Arc<dyn Transport> = http;
let (mut ctx, bufs) = test_context(
transport,
git(),
config,
Arc::new(ScriptedPrompter::new()),
false,
);
ctx.repo_override = Some(RepoId::new("acme", "widgets"));
(ctx, bufs)
}
#[test]
fn repos_search_builds_query_and_lists() {
let h = Arc::new(FakeTransport::new());
h.stub(
"repos",
FakeTransport::rest(Method::Get, "/repositories/myws"),
FakeTransport::json(
200,
r#"{"values":[{"full_name":"myws/widget","description":"a widget"}]}"#,
),
);
let (ctx, bufs) = ctx_with(h.clone(), authed_config());
run(
&ctx,
SearchArgs {
command: SearchCommands::Repos(WsQuery {
query: "widget".to_owned(),
workspace: Some("myws".to_owned()),
limit: 30,
}),
},
)
.unwrap();
assert!(bufs.stdout_string().contains("myws/widget\ta widget"));
let reqs = h.requests.lock().unwrap();
assert!(
reqs[0].url.contains(&percent_encode("name~\"widget\"")),
"url: {}",
reqs[0].url
);
}
#[test]
fn code_search_lists_paths() {
let h = Arc::new(FakeTransport::new());
h.stub(
"code",
FakeTransport::rest(Method::Get, "/workspaces/myws/search/code"),
FakeTransport::json(
200,
r#"{"values":[{"file":{"path":"src/main.rs"}},{"file":{"path":"README.md"}}]}"#,
),
);
let (ctx, bufs) = ctx_with(h.clone(), authed_config());
run(
&ctx,
SearchArgs {
command: SearchCommands::Code(WsQuery {
query: "fn main".to_owned(),
workspace: Some("myws".to_owned()),
limit: 30,
}),
},
)
.unwrap();
let out = bufs.stdout_string();
assert!(out.contains("src/main.rs"), "out: {out}");
assert!(out.contains("README.md"), "out: {out}");
}
#[test]
fn code_search_without_workspace_warns_repo_only_scopes_workspace() {
let h = Arc::new(FakeTransport::new());
h.stub(
"code",
FakeTransport::rest(Method::Get, "/workspaces/acme/search/code"),
FakeTransport::json(200, r#"{"values":[{"file":{"path":"src/lib.rs"}}]}"#),
);
let (ctx, bufs) = ctx_with(h.clone(), authed_config());
run(
&ctx,
SearchArgs {
command: SearchCommands::Code(WsQuery {
query: "fn".to_owned(),
workspace: None,
limit: 30,
}),
},
)
.unwrap();
assert!(bufs.stdout_string().contains("src/lib.rs"));
let err = bufs.stderr_string();
assert!(err.contains("workspace-wide"), "stderr: {err}");
assert!(err.contains("(acme)"), "stderr: {err}");
}
#[test]
fn code_search_with_explicit_workspace_emits_no_note() {
let h = Arc::new(FakeTransport::new());
h.stub(
"code",
FakeTransport::rest(Method::Get, "/workspaces/myws/search/code"),
FakeTransport::json(200, r#"{"values":[{"file":{"path":"a.rs"}}]}"#),
);
let (ctx, bufs) = ctx_with(h.clone(), authed_config());
run(
&ctx,
SearchArgs {
command: SearchCommands::Code(WsQuery {
query: "fn".to_owned(),
workspace: Some("myws".to_owned()),
limit: 30,
}),
},
)
.unwrap();
assert!(bufs.stderr_string().is_empty(), "unexpected note on stderr");
}
#[test]
fn prs_search_lists() {
let h = Arc::new(FakeTransport::new());
h.stub(
"prs",
FakeTransport::rest(Method::Get, "/repositories/acme/widgets/pullrequests"),
FakeTransport::json(200, r#"{"values":[{"id":7,"title":"Fix the bug"}]}"#),
);
let (ctx, bufs) = ctx_with(h.clone(), authed_config());
run(
&ctx,
SearchArgs {
command: SearchCommands::Prs(Query {
query: "bug".to_owned(),
limit: 30,
}),
},
)
.unwrap();
assert!(bufs.stdout_string().contains("#7\tFix the bug"));
}
#[test]
fn search_not_authed_is_auth_error() {
let h = Arc::new(FakeTransport::new());
let (ctx, _bufs) = ctx_with(h.clone(), Arc::new(FileConfig::blank()));
let err = run(
&ctx,
SearchArgs {
command: SearchCommands::Prs(Query {
query: "x".to_owned(),
limit: 30,
}),
},
)
.unwrap_err();
assert!(err.downcast_ref::<AuthError>().is_some(), "got: {err}");
}
}