From 307f06e258cdb1d592d40f5afe46273b426afdb1 Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Fri, 12 Dec 2025 22:36:18 -0500 Subject: [PATCH 1/5] feat: add `browse` command, `search code` functionality, and API for source tree and file content. --- Cargo.lock | 7 ++ Cargo.toml | 1 + README.md | 51 ++++++++++++++ src/api/client.rs | 155 +++++++++++++++++++++++++++++++++++++++++ src/api/models.rs | 86 +++++++++++++++++++++++ src/cli.rs | 4 ++ src/commands/browse.rs | 38 ++++++++++ src/commands/mod.rs | 2 + src/commands/repo.rs | 71 ++++++++++++++++++- src/commands/search.rs | 77 ++++++++++++++++++++ src/display/mod.rs | 2 + src/display/search.rs | 59 ++++++++++++++++ src/display/tree.rs | 42 +++++++++++ src/main.rs | 2 + 14 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 src/commands/browse.rs create mode 100644 src/commands/search.rs create mode 100644 src/display/search.rs create mode 100644 src/display/tree.rs diff --git a/Cargo.lock b/Cargo.lock index fc3fa6b..c7aea32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,7 @@ dependencies = [ "similar", "tokio", "toml_edit", + "urlencoding", ] [[package]] @@ -2156,6 +2157,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7cc0e50..3a34b4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ toml_edit = "0.23.7" similar = { version = "2.7", features = ["inline"] } dialoguer = "0.12.0" glob = "0.3.3" +urlencoding = "2.1.3" [[bin]] name = "bb" diff --git a/README.md b/README.md index b899b3e..ea367b4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ curl -fsSL https://raw.githubusercontent.com/tmaffia/bitbucket-cli/main/scripts/ - **Authentication**: Secure login using Bitbucket API Tokens (stored in system keyring). - **Pull Requests**: List, view, diff, and comment on pull requests. +- **Repository Browsing**: List files, view file contents, and open repos in browser. +- **Code Search**: Search code across your workspace. - **Configuration**: Manage multiple profiles and default settings. - **Git Integration**: Auto-detects repository and branch from current directory. @@ -81,6 +83,55 @@ bb repo list bb repo list --limit 20 ``` +List files and directories: + +```bash +# List root directory +bb repo tree + +# List specific path +bb repo tree src/commands/ + +# List on a specific branch +bb repo tree --ref main +``` + +View file contents: + +```bash +bb repo view README.md +bb repo view src/main.rs --ref develop +``` + +### Browse + +Open repository in browser: + +```bash +# Current repository +bb browse + +# Specific repository +bb browse myworkspace/myrepo +``` + +### Search + +Search code across your workspace: + +```bash +bb search code "function_name" + +# Filter by extension +bb search code "error" --extension rs + +# Filter by repository +bb search code "TODO" --repo myrepo + +# Limit results +bb search code "panic" --limit 10 +``` + ### Pull Requests List pull requests: diff --git a/src/api/client.rs b/src/api/client.rs index 04286bb..0438d44 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -382,6 +382,161 @@ impl BitbucketClient { pub async fn get_current_user(&self) -> Result { self.get("/user").await } + + /// Get source entries (files/directories) at a path in a repository + /// + /// # Arguments + /// + /// * `workspace` - The workspace ID or slug + /// * `repo` - The repository slug + /// * `path` - The path to list (empty string for root) + /// * `ref_` - Optional git ref (branch, tag, or commit). If None, uses default branch + pub async fn get_source( + &self, + workspace: &str, + repo: &str, + path: &str, + ref_: Option<&str>, + ) -> Result> { + let mut all_entries = Vec::new(); + + // Determine the ref to use + let effective_ref = match ref_ { + Some(r) => r.to_string(), + None => { + // Get the default branch from the repository + let repo_info: crate::api::models::RepositoryInfo = self + .get(&format!("/repositories/{}/{}", workspace, repo)) + .await?; + repo_info + .mainbranch + .map(|b| b.name) + .unwrap_or_else(|| "main".to_string()) + } + }; + + // Build URL with ref and optional path + let mut url = if path.is_empty() { + format!("/repositories/{}/{}/src/{}", workspace, repo, effective_ref) + } else { + format!( + "/repositories/{}/{}/src/{}/{}", + workspace, + repo, + effective_ref, + path.trim_start_matches('/') + ) + }; + + loop { + let response: crate::api::models::PaginatedResponse = + self.get(&url).await?; + + all_entries.extend(response.values); + + match response.next { + Some(next_url) => url = next_url, + None => break, + } + } + + Ok(all_entries) + } + + /// Get raw file content + /// + /// # Arguments + /// + /// * `workspace` - The workspace ID or slug + /// * `repo` - The repository slug + /// * `path` - The path to the file + /// * `ref_` - Optional git ref (branch, tag, or commit). If None, uses default branch + pub async fn get_file_content( + &self, + workspace: &str, + repo: &str, + path: &str, + ref_: Option<&str>, + ) -> Result { + // Determine the ref to use + let effective_ref = match ref_ { + Some(r) => r.to_string(), + None => { + // Get the default branch from the repository + let repo_info: crate::api::models::RepositoryInfo = self + .get(&format!("/repositories/{}/{}", workspace, repo)) + .await?; + repo_info + .mainbranch + .map(|b| b.name) + .unwrap_or_else(|| "main".to_string()) + } + }; + + let url = format!( + "/repositories/{}/{}/src/{}/{}", + workspace, + repo, + effective_ref, + path.trim_start_matches('/') + ); + let request = self.build_request(Method::GET, &url); + let response = self.send_request(request).await?; + + let text = response + .text() + .await + .context("Failed to get file content")?; + Ok(text) + } + + /// Search code across a workspace + /// + /// # Arguments + /// + /// * `workspace` - The workspace ID or slug + /// * `query` - The search query + /// * `limit` - Optional maximum number of results to return + pub async fn search_code( + &self, + workspace: &str, + query: &str, + limit: Option, + ) -> Result> { + let mut all_results = Vec::new(); + let page_len = limit.map(|l| std::cmp::min(l, 100)).unwrap_or(25); + + // URL encode the query + let encoded_query = urlencoding::encode(query); + // Request repository info in results via fields parameter + let mut url = format!( + "/workspaces/{}/search/code?search_query={}&pagelen={}&fields=%2Bvalues.file.commit.repository", + workspace, encoded_query, page_len + ); + + loop { + let response: crate::api::models::PaginatedResponse< + crate::api::models::CodeSearchResult, + > = self.get(&url).await?; + + all_results.extend(response.values); + + // Check if we've reached the limit + let limit_reached = limit.is_some_and(|max| all_results.len() >= max as usize); + + if limit_reached { + all_results.truncate(limit.unwrap() as usize); + break; + } + + match response.next { + Some(next_url) => url = next_url, + None => break, + } + } + + Ok(all_results) + } } #[cfg(test)] diff --git a/src/api/models.rs b/src/api/models.rs index 364f31b..20ce5b8 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -57,6 +57,14 @@ pub struct Repository { pub is_private: Option, } +/// Repository info response from GET /repositories/{workspace}/{repo} +#[derive(Debug, Deserialize, Serialize)] +pub struct RepositoryInfo { + pub name: String, + pub full_name: String, + pub mainbranch: Option, +} + #[derive(Debug, Deserialize, Serialize)] pub struct Links { pub html: Link, @@ -110,3 +118,81 @@ pub struct CommitStatus { pub url: String, pub description: Option, } + +// Source browsing models + +/// Entry in a directory listing from the source API +#[derive(Debug, Deserialize, Serialize)] +pub struct SourceEntry { + pub path: String, + #[serde(rename = "type")] + pub entry_type: String, + pub size: Option, + pub commit: Option, + pub links: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SourceLinks { + #[serde(rename = "self")] + pub self_link: Option, + pub html: Option, +} + +// Code search models + +/// Result from code search API +#[derive(Debug, Deserialize, Serialize)] +pub struct CodeSearchResult { + pub file: SourceFile, + #[serde(default)] + pub content_matches: Vec, + #[serde(default)] + pub path_matches: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SourceFile { + pub path: String, + #[serde(rename = "type")] + pub file_type: Option, + pub commit: Option, + pub links: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SearchCommit { + pub hash: Option, + pub repository: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SearchRepository { + pub name: Option, + pub full_name: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ContentMatch { + pub lines: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct MatchLine { + pub line: Option, + pub segments: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct MatchSegment { + pub text: String, + #[serde(rename = "match")] + pub is_match: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PathMatch { + pub text: Option, + #[serde(rename = "match")] + pub is_match: Option, +} diff --git a/src/cli.rs b/src/cli.rs index 2d97173..d6a7f7e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,4 +42,8 @@ pub enum Commands { Config(commands::config::ConfigArgs), /// Repository operations Repo(commands::repo::RepoArgs), + /// Open repository in browser + Browse(commands::browse::BrowseArgs), + /// Search code, PRs, issues, or commits + Search(commands::search::SearchArgs), } diff --git a/src/commands/browse.rs b/src/commands/browse.rs new file mode 100644 index 0000000..1941fe8 --- /dev/null +++ b/src/commands/browse.rs @@ -0,0 +1,38 @@ +use anyhow::{Context, Result}; +use clap::Args; + +use crate::context::AppContext; + +#[derive(Args)] +pub struct BrowseArgs { + /// Repository to open (format: workspace/repo). Defaults to current repository + pub repo: Option, +} + +pub async fn handle(ctx: &AppContext, args: BrowseArgs) -> Result<()> { + let (workspace, repo) = if let Some(repo_arg) = args.repo { + // Parse workspace/repo format + let parts: Vec<&str> = repo_arg.splitn(2, '/').collect(); + if parts.len() != 2 { + anyhow::bail!("Repository must be in format: workspace/repo"); + } + (parts[0].to_string(), parts[1].to_string()) + } else { + // Use context defaults + let ws = ctx.workspace.clone().context( + "No workspace configured. Provide a repository argument or set default workspace.", + )?; + let rp = ctx + .repo + .clone() + .context("No repository configured. Provide a repository argument or run from within a git repository.")?; + (ws, rp) + }; + + let url = format!("https://bitbucket.org/{}/{}", workspace, repo); + + crate::display::ui::info(&format!("Opening {} in browser...", url)); + open::that(&url).context("Failed to open browser")?; + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1be3baa..9563ca1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,6 @@ pub mod auth; +pub mod browse; pub mod config; pub mod pr; pub mod repo; +pub mod search; diff --git a/src/commands/repo.rs b/src/commands/repo.rs index c6de34e..73020bf 100644 --- a/src/commands/repo.rs +++ b/src/commands/repo.rs @@ -21,6 +21,27 @@ pub enum RepoCommands { #[arg(long, default_value = "100")] limit: u32, }, + + /// List files and directories in the repository + Tree { + /// Path to list (defaults to repository root) + #[arg(default_value = "")] + path: String, + + /// Git ref (branch, tag, or commit). Defaults to HEAD + #[arg(long, short = 'r', name = "ref")] + ref_: Option, + }, + + /// View file contents + View { + /// Path to the file to view + path: String, + + /// Git ref (branch, tag, or commit). Defaults to HEAD + #[arg(long, short = 'r', name = "ref")] + ref_: Option, + }, } pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { @@ -30,7 +51,7 @@ pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { .or_else(|| ctx.workspace.clone()) .context("No workspace configured. Please set a default workspace with 'bb config set workspace ' or provide --workspace")?; - let client = ctx.client.clone(); // Use client from context which is already initialized with auth + let client = ctx.client.clone(); ui::info(&format!("Fetching repositories for workspace '{}'...", ws)); @@ -42,6 +63,54 @@ pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { crate::display::repo::print_repo_list(&repos); } } + + RepoCommands::Tree { path, ref_ } => { + let ws = ctx + .workspace + .clone() + .context("No workspace configured. Please set a default workspace or run from within a git repository.")?; + let repo = ctx.repo.clone().context( + "No repository configured. Please run from within a git repository or use -R flag.", + )?; + + let client = ctx.client.clone(); + + let entries = client + .get_source(&ws, &repo, &path, ref_.as_deref()) + .await?; + + if ctx.json { + ui::print_json(&entries)?; + } else { + crate::display::tree::print_tree(&entries, &path); + } + } + + RepoCommands::View { path, ref_ } => { + let ws = ctx + .workspace + .clone() + .context("No workspace configured. Please set a default workspace or run from within a git repository.")?; + let repo = ctx.repo.clone().context( + "No repository configured. Please run from within a git repository or use -R flag.", + )?; + + let client = ctx.client.clone(); + + let content = client + .get_file_content(&ws, &repo, &path, ref_.as_deref()) + .await?; + + if ctx.json { + let output = serde_json::json!({ + "path": path, + "content": content + }); + ui::print_json(&output)?; + } else { + println!("{}", content); + } + } } Ok(()) } diff --git a/src/commands/search.rs b/src/commands/search.rs new file mode 100644 index 0000000..48ee2de --- /dev/null +++ b/src/commands/search.rs @@ -0,0 +1,77 @@ +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; + +use crate::context::AppContext; +use crate::display::ui; + +#[derive(Args)] +pub struct SearchArgs { + #[command(subcommand)] + pub command: SearchCommands, +} + +#[derive(Subcommand)] +pub enum SearchCommands { + /// Search code across the workspace + Code { + /// Search query + query: String, + + /// Filter by file extension (e.g., rs, py, js) + #[arg(long, short)] + extension: Option, + + /// Search within a specific repository + #[arg(long, short = 'R')] + repo: Option, + + /// Maximum number of results (default: 25) + #[arg(long, default_value = "25")] + limit: u32, + + /// Workspace to search in (defaults to configured workspace) + #[arg(long, short)] + workspace: Option, + }, +} + +pub async fn handle(ctx: &AppContext, args: SearchArgs) -> Result<()> { + match args.command { + SearchCommands::Code { + query, + extension, + repo, + limit, + workspace, + } => { + let ws = workspace + .or_else(|| ctx.workspace.clone()) + .context("No workspace configured. Please set a default workspace with 'bb config set workspace ' or provide --workspace")?; + + // Build the search query with modifiers + let mut search_query = query.clone(); + if let Some(ext) = extension { + search_query = format!("{} ext:{}", search_query, ext); + } + if let Some(repo_name) = repo { + search_query = format!("{} repo:{}", search_query, repo_name); + } + + let client = ctx.client.clone(); + + ui::info(&format!( + "Searching for '{}' in workspace '{}'...", + search_query, ws + )); + + let results = client.search_code(&ws, &search_query, Some(limit)).await?; + + if ctx.json { + ui::print_json(&results)?; + } else { + crate::display::search::print_code_search_results(&results); + } + } + } + Ok(()) +} diff --git a/src/display/mod.rs b/src/display/mod.rs index 6220089..d519e21 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -1,4 +1,6 @@ pub mod diff; pub mod pr; pub mod repo; +pub mod search; +pub mod tree; pub mod ui; diff --git a/src/display/search.rs b/src/display/search.rs new file mode 100644 index 0000000..0b7ee66 --- /dev/null +++ b/src/display/search.rs @@ -0,0 +1,59 @@ +use crate::api::models::CodeSearchResult; + +// ANSI color codes +const CYAN: &str = "\x1b[36m"; +const BLUE: &str = "\x1b[34m"; +const YELLOW_BOLD: &str = "\x1b[1;33m"; +const RESET: &str = "\x1b[0m"; + +/// Print code search results +pub fn print_code_search_results(results: &[CodeSearchResult]) { + if results.is_empty() { + println!("No results found."); + return; + } + + println!("\nFound {} result(s):\n", results.len()); + + for result in results { + // Get repo name (without workspace prefix) + let repo_name = result + .file + .commit + .as_ref() + .and_then(|c| c.repository.as_ref()) + .and_then(|r| r.name.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("unknown"); + + // Print repo name in cyan + println!("{}{}{}", CYAN, repo_name, RESET); + // Print file path in blue + println!(" {}{}{}", BLUE, result.file.path, RESET); + + let lines: Vec<_> = result + .content_matches + .iter() + .filter_map(|m| m.lines.as_ref()) + .flatten() + .filter_map(|line| { + let line_num = line.line?; + let segments = line.segments.as_ref()?; + let content: String = segments + .iter() + .map(|seg| match seg.is_match { + Some(true) => format!("{}{}{}", YELLOW_BOLD, seg.text, RESET), + _ => seg.text.clone(), + }) + .collect(); + Some((line_num, content)) + }) + .collect(); + + for (line_num, content) in lines { + println!(" {:>4}: {}", line_num, content.trim()); + } + + println!(); + } +} diff --git a/src/display/tree.rs b/src/display/tree.rs new file mode 100644 index 0000000..59f8578 --- /dev/null +++ b/src/display/tree.rs @@ -0,0 +1,42 @@ +use crate::api::models::SourceEntry; + +/// Print a tree of source entries +pub fn print_tree(entries: &[SourceEntry], path: &str) { + if entries.is_empty() { + println!( + "No files found at '{}'", + if path.is_empty() { "/" } else { path } + ); + return; + } + + let display_path = if path.is_empty() { "/" } else { path }; + println!("\n{}\n", display_path); + + // Separate directories and files, sort alphabetically + let mut dirs: Vec<_> = entries + .iter() + .filter(|e| e.entry_type == "commit_directory") + .collect(); + let mut files: Vec<_> = entries + .iter() + .filter(|e| e.entry_type == "commit_file") + .collect(); + + dirs.sort_by(|a, b| a.path.cmp(&b.path)); + files.sort_by(|a, b| a.path.cmp(&b.path)); + + // Print directories first + for entry in dirs { + let name = entry.path.rsplit('/').next().unwrap_or(&entry.path); + println!(" {}/", name); + } + + // Print files + for entry in files { + let name = entry.path.rsplit('/').next().unwrap_or(&entry.path); + println!(" {}", name); + } + + println!(); +} diff --git a/src/main.rs b/src/main.rs index b63b8cc..a822f64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,8 @@ async fn main() { Commands::Auth(args) => commands::auth::handle(&ctx, args).await, Commands::Config(args) => commands::config::handle(&ctx, args).await, Commands::Repo(args) => commands::repo::handle(&ctx, args).await, + Commands::Browse(args) => commands::browse::handle(&ctx, args).await, + Commands::Search(args) => commands::search::handle(&ctx, args).await, }; if let Err(e) = result { From 907337f209e26bfe3043b931f4192f85f7d333c4 Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Fri, 12 Dec 2025 22:46:25 -0500 Subject: [PATCH 2/5] feat: Extract common parsing and query building logic, refactor PR review command, and enhance user feedback with success messages. --- src/api/client.rs | 1 - src/commands/auth.rs | 5 +++ src/commands/browse.rs | 64 +++++++++++++++++++++++++++++++---- src/commands/pr.rs | 2 +- src/commands/pr/review.rs | 15 +++++---- src/commands/repo.rs | 14 +++----- src/commands/search.rs | 71 +++++++++++++++++++++++++++++++++------ src/display/search.rs | 16 ++++----- 8 files changed, 143 insertions(+), 45 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index 0438d44..2722290 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -19,7 +19,6 @@ impl BitbucketClient { /// # Arguments /// /// * `base_url` - The base URL for the Bitbucket API - /// * `base_url` - The base URL for the Bitbucket API /// * `auth` - Optional tuple of (username, password/token) for Basic Auth pub fn new(base_url: String, auth: Option<(String, String)>) -> Result { let client = Client::builder() diff --git a/src/commands/auth.rs b/src/commands/auth.rs index d1d90a1..d602b74 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -75,6 +75,11 @@ use messages::auth as msg; // TODO: Improve view layer of this command. use crate::context::AppContext; +/// Handle auth commands (login, logout, status) +/// +/// Note: `_ctx` is unused because auth commands create their own BitbucketClient +/// instances to verify new credentials before storing them, rather than using +/// the pre-authenticated client from context. pub async fn handle(_ctx: &AppContext, args: AuthArgs) -> Result<()> { match args.command { AuthCommands::Login => { diff --git a/src/commands/browse.rs b/src/commands/browse.rs index 1941fe8..5ed3821 100644 --- a/src/commands/browse.rs +++ b/src/commands/browse.rs @@ -9,14 +9,25 @@ pub struct BrowseArgs { pub repo: Option, } +/// Parse a repository argument in "workspace/repo" format +/// +/// # Arguments +/// * `repo_arg` - String in format "workspace/repo" +/// +/// # Returns +/// * `Ok((workspace, repo))` - Tuple of workspace and repo names +/// * `Err` - If the format is invalid +fn parse_repo_arg(repo_arg: &str) -> Result<(String, String)> { + let parts: Vec<&str> = repo_arg.splitn(2, '/').collect(); + if parts.len() != 2 { + anyhow::bail!("Repository must be in format: workspace/repo"); + } + Ok((parts[0].to_string(), parts[1].to_string())) +} + pub async fn handle(ctx: &AppContext, args: BrowseArgs) -> Result<()> { let (workspace, repo) = if let Some(repo_arg) = args.repo { - // Parse workspace/repo format - let parts: Vec<&str> = repo_arg.splitn(2, '/').collect(); - if parts.len() != 2 { - anyhow::bail!("Repository must be in format: workspace/repo"); - } - (parts[0].to_string(), parts[1].to_string()) + parse_repo_arg(&repo_arg)? } else { // Use context defaults let ws = ctx.workspace.clone().context( @@ -33,6 +44,47 @@ pub async fn handle(ctx: &AppContext, args: BrowseArgs) -> Result<()> { crate::display::ui::info(&format!("Opening {} in browser...", url)); open::that(&url).context("Failed to open browser")?; + crate::display::ui::success(&format!("Opened {}/{} in browser", workspace, repo)); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_repo_arg_valid() { + let (ws, repo) = parse_repo_arg("myworkspace/myrepo").unwrap(); + assert_eq!(ws, "myworkspace"); + assert_eq!(repo, "myrepo"); + } + + #[test] + fn test_parse_repo_arg_with_slashes_in_repo() { + // splitn(2, '/') should only split on the first slash + let (ws, repo) = parse_repo_arg("workspace/repo/with/slashes").unwrap(); + assert_eq!(ws, "workspace"); + assert_eq!(repo, "repo/with/slashes"); + } + + #[test] + fn test_parse_repo_arg_no_slash() { + let result = parse_repo_arg("just-a-repo"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("workspace/repo")); + } + + #[test] + fn test_parse_repo_arg_empty() { + let result = parse_repo_arg(""); + assert!(result.is_err()); + } + + #[test] + fn test_parse_repo_arg_only_slash() { + let (ws, repo) = parse_repo_arg("/").unwrap(); + assert_eq!(ws, ""); + assert_eq!(repo, ""); + } +} diff --git a/src/commands/pr.rs b/src/commands/pr.rs index 55c2a15..1ef55fb 100644 --- a/src/commands/pr.rs +++ b/src/commands/pr.rs @@ -228,7 +228,7 @@ pub async fn handle(ctx: &AppContext, args: PrArgs) -> Result<()> { } } PrCommands::Review(args) => { - review::pr_review(ctx, &args).await?; + review::handle(ctx, args).await?; } } Ok(()) diff --git a/src/commands/pr/review.rs b/src/commands/pr/review.rs index ef4ce8d..df63296 100644 --- a/src/commands/pr/review.rs +++ b/src/commands/pr/review.rs @@ -1,4 +1,5 @@ use crate::context::AppContext; +use crate::display::ui; use anyhow::{Context, Result}; use clap::Args; use dialoguer::{Input, Select}; @@ -25,7 +26,7 @@ pub struct ReviewArgs { pub body: Option, } -pub async fn pr_review(ctx: &AppContext, args: &ReviewArgs) -> Result<()> { +pub async fn handle(ctx: &AppContext, args: ReviewArgs) -> Result<()> { let workspace = ctx .workspace .as_ref() @@ -54,12 +55,12 @@ pub async fn pr_review(ctx: &AppContext, args: &ReviewArgs) -> Result<()> { if args.approve || args.request_changes || args.comment { if args.approve { ctx.client.approve_pr(workspace, repo, pr_id).await?; - println!("Approved pull request #{}", pr_id); + ui::success(&format!("Approved pull request #{}", pr_id)); } if args.request_changes { ctx.client.request_changes(workspace, repo, pr_id).await?; - println!("Requested changes on pull request #{}", pr_id); + ui::success(&format!("Requested changes on pull request #{}", pr_id)); } if args.comment { @@ -70,7 +71,7 @@ pub async fn pr_review(ctx: &AppContext, args: &ReviewArgs) -> Result<()> { ctx.client .post_pr_comment(workspace, repo, pr_id, &body) .await?; - println!("Commented on pull request #{}", pr_id); + ui::success(&format!("Commented on pull request #{}", pr_id)); } } else { // Interactive mode @@ -85,12 +86,12 @@ pub async fn pr_review(ctx: &AppContext, args: &ReviewArgs) -> Result<()> { 0 => { // Approve ctx.client.approve_pr(workspace, repo, pr_id).await?; - println!("Approved pull request #{}", pr_id); + ui::success(&format!("Approved pull request #{}", pr_id)); } 1 => { // Request Changes ctx.client.request_changes(workspace, repo, pr_id).await?; - println!("Requested changes on pull request #{}", pr_id); + ui::success(&format!("Requested changes on pull request #{}", pr_id)); } 2 => { // Comment @@ -98,7 +99,7 @@ pub async fn pr_review(ctx: &AppContext, args: &ReviewArgs) -> Result<()> { ctx.client .post_pr_comment(workspace, repo, pr_id, &body) .await?; - println!("Commented on pull request #{}", pr_id); + ui::success(&format!("Commented on pull request #{}", pr_id)); } _ => unreachable!(), } diff --git a/src/commands/repo.rs b/src/commands/repo.rs index 73020bf..e9b43ec 100644 --- a/src/commands/repo.rs +++ b/src/commands/repo.rs @@ -51,11 +51,9 @@ pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { .or_else(|| ctx.workspace.clone()) .context("No workspace configured. Please set a default workspace with 'bb config set workspace ' or provide --workspace")?; - let client = ctx.client.clone(); - ui::info(&format!("Fetching repositories for workspace '{}'...", ws)); - let repos = client.list_repositories(&ws, Some(limit)).await?; + let repos = ctx.client.list_repositories(&ws, Some(limit)).await?; if ctx.json { ui::print_json(&repos)?; @@ -73,9 +71,8 @@ pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { "No repository configured. Please run from within a git repository or use -R flag.", )?; - let client = ctx.client.clone(); - - let entries = client + let entries = ctx + .client .get_source(&ws, &repo, &path, ref_.as_deref()) .await?; @@ -95,9 +92,8 @@ pub async fn handle(ctx: &AppContext, args: RepoArgs) -> Result<()> { "No repository configured. Please run from within a git repository or use -R flag.", )?; - let client = ctx.client.clone(); - - let content = client + let content = ctx + .client .get_file_content(&ws, &repo, &path, ref_.as_deref()) .await?; diff --git a/src/commands/search.rs b/src/commands/search.rs index 48ee2de..278a3d8 100644 --- a/src/commands/search.rs +++ b/src/commands/search.rs @@ -35,6 +35,26 @@ pub enum SearchCommands { }, } +/// Build a search query string with optional modifiers +/// +/// # Arguments +/// * `query` - Base search query +/// * `extension` - Optional file extension filter (e.g., "rs", "py") +/// * `repo` - Optional repository filter +/// +/// # Returns +/// The formatted search query string with modifiers appended +fn build_search_query(query: &str, extension: Option<&str>, repo: Option<&str>) -> String { + let mut search_query = query.to_string(); + if let Some(ext) = extension { + search_query = format!("{} ext:{}", search_query, ext); + } + if let Some(repo_name) = repo { + search_query = format!("{} repo:{}", search_query, repo_name); + } + search_query +} + pub async fn handle(ctx: &AppContext, args: SearchArgs) -> Result<()> { match args.command { SearchCommands::Code { @@ -48,23 +68,17 @@ pub async fn handle(ctx: &AppContext, args: SearchArgs) -> Result<()> { .or_else(|| ctx.workspace.clone()) .context("No workspace configured. Please set a default workspace with 'bb config set workspace ' or provide --workspace")?; - // Build the search query with modifiers - let mut search_query = query.clone(); - if let Some(ext) = extension { - search_query = format!("{} ext:{}", search_query, ext); - } - if let Some(repo_name) = repo { - search_query = format!("{} repo:{}", search_query, repo_name); - } - - let client = ctx.client.clone(); + let search_query = build_search_query(&query, extension.as_deref(), repo.as_deref()); ui::info(&format!( "Searching for '{}' in workspace '{}'...", search_query, ws )); - let results = client.search_code(&ws, &search_query, Some(limit)).await?; + let results = ctx + .client + .search_code(&ws, &search_query, Some(limit)) + .await?; if ctx.json { ui::print_json(&results)?; @@ -75,3 +89,38 @@ pub async fn handle(ctx: &AppContext, args: SearchArgs) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_search_query_base_only() { + let result = build_search_query("foo bar", None, None); + assert_eq!(result, "foo bar"); + } + + #[test] + fn test_build_search_query_with_extension() { + let result = build_search_query("hello", Some("rs"), None); + assert_eq!(result, "hello ext:rs"); + } + + #[test] + fn test_build_search_query_with_repo() { + let result = build_search_query("hello", None, Some("myrepo")); + assert_eq!(result, "hello repo:myrepo"); + } + + #[test] + fn test_build_search_query_with_both() { + let result = build_search_query("search term", Some("py"), Some("backend")); + assert_eq!(result, "search term ext:py repo:backend"); + } + + #[test] + fn test_build_search_query_preserves_special_chars() { + let result = build_search_query("fn main()", Some("rs"), None); + assert_eq!(result, "fn main() ext:rs"); + } +} diff --git a/src/display/search.rs b/src/display/search.rs index 0b7ee66..3552407 100644 --- a/src/display/search.rs +++ b/src/display/search.rs @@ -1,15 +1,11 @@ use crate::api::models::CodeSearchResult; - -// ANSI color codes -const CYAN: &str = "\x1b[36m"; -const BLUE: &str = "\x1b[34m"; -const YELLOW_BOLD: &str = "\x1b[1;33m"; -const RESET: &str = "\x1b[0m"; +use crate::display::ui; +use crossterm::style::{Color, Stylize}; /// Print code search results pub fn print_code_search_results(results: &[CodeSearchResult]) { if results.is_empty() { - println!("No results found."); + ui::info("No results found."); return; } @@ -27,9 +23,9 @@ pub fn print_code_search_results(results: &[CodeSearchResult]) { .unwrap_or("unknown"); // Print repo name in cyan - println!("{}{}{}", CYAN, repo_name, RESET); + println!("{}", repo_name.with(Color::Cyan)); // Print file path in blue - println!(" {}{}{}", BLUE, result.file.path, RESET); + println!(" {}", result.file.path.as_str().with(Color::Blue)); let lines: Vec<_> = result .content_matches @@ -42,7 +38,7 @@ pub fn print_code_search_results(results: &[CodeSearchResult]) { let content: String = segments .iter() .map(|seg| match seg.is_match { - Some(true) => format!("{}{}{}", YELLOW_BOLD, seg.text, RESET), + Some(true) => format!("{}", seg.text.as_str().with(Color::Yellow).bold()), _ => seg.text.clone(), }) .collect(); From 1953796fa4374cc30094323179261a958974bf31 Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Fri, 12 Dec 2025 23:02:11 -0500 Subject: [PATCH 3/5] refactor: extract source URL building logic into a dedicated helper function and add tests. --- src/api/client.rs | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index 2722290..2a28d53 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -382,6 +382,23 @@ impl BitbucketClient { self.get("/user").await } + fn build_source_url(workspace: &str, repo: &str, effective_ref: &str, path: &str) -> String { + if path.is_empty() { + format!( + "/repositories/{}/{}/src/{}/", + workspace, repo, effective_ref + ) + } else { + format!( + "/repositories/{}/{}/src/{}/{}", + workspace, + repo, + effective_ref, + path.trim_start_matches('/') + ) + } + } + /// Get source entries (files/directories) at a path in a repository /// /// # Arguments @@ -415,17 +432,7 @@ impl BitbucketClient { }; // Build URL with ref and optional path - let mut url = if path.is_empty() { - format!("/repositories/{}/{}/src/{}", workspace, repo, effective_ref) - } else { - format!( - "/repositories/{}/{}/src/{}/{}", - workspace, - repo, - effective_ref, - path.trim_start_matches('/') - ) - }; + let mut url = Self::build_source_url(workspace, repo, &effective_ref, path); loop { let response: crate::api::models::PaginatedResponse = @@ -579,4 +586,16 @@ mod tests { "Authorization header should NOT be present" ); } + + #[test] + fn test_build_source_url_root() { + let url = BitbucketClient::build_source_url("ws", "repo", "main", ""); + assert_eq!(url, "/repositories/ws/repo/src/main/"); + } + + #[test] + fn test_build_source_url_path() { + let url = BitbucketClient::build_source_url("ws", "repo", "main", "foo/bar"); + assert_eq!(url, "/repositories/ws/repo/src/main/foo/bar"); + } } From b21b7db39d363500bddb01ade213198c07360e23 Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Fri, 12 Dec 2025 23:10:20 -0500 Subject: [PATCH 4/5] feat: Detect and error on Bitbucket directory listings returned as JSON. --- src/api/client.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/api/client.rs b/src/api/client.rs index 2a28d53..9b579c9 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -489,10 +489,33 @@ impl BitbucketClient { let request = self.build_request(Method::GET, &url); let response = self.send_request(request).await?; + // Check if the response is JSON, which might indicate a directory listing + // Bitbucket API returns directory listings as JSON with specialized keys + let is_json = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.contains("application/json")) + .unwrap_or(false); + let text = response .text() .await .context("Failed to get file content")?; + + if is_json { + // Try to parse as JSON and check for directory listing signatures + if let Ok(value) = serde_json::from_str::(&text) { + // Directory listings typically have "values" and "pagelen" + if value.get("values").is_some() && value.get("pagelen").is_some() { + return Err(anyhow::anyhow!( + "Path '{}' is a directory. Use 'repo tree' to list contents.", + path + )); + } + } + } + Ok(text) } From d9a1381480ce3468a71e56d83bcf5e52f98c1104 Mon Sep 17 00:00:00 2001 From: Tom Maffia Date: Fri, 12 Dec 2025 23:13:28 -0500 Subject: [PATCH 5/5] chore: bump package version to 0.4.0 --- Cargo.lock | 84 ++++++++++++++++++++++++++++++++---------------------- Cargo.toml | 2 +- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7aea32..1ed0fbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,7 +89,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bb-cli" -version = "0.3.8" +version = "0.4.0" dependencies = [ "anyhow", "clap", @@ -148,9 +148,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -289,9 +289,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -376,22 +376,23 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.10.0", "proc-macro2", "quote", + "rustc_version", "syn", ] @@ -790,9 +791,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64", "bytes", @@ -862,9 +863,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -876,9 +877,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1010,9 +1011,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libredox" @@ -1053,9 +1054,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -1077,9 +1078,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", @@ -1431,9 +1432,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" dependencies = [ "base64", "bytes", @@ -1517,6 +1518,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" @@ -1628,6 +1638,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1717,9 +1733,9 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" @@ -2007,9 +2023,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" dependencies = [ "indexmap", "toml_datetime", @@ -2050,9 +2066,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -2568,18 +2584,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 3a34b4a..786ef42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bb-cli" -version = "0.3.8" +version = "0.4.0" edition = "2024" [dependencies]