use std::fs;
use std::path::Path;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use crate::atlassian::client::JiraAttachment;
use crate::cli::atlassian::helpers::create_client;
const IMAGE_MIME_TYPES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/svg+xml",
"image/webp",
];
#[derive(Parser)]
pub struct AttachmentCommand {
#[command(subcommand)]
pub command: AttachmentSubcommands,
}
#[derive(Subcommand)]
pub enum AttachmentSubcommands {
Download(DownloadCommand),
Images(ImagesCommand),
}
impl AttachmentCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
AttachmentSubcommands::Download(cmd) => cmd.execute().await,
AttachmentSubcommands::Images(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct DownloadCommand {
pub key: String,
#[arg(long, default_value = ".")]
pub output_dir: String,
#[arg(long)]
pub filter: Option<String>,
}
impl DownloadCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let attachments = client.get_attachments(&self.key).await?;
let filtered = filter_attachments(&attachments, self.filter.as_deref());
if filtered.is_empty() {
println!("No attachments found.");
return Ok(());
}
ensure_dir(&self.output_dir)?;
for attachment in &filtered {
download_file(&client, attachment, &self.output_dir).await?;
}
println!(
"Downloaded {} file(s) to {}.",
filtered.len(),
self.output_dir
);
Ok(())
}
}
#[derive(Parser)]
pub struct ImagesCommand {
pub key: String,
#[arg(long, default_value = ".")]
pub output_dir: String,
}
impl ImagesCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let attachments = client.get_attachments(&self.key).await?;
let images = filter_images(&attachments);
if images.is_empty() {
println!("No image attachments found.");
return Ok(());
}
ensure_dir(&self.output_dir)?;
for attachment in &images {
download_file(&client, attachment, &self.output_dir).await?;
}
println!(
"Downloaded {} image(s) to {}.",
images.len(),
self.output_dir
);
Ok(())
}
}
fn filter_attachments<'a>(
attachments: &'a [JiraAttachment],
filter: Option<&str>,
) -> Vec<&'a JiraAttachment> {
match filter {
Some(pattern) => {
let pattern_lower = pattern.to_lowercase();
attachments
.iter()
.filter(|a| a.filename.to_lowercase().contains(&pattern_lower))
.collect()
}
None => attachments.iter().collect(),
}
}
fn filter_images(attachments: &[JiraAttachment]) -> Vec<&JiraAttachment> {
attachments
.iter()
.filter(|a| IMAGE_MIME_TYPES.contains(&a.mime_type.as_str()))
.collect()
}
fn ensure_dir(dir: &str) -> Result<()> {
if !Path::new(dir).exists() {
fs::create_dir_all(dir)
.with_context(|| format!("Failed to create output directory: {dir}"))?;
}
Ok(())
}
async fn download_file(
client: &crate::atlassian::client::AtlassianClient,
attachment: &JiraAttachment,
output_dir: &str,
) -> Result<()> {
eprintln!(
"Downloading {} ({})...",
attachment.filename,
format_size(attachment.size)
);
let data = client.get_bytes(&attachment.content_url).await?;
let path = Path::new(output_dir).join(&attachment.filename);
fs::write(&path, &data).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
fn format_size(size: u64) -> String {
if size < 1024 {
format!("{size} B")
} else if size < 1024 * 1024 {
format!("{:.1} KB", size as f64 / 1024.0)
} else {
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_attachment(id: &str, filename: &str, mime_type: &str, size: u64) -> JiraAttachment {
JiraAttachment {
id: id.to_string(),
filename: filename.to_string(),
mime_type: mime_type.to_string(),
size,
content_url: format!("https://org.atlassian.net/attachment/{id}"),
}
}
#[test]
fn filter_no_pattern_returns_all() {
let attachments = vec![
sample_attachment("1", "file.txt", "text/plain", 100),
sample_attachment("2", "image.png", "image/png", 200),
];
let result = filter_attachments(&attachments, None);
assert_eq!(result.len(), 2);
}
#[test]
fn filter_by_pattern() {
let attachments = vec![
sample_attachment("1", "screenshot.png", "image/png", 100),
sample_attachment("2", "report.pdf", "application/pdf", 200),
sample_attachment("3", "Screenshot_2.png", "image/png", 300),
];
let result = filter_attachments(&attachments, Some("screenshot"));
assert_eq!(result.len(), 2);
}
#[test]
fn filter_no_match() {
let attachments = vec![sample_attachment("1", "file.txt", "text/plain", 100)];
let result = filter_attachments(&attachments, Some("nonexistent"));
assert!(result.is_empty());
}
#[test]
fn filter_images_mixed() {
let attachments = vec![
sample_attachment("1", "photo.png", "image/png", 100),
sample_attachment("2", "doc.pdf", "application/pdf", 200),
sample_attachment("3", "icon.gif", "image/gif", 50),
sample_attachment("4", "page.svg", "image/svg+xml", 75),
sample_attachment("5", "hero.webp", "image/webp", 150),
sample_attachment("6", "photo.jpg", "image/jpeg", 300),
];
let result = filter_images(&attachments);
assert_eq!(result.len(), 5);
}
#[test]
fn filter_images_none() {
let attachments = vec![
sample_attachment("1", "doc.pdf", "application/pdf", 200),
sample_attachment("2", "data.json", "application/json", 100),
];
let result = filter_images(&attachments);
assert!(result.is_empty());
}
#[test]
fn format_size_bytes() {
assert_eq!(format_size(500), "500 B");
}
#[test]
fn format_size_kilobytes() {
assert_eq!(format_size(2048), "2.0 KB");
}
#[test]
fn format_size_megabytes() {
assert_eq!(format_size(5_242_880), "5.0 MB");
}
#[test]
fn format_size_zero() {
assert_eq!(format_size(0), "0 B");
}
#[test]
fn ensure_dir_creates_directory() {
let temp = tempfile::tempdir().unwrap();
let new_dir = temp.path().join("subdir");
ensure_dir(new_dir.to_str().unwrap()).unwrap();
assert!(new_dir.exists());
}
#[test]
fn ensure_dir_existing_is_ok() {
let temp = tempfile::tempdir().unwrap();
ensure_dir(temp.path().to_str().unwrap()).unwrap();
}
#[test]
fn attachment_command_download_variant() {
let cmd = AttachmentCommand {
command: AttachmentSubcommands::Download(DownloadCommand {
key: "PROJ-1".to_string(),
output_dir: ".".to_string(),
filter: None,
}),
};
assert!(matches!(cmd.command, AttachmentSubcommands::Download(_)));
}
#[test]
fn attachment_command_images_variant() {
let cmd = AttachmentCommand {
command: AttachmentSubcommands::Images(ImagesCommand {
key: "PROJ-1".to_string(),
output_dir: ".".to_string(),
}),
};
assert!(matches!(cmd.command, AttachmentSubcommands::Images(_)));
}
#[test]
fn download_command_with_filter() {
let cmd = DownloadCommand {
key: "PROJ-1".to_string(),
output_dir: "/tmp/out".to_string(),
filter: Some("screenshot".to_string()),
};
assert_eq!(cmd.filter.as_deref(), Some("screenshot"));
}
}