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..be81359 --- /dev/null +++ b/src/cmd/space/image.rs @@ -0,0 +1,141 @@ +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(|| 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 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 { + normalized + }; + 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::*; + 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")); + } + + #[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_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")); + } +} 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..2a748a8 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, or "space_image" if none) + #[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..22f6aaf 100644 --- a/website/docs/commands.md +++ b/website/docs/commands.md @@ -151,6 +151,23 @@ Updated: 2024-07-01T00:00:00Z Scheduled maintenance on 2024-07-01. ``` +## `bl space image` + +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 +``` + +Example output: + +```text +Saved: space_image.png (1234 bytes) +``` + ## `bl project list` List all projects you have access to. @@ -2182,7 +2199,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..3d51d2f 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,23 @@ Updated: 2024-07-01T00:00:00Z メンテナンスのお知らせ ``` +## `bl space image` + +スペースのアイコン画像をダウンロードします。 + +レスポンスはバイナリデータです。`--output` / `-o` で保存先を指定してください。省略した場合は、サーバーが返すファイル名(取得できない場合は `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 +2201,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