From 69bf2b51dff1e8afe6a4cc839344b5ebd53e9b91 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 23:19:36 +0900 Subject: [PATCH] feat: implement bl user icon (GET /api/v2/users/{userId}/icon) --- src/api/mod.rs | 7 + src/api/user.rs | 4 + src/cmd/user/icon.rs | 125 ++++++++++++++++++ src/cmd/user/mod.rs | 2 + src/main.rs | 14 +- website/docs/commands.md | 19 ++- .../current/commands.md | 19 ++- 7 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/cmd/user/icon.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index cb3d5ee..3c4c72b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -595,6 +595,9 @@ pub trait BacklogApi { fn count_user_stars(&self, _user_id: u64, _params: &[(String, String)]) -> Result { unimplemented!() } + fn download_user_icon(&self, _user_id: u64) -> Result<(Vec, String)> { + unimplemented!() + } fn add_star(&self, _params: &[(String, String)]) -> Result<()> { unimplemented!() } @@ -1390,6 +1393,10 @@ impl BacklogApi for BacklogClient { self.count_user_stars(user_id, params) } + fn download_user_icon(&self, user_id: u64) -> Result<(Vec, String)> { + self.download_user_icon(user_id) + } + fn add_star(&self, params: &[(String, String)]) -> Result<()> { self.add_star(params) } diff --git a/src/api/user.rs b/src/api/user.rs index ab771f0..cb44df4 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -146,6 +146,10 @@ impl BacklogClient { deserialize(value) } + pub fn download_user_icon(&self, user_id: u64) -> Result<(Vec, String)> { + self.download(&format!("/users/{user_id}/icon")) + } + pub fn add_star(&self, params: &[(String, String)]) -> Result<()> { self.post_form("/stars", params)?; Ok(()) diff --git a/src/cmd/user/icon.rs b/src/cmd/user/icon.rs new file mode 100644 index 0000000..9e9d898 --- /dev/null +++ b/src/cmd/user/icon.rs @@ -0,0 +1,125 @@ +use anstream::println; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct UserIconArgs { + id: u64, + output: Option, +} + +impl UserIconArgs { + pub fn new(id: u64, output: Option) -> Self { + Self { id, output } + } +} + +pub fn icon(args: &UserIconArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + icon_with(args, &client) +} + +pub fn icon_with(args: &UserIconArgs, api: &dyn BacklogApi) -> Result<()> { + let (bytes, filename) = api.download_user_icon(args.id)?; + let path = args + .output + .clone() + .unwrap_or_else(|| default_output_path(&filename)); + std::fs::write(&path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?; + println!("Saved: {} ({} bytes)", path.display(), bytes.len()); + Ok(()) +} + +fn default_output_path(filename: &str) -> PathBuf { + let normalized = filename.trim(); + let base = std::path::Path::new(normalized) + .file_name() + .unwrap_or(std::ffi::OsStr::new("")); + let base_lower = base.to_string_lossy().to_ascii_lowercase(); + let is_generic_attachment = base_lower == "attachment" || base_lower.starts_with("attachment."); + + if base.is_empty() || is_generic_attachment { + PathBuf::from("user_icon") + } else { + PathBuf::from(base) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::anyhow; + use tempfile::tempdir; + + struct MockApi { + result: Option<(Vec, String)>, + } + + impl crate::api::BacklogApi for MockApi { + fn download_user_icon(&self, _user_id: u64) -> anyhow::Result<(Vec, String)> { + self.result + .clone() + .ok_or_else(|| anyhow!("download failed")) + } + } + + fn args(output: Option) -> UserIconArgs { + UserIconArgs::new(1, output) + } + + #[test] + fn icon_with_saves_file_to_specified_path() { + let dir = tempdir().unwrap(); + let path = dir.path().join("out.png"); + let api = MockApi { + result: Some((b"png-data".to_vec(), "user_icon.png".to_string())), + }; + assert!(icon_with(&args(Some(path.clone())), &api).is_ok()); + assert_eq!(std::fs::read(&path).unwrap(), b"png-data"); + } + + #[test] + fn icon_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = icon_with(&args(None), &api).unwrap_err(); + assert!(err.to_string().contains("download failed")); + } + + #[test] + fn default_output_path_uses_server_filename() { + assert_eq!( + default_output_path("user_icon.png"), + PathBuf::from("user_icon.png") + ); + } + + #[test] + fn default_output_path_falls_back_for_attachment() { + assert_eq!( + default_output_path("attachment"), + PathBuf::from("user_icon") + ); + } + + #[test] + fn default_output_path_falls_back_for_attachment_with_extension() { + assert_eq!( + default_output_path("attachment.png"), + PathBuf::from("user_icon") + ); + } + + #[test] + fn default_output_path_falls_back_for_path_with_attachment_basename() { + assert_eq!( + default_output_path("foo/attachment.png"), + PathBuf::from("user_icon") + ); + } + + #[test] + fn default_output_path_falls_back_for_empty() { + assert_eq!(default_output_path(""), PathBuf::from("user_icon")); + } +} diff --git a/src/cmd/user/mod.rs b/src/cmd/user/mod.rs index f0c37c8..d4203a6 100644 --- a/src/cmd/user/mod.rs +++ b/src/cmd/user/mod.rs @@ -1,6 +1,7 @@ mod activities; mod add; mod delete; +mod icon; mod list; mod recently_viewed; mod recently_viewed_projects; @@ -10,6 +11,7 @@ pub mod star; mod update; pub use activities::{UserActivitiesArgs, activities}; +pub use icon::{UserIconArgs, icon}; #[cfg(test)] pub(crate) fn sample_user() -> crate::api::user::User { diff --git a/src/main.rs b/src/main.rs index 982f9a5..697b9ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,8 +84,9 @@ use cmd::team::{ }; use cmd::user::star::{UserStarCountArgs, UserStarListArgs}; use cmd::user::{ - UserActivitiesArgs, UserAddArgs, UserDeleteArgs, UserListArgs, UserRecentlyViewedArgs, - UserRecentlyViewedProjectsArgs, UserRecentlyViewedWikisArgs, UserShowArgs, UserUpdateArgs, + UserActivitiesArgs, UserAddArgs, UserDeleteArgs, UserIconArgs, UserListArgs, + UserRecentlyViewedArgs, UserRecentlyViewedProjectsArgs, UserRecentlyViewedWikisArgs, + UserShowArgs, UserUpdateArgs, }; use cmd::watch::{ WatchAddArgs, WatchCountArgs, WatchDeleteArgs, WatchListArgs, WatchReadArgs, WatchShowArgs, @@ -1685,6 +1686,14 @@ enum UserCommands { #[arg(long)] json: bool, }, + /// Download a user icon image + Icon { + /// User numeric ID + id: u64, + /// Output file path (default: server-provided filename, or "user_icon" if none) + #[arg(long, short = 'o')] + output: Option, + }, /// Show a user Show { /// User numeric ID @@ -3248,6 +3257,7 @@ fn run() -> Result<()> { }, Commands::User { action } => match action { UserCommands::List { json } => cmd::user::list(&UserListArgs::new(json)), + UserCommands::Icon { id, output } => cmd::user::icon(&UserIconArgs::new(id, output)), UserCommands::Show { id, json } => cmd::user::show(&UserShowArgs::new(id, json)), UserCommands::Activities { id, diff --git a/website/docs/commands.md b/website/docs/commands.md index a59c2d9..d5aaeca 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -1814,6 +1814,23 @@ Example output: 42 ``` +## `bl user icon` + +Download a user's icon image. + +The response is binary data. Use `--output` / `-o` to specify where the file is written. If omitted, the command saves the file in the current directory using the filename returned by the server (or `user_icon` when the filename is missing or a generic `attachment` placeholder). + +```bash +bl user icon +bl user icon --output my_icon.png +``` + +Example output: + +```text +Saved: user_icon (1234 bytes) +``` + ## `bl user list` List all users in the space. @@ -2391,7 +2408,7 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | ✅ Implemented | | `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ Implemented | | `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ Implemented | -| — | `GET /api/v2/users/{userId}/icon` | Planned | +| `bl user icon ` | `GET /api/v2/users/{userId}/icon` | ✅ Implemented | ### Notifications diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md index c4d5e74..380968e 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -1817,6 +1817,23 @@ bl user star count --since 2024-01-01 --until 2024-12-31 --json 42 ``` +## `bl user icon` + +ユーザーのアイコン画像をダウンロードします。 + +レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(ファイル名がない場合や `attachment` などの汎用プレースホルダーの場合は `user_icon`)を使ってカレントディレクトリに保存します。 + +```bash +bl user icon +bl user icon --output my_icon.png +``` + +出力例: + +```text +Saved: user_icon (1234 bytes) +``` + ## `bl user list` スペース内のユーザーを一覧表示します。 @@ -2393,7 +2410,7 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl user recently-viewed-wikis` | `GET /api/v2/users/myself/recentlyViewedWikis` | ✅ 実装済み | | `bl user star list ` | `GET /api/v2/users/{userId}/stars` | ✅ 実装済み | | `bl user star count ` | `GET /api/v2/users/{userId}/stars/count` | ✅ 実装済み | -| — | `GET /api/v2/users/{userId}/icon` | 計画中 | +| `bl user icon ` | `GET /api/v2/users/{userId}/icon` | ✅ 実装済み | ### Notifications