From 317e8e66c98b690a21785f9b08b7e8a2500a4415 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 20:30:59 +0900 Subject: [PATCH 1/4] feat: implement bl space image (GET /api/v2/space/image) --- src/api/mod.rs | 7 ++ src/api/space.rs | 4 + src/cmd/space/image.rs | 98 +++++++++++++++++++ src/cmd/space/mod.rs | 2 + src/main.rs | 13 ++- website/docs/commands.md | 17 +++- .../current/commands.md | 17 +++- 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/cmd/space/image.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index e8fcf74..12f3172 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -88,6 +88,9 @@ pub trait BacklogApi { fn get_space(&self) -> Result { unimplemented!() } + fn download_space_image(&self) -> Result<(Vec, String)> { + unimplemented!() + } fn get_rate_limit(&self) -> Result { unimplemented!() } @@ -764,6 +767,10 @@ impl BacklogApi for BacklogClient { self.get_space() } + fn download_space_image(&self) -> Result<(Vec, String)> { + self.download_space_image() + } + fn get_rate_limit(&self) -> Result { self.get_rate_limit() } diff --git a/src/api/space.rs b/src/api/space.rs index f2e1275..437f1b9 100644 --- a/src/api/space.rs +++ b/src/api/space.rs @@ -22,6 +22,10 @@ impl BacklogClient { let value = self.get("/space")?; deserialize(value) } + + pub fn download_space_image(&self) -> Result<(Vec, String)> { + self.download("/space/image") + } } #[cfg(test)] diff --git a/src/cmd/space/image.rs b/src/cmd/space/image.rs new file mode 100644 index 0000000..4640d82 --- /dev/null +++ b/src/cmd/space/image.rs @@ -0,0 +1,98 @@ +use anstream::println; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +use crate::api::{BacklogApi, BacklogClient}; + +pub struct SpaceImageArgs { + output: Option, +} + +impl SpaceImageArgs { + pub fn new(output: Option) -> Self { + Self { output } + } +} + +pub fn image(args: &SpaceImageArgs) -> Result<()> { + let client = BacklogClient::from_config()?; + image_with(args, &client) +} + +pub fn image_with(args: &SpaceImageArgs, api: &dyn BacklogApi) -> Result<()> { + let (bytes, filename) = api.download_space_image()?; + let path = args.output.clone().unwrap_or_else(|| { + let base = std::path::Path::new(&filename) + .file_name() + .unwrap_or(std::ffi::OsStr::new("space_image")); + PathBuf::from(base) + }); + std::fs::write(&path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?; + println!("Saved: {} ({} bytes)", path.display(), bytes.len()); + Ok(()) +} + +#[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_space_image(&self) -> anyhow::Result<(Vec, String)> { + self.result + .clone() + .ok_or_else(|| anyhow!("download failed")) + } + } + + fn args(output: Option) -> SpaceImageArgs { + SpaceImageArgs::new(output) + } + + #[test] + fn image_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(), "space_image.png".to_string())), + }; + assert!(image_with(&args(Some(path.clone())), &api).is_ok()); + assert_eq!(std::fs::read(&path).unwrap(), b"png-data"); + } + + #[test] + fn image_with_saves_file_to_default_filename() { + let dir = tempdir().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + + struct DirGuard(std::path::PathBuf); + impl Drop for DirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + let _guard = DirGuard(original_dir); + + let api = MockApi { + result: Some((b"png-data".to_vec(), "space_image.png".to_string())), + }; + assert!(image_with(&args(None), &api).is_ok()); + assert_eq!( + std::fs::read(dir.path().join("space_image.png")).unwrap(), + b"png-data" + ); + } + + #[test] + fn image_with_propagates_api_error() { + let api = MockApi { result: None }; + let err = image_with(&args(None), &api).unwrap_err(); + assert!(err.to_string().contains("download failed")); + } +} diff --git a/src/cmd/space/mod.rs b/src/cmd/space/mod.rs index 2b2636b..f84e0d4 100644 --- a/src/cmd/space/mod.rs +++ b/src/cmd/space/mod.rs @@ -1,11 +1,13 @@ mod activities; mod disk_usage; +mod image; mod licence; mod notification; mod show; mod update_notification; pub use activities::{SpaceActivitiesArgs, activities}; +pub use image::{SpaceImageArgs, image}; #[cfg(test)] pub(crate) fn sample_notification() -> crate::api::space_notification::SpaceNotification { diff --git a/src/main.rs b/src/main.rs index c54a65e..8823928 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,8 +75,8 @@ use cmd::rate_limit::RateLimitArgs; use cmd::resolution::ResolutionListArgs; use cmd::shared_file::{SharedFileGetArgs, SharedFileListArgs}; use cmd::space::{ - SpaceActivitiesArgs, SpaceDiskUsageArgs, SpaceLicenceArgs, SpaceNotificationArgs, - SpaceShowArgs, SpaceUpdateNotificationArgs, + SpaceActivitiesArgs, SpaceDiskUsageArgs, SpaceImageArgs, SpaceLicenceArgs, + SpaceNotificationArgs, SpaceShowArgs, SpaceUpdateNotificationArgs, }; use cmd::star::{StarAddArgs, StarDeleteArgs}; use cmd::team::{ @@ -336,6 +336,12 @@ enum SpaceCommands { #[arg(long)] json: bool, }, + /// Download the space icon image + Image { + /// Output file path (default: server-provided filename) + #[arg(long, short = 'o')] + output: Option, + }, } #[derive(Subcommand)] @@ -3695,6 +3701,9 @@ fn run() -> Result<()> { content, json || sub_json, )), + Some(SpaceCommands::Image { output }) => { + cmd::space::image(&SpaceImageArgs::new(output)) + } }, } } diff --git a/website/docs/commands.md b/website/docs/commands.md index 29a5a47..c8fda63 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -151,6 +151,21 @@ Updated: 2024-07-01T00:00:00Z Scheduled maintenance on 2024-07-01. ``` +## `bl space image` + +Download the space icon image. + +```bash +bl space image +bl space image --output my_icon.png +``` + +Example output: + +```text +Saved: space_image.png (1234 bytes) +``` + ## `bl project list` List all projects you have access to. @@ -2182,7 +2197,7 @@ The table below maps Backlog API v2 endpoints to `bl` commands. | `bl space notification` | `GET /api/v2/space/notification` | ✅ Implemented | | `bl space licence` | `GET /api/v2/space/licence` | ✅ Implemented | | `bl space update-notification` | `PUT /api/v2/space/notification` | ✅ Implemented | -| — | `GET /api/v2/space/image` | Planned | +| `bl space image` | `GET /api/v2/space/image` | ✅ Implemented | | — | `POST /api/v2/space/attachment` | Planned | ### Projects 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 820a60d..6ef4886 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -150,6 +150,21 @@ Updated: 2024-07-01T00:00:00Z メンテナンスのお知らせ ``` +## `bl space image` + +スペースのアイコン画像をダウンロードします。 + +```bash +bl space image +bl space image --output my_icon.png +``` + +出力例: + +```text +Saved: space_image.png (1234 bytes) +``` + ## `bl project list` アクセス可能なプロジェクトを一覧表示します。 @@ -2184,7 +2199,7 @@ Backlog API v2 エンドポイントと `bl` コマンドの対応表です。 | `bl space notification` | `GET /api/v2/space/notification` | ✅ 実装済み | | `bl space licence` | `GET /api/v2/space/licence` | ✅ 実装済み | | `bl space update-notification` | `PUT /api/v2/space/notification` | ✅ 実装済み | -| — | `GET /api/v2/space/image` | 計画中 | +| `bl space image` | `GET /api/v2/space/image` | ✅ 実装済み | | — | `POST /api/v2/space/attachment` | 計画中 | ### Projects From 2bacf68f992ad88767ade461c00a8bb68fb44628 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 22:16:29 +0900 Subject: [PATCH 2/4] fix: fall back to "space_image" when server filename is absent or generic Addresses review comment: add explicit check for missing/placeholder filenames ("attachment") and substitute domain-specific default --- src/cmd/space/image.rs | 43 ++++++++++++++++++++++++++++++++++++------ src/main.rs | 2 +- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/cmd/space/image.rs b/src/cmd/space/image.rs index 4640d82..17e9270 100644 --- a/src/cmd/space/image.rs +++ b/src/cmd/space/image.rs @@ -21,17 +21,27 @@ pub fn image(args: &SpaceImageArgs) -> Result<()> { pub fn image_with(args: &SpaceImageArgs, api: &dyn BacklogApi) -> Result<()> { let (bytes, filename) = api.download_space_image()?; - let path = args.output.clone().unwrap_or_else(|| { - let base = std::path::Path::new(&filename) - .file_name() - .unwrap_or(std::ffi::OsStr::new("space_image")); - PathBuf::from(base) - }); + 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 effective = if filename.is_empty() || filename == "attachment" { + "space_image" + } else { + filename + }; + let base = std::path::Path::new(effective) + .file_name() + .unwrap_or(std::ffi::OsStr::new("space_image")); + PathBuf::from(base) +} + #[cfg(test)] mod tests { use super::*; @@ -95,4 +105,25 @@ mod tests { let err = image_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("space_image.png"), + PathBuf::from("space_image.png") + ); + } + + #[test] + fn default_output_path_falls_back_for_attachment() { + assert_eq!( + default_output_path("attachment"), + PathBuf::from("space_image") + ); + } + + #[test] + fn default_output_path_falls_back_for_empty() { + assert_eq!(default_output_path(""), PathBuf::from("space_image")); + } } diff --git a/src/main.rs b/src/main.rs index 8823928..2a748a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,7 +338,7 @@ enum SpaceCommands { }, /// Download the space icon image Image { - /// Output file path (default: server-provided filename) + /// Output file path (default: server-provided filename, or "space_image" if none) #[arg(long, short = 'o')] output: Option, }, From 877c2c017dacf7e4fcab4c53e97affa85aa36d30 Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 22:18:30 +0900 Subject: [PATCH 3/4] docs: clarify bl space image binary output and --output flag behavior --- website/docs/commands.md | 2 ++ .../i18n/ja/docusaurus-plugin-content-docs/current/commands.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/website/docs/commands.md b/website/docs/commands.md index c8fda63..22f6aaf 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -155,6 +155,8 @@ Scheduled maintenance on 2024-07-01. Download the space 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 `space_image` if none is provided). + ```bash bl space image bl space image --output my_icon.png 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 6ef4886..3d51d2f 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/commands.md @@ -154,6 +154,8 @@ Updated: 2024-07-01T00:00:00Z スペースのアイコン画像をダウンロードします。 +レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(取得できない場合は `space_image`)を使ってカレントディレクトリに保存します。 + ```bash bl space image bl space image --output my_icon.png From f13d9ac966224f22b8ee065f27d21be755566e1b Mon Sep 17 00:00:00 2001 From: 23prime <23.prime.37@gmail.com> Date: Sun, 22 Mar 2026 22:21:44 +0900 Subject: [PATCH 4/4] fix: also treat attachment.* as generic placeholder in default_output_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review comment: fallback was too narrow — only exact "attachment" was handled --- src/cmd/space/image.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/cmd/space/image.rs b/src/cmd/space/image.rs index 17e9270..be81359 100644 --- a/src/cmd/space/image.rs +++ b/src/cmd/space/image.rs @@ -31,10 +31,14 @@ pub fn image_with(args: &SpaceImageArgs, api: &dyn BacklogApi) -> Result<()> { } fn default_output_path(filename: &str) -> PathBuf { - let effective = if filename.is_empty() || filename == "attachment" { + let normalized = filename.trim(); + let lower = normalized.to_ascii_lowercase(); + let is_generic_attachment = lower == "attachment" || lower.starts_with("attachment."); + + let effective = if normalized.is_empty() || is_generic_attachment { "space_image" } else { - filename + normalized }; let base = std::path::Path::new(effective) .file_name() @@ -122,6 +126,14 @@ mod tests { ); } + #[test] + fn default_output_path_falls_back_for_attachment_with_extension() { + assert_eq!( + default_output_path("attachment.png"), + PathBuf::from("space_image") + ); + } + #[test] fn default_output_path_falls_back_for_empty() { assert_eq!(default_output_path(""), PathBuf::from("space_image"));