use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::confluence_api::{
ConfluenceApi, ConfluenceAttachment, ConfluenceAttachmentPage,
};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct AttachmentCommand {
#[command(subcommand)]
pub command: AttachmentSubcommands,
}
#[derive(Subcommand)]
pub enum AttachmentSubcommands {
Upload(UploadCommand),
List(ListCommand),
Delete(DeleteCommand),
}
impl AttachmentCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
AttachmentSubcommands::Upload(cmd) => cmd.execute().await,
AttachmentSubcommands::List(cmd) => cmd.execute().await,
AttachmentSubcommands::Delete(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct UploadCommand {
pub page_id: String,
pub file: PathBuf,
#[arg(long)]
pub filename: Option<String>,
#[arg(long)]
pub comment: Option<String>,
#[arg(long)]
pub minor_edit: bool,
}
impl UploadCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
run_upload(
&api,
&self.page_id,
&self.file,
self.filename.as_deref(),
self.comment.as_deref(),
self.minor_edit,
)
.await
}
}
async fn run_upload(
api: &ConfluenceApi,
page_id: &str,
file: &std::path::Path,
filename: Option<&str>,
comment: Option<&str>,
minor_edit: bool,
) -> Result<()> {
let attachment = api
.upload_attachment(page_id, file, filename, comment, minor_edit)
.await?;
print_upload_confirmation(&attachment, page_id);
Ok(())
}
fn print_upload_confirmation(attachment: &ConfluenceAttachment, page_id: &str) {
println!(
"Uploaded {} (id={}) to page {}.",
attachment.title, attachment.id, page_id,
);
}
#[derive(Parser)]
pub struct ListCommand {
pub page_id: String,
#[arg(long)]
pub cursor: Option<String>,
#[arg(long, default_value_t = 25)]
pub limit: u32,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
impl ListCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
run_list(
&api,
&self.page_id,
self.cursor.as_deref(),
self.limit,
&self.output,
)
.await
}
}
async fn run_list(
api: &ConfluenceApi,
page_id: &str,
cursor: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let page = api.list_attachments(page_id, cursor, limit).await?;
display_attachments(&page, output)
}
fn display_attachments(page: &ConfluenceAttachmentPage, output: &OutputFormat) -> Result<()> {
if output_as(page, output)? {
return Ok(());
}
print_attachments(page);
Ok(())
}
fn print_attachments(page: &ConfluenceAttachmentPage) {
if page.results.is_empty() {
println!("No attachments found.");
return;
}
let id_width = page
.results
.iter()
.map(|a| a.id.len())
.max()
.unwrap_or(2)
.max(2);
let title_width = page
.results
.iter()
.map(|a| a.title.len())
.max()
.unwrap_or(5)
.max(5);
let media_width = page
.results
.iter()
.map(|a| a.media_type.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(10)
.max(10);
println!(
"{:<id_width$} {:<title_width$} {:<media_width$} {:>10}",
"ID", "TITLE", "MEDIA-TYPE", "SIZE"
);
println!(
"{:<id_width$} {:<title_width$} {:<media_width$} {:>10}",
"-".repeat(id_width),
"-".repeat(title_width),
"-".repeat(media_width),
"-".repeat(10),
);
for a in &page.results {
let media = a.media_type.as_deref().unwrap_or("-");
let size = a
.file_size
.map_or_else(|| "-".to_string(), |s| s.to_string());
println!(
"{:<id_width$} {:<title_width$} {:<media_width$} {:>10}",
a.id, a.title, media, size,
);
}
if let Some(cursor) = &page.next_cursor {
println!();
println!("Next page: --cursor {cursor}");
}
}
#[derive(Parser)]
pub struct DeleteCommand {
pub attachment_id: String,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub purge: bool,
}
impl DeleteCommand {
pub async fn execute(self) -> Result<()> {
let confirmed = self.force || self.prompt(&mut io::stdin().lock())?;
self.run_delete(confirmed).await
}
fn prompt(&self, reader: &mut dyn BufRead) -> Result<bool> {
let prompt = format_delete_prompt(&self.attachment_id, self.purge);
confirm_with_reader(&prompt, reader)
}
async fn run_delete(self, confirmed: bool) -> Result<()> {
if !confirmed {
println!("Cancelled.");
return Ok(());
}
let (client, instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
api.delete_attachment(&self.attachment_id, self.purge)
.await?;
println!(
"Deleted attachment {} from {}.",
self.attachment_id, instance_url
);
Ok(())
}
}
fn format_delete_prompt(attachment_id: &str, purge: bool) -> String {
if purge {
format!("Permanently purge attachment {attachment_id}? [y/N] ")
} else {
format!("Delete attachment {attachment_id}? [y/N] ")
}
}
fn confirm_with_reader(prompt: &str, reader: &mut dyn BufRead) -> Result<bool> {
print!("{prompt}");
io::stdout().flush()?;
let mut answer = String::new();
reader.read_line(&mut answer)?;
Ok(answer.trim().eq_ignore_ascii_case("y"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::await_holding_lock)]
mod tests {
use super::*;
use std::io::Cursor;
fn sample_attachment(id: &str, title: &str) -> ConfluenceAttachment {
ConfluenceAttachment {
id: id.to_string(),
title: title.to_string(),
media_type: Some("text/plain".to_string()),
file_size: Some(42),
download_url: Some("/dl".to_string()),
version: Some(1),
page_id: Some("12345".to_string()),
file_id: Some("f-1".to_string()),
}
}
fn sample_page(
items: Vec<ConfluenceAttachment>,
cursor: Option<&str>,
) -> ConfluenceAttachmentPage {
ConfluenceAttachmentPage {
results: items,
next_cursor: cursor.map(str::to_string),
}
}
#[test]
fn attachment_subcommands_upload_variant() {
let cmd = AttachmentCommand {
command: AttachmentSubcommands::Upload(UploadCommand {
page_id: "12345".to_string(),
file: PathBuf::from("/tmp/x"),
filename: None,
comment: None,
minor_edit: false,
}),
};
assert!(matches!(cmd.command, AttachmentSubcommands::Upload(_)));
}
#[test]
fn attachment_subcommands_list_variant() {
let cmd = AttachmentCommand {
command: AttachmentSubcommands::List(ListCommand {
page_id: "12345".to_string(),
cursor: None,
limit: 25,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, AttachmentSubcommands::List(_)));
}
#[test]
fn attachment_subcommands_delete_variant() {
let cmd = AttachmentCommand {
command: AttachmentSubcommands::Delete(DeleteCommand {
attachment_id: "att-1".to_string(),
force: true,
purge: false,
}),
};
assert!(matches!(cmd.command, AttachmentSubcommands::Delete(_)));
}
#[test]
fn display_attachments_table() {
let page = sample_page(vec![sample_attachment("a", "x.txt")], None);
assert!(display_attachments(&page, &OutputFormat::Table).is_ok());
}
#[test]
fn display_attachments_json() {
let page = sample_page(vec![sample_attachment("a", "x.txt")], None);
assert!(display_attachments(&page, &OutputFormat::Json).is_ok());
}
#[test]
fn display_attachments_yaml() {
let page = sample_page(vec![sample_attachment("a", "x.txt")], None);
assert!(display_attachments(&page, &OutputFormat::Yaml).is_ok());
}
#[test]
fn display_attachments_empty_table() {
let page = sample_page(vec![], None);
assert!(display_attachments(&page, &OutputFormat::Table).is_ok());
}
#[test]
fn print_attachments_with_cursor() {
let page = sample_page(vec![sample_attachment("a", "x.txt")], Some("NEXT"));
print_attachments(&page);
}
#[test]
fn format_delete_prompt_default() {
assert_eq!(
format_delete_prompt("att-1", false),
"Delete attachment att-1? [y/N] "
);
}
#[test]
fn format_delete_prompt_purge() {
assert_eq!(
format_delete_prompt("att-1", true),
"Permanently purge attachment att-1? [y/N] "
);
}
#[test]
fn confirm_yes_lowercase() {
let mut input = Cursor::new(b"y\n");
assert!(confirm_with_reader("Delete? ", &mut input).unwrap());
}
#[test]
fn confirm_no() {
let mut input = Cursor::new(b"n\n");
assert!(!confirm_with_reader("Delete? ", &mut input).unwrap());
}
#[test]
fn confirm_empty_is_no() {
let mut input = Cursor::new(b"\n");
assert!(!confirm_with_reader("Delete? ", &mut input).unwrap());
}
#[tokio::test]
async fn run_upload_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "att-1", "title": "hello.txt"}]
})),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hello.txt");
tokio::fs::write(&path, b"hi").await.unwrap();
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
let api = ConfluenceApi::new(client);
assert!(run_upload(&api, "12345", &path, None, None, false)
.await
.is_ok());
}
#[tokio::test]
async fn run_list_table_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "a", "title": "x.txt", "mediaType": "text/plain", "fileSize": 1}
]
})),
)
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
let api = ConfluenceApi::new(client);
assert!(run_list(&api, "12345", None, 25, &OutputFormat::Table)
.await
.is_ok());
}
fn set_atlassian_env(uri: &str) {
std::env::set_var(crate::atlassian::auth::ATLASSIAN_INSTANCE_URL, uri);
std::env::set_var(crate::atlassian::auth::ATLASSIAN_EMAIL, "user@test.com");
std::env::set_var(crate::atlassian::auth::ATLASSIAN_API_TOKEN, "t");
}
fn clear_atlassian_env() {
std::env::remove_var(crate::atlassian::auth::ATLASSIAN_INSTANCE_URL);
std::env::remove_var(crate::atlassian::auth::ATLASSIAN_EMAIL);
std::env::remove_var(crate::atlassian::auth::ATLASSIAN_API_TOKEN);
}
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[tokio::test(flavor = "current_thread")]
async fn upload_command_execute_runs_through_dispatch() {
let _lock = ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "att-1", "title": "x.txt"}]
})),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("x.txt");
tokio::fs::write(&path, b"hi").await.unwrap();
set_atlassian_env(&server.uri());
let cmd = AttachmentCommand {
command: AttachmentSubcommands::Upload(UploadCommand {
page_id: "12345".to_string(),
file: path,
filename: None,
comment: Some("v1".to_string()),
minor_edit: true,
}),
};
let result = cmd.execute().await;
clear_atlassian_env();
assert!(result.is_ok(), "{result:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn list_command_execute_runs_through_dispatch() {
let _lock = ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/wiki/api/v2/pages/12345/attachments",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": []
})),
)
.mount(&server)
.await;
set_atlassian_env(&server.uri());
let cmd = AttachmentCommand {
command: AttachmentSubcommands::List(ListCommand {
page_id: "12345".to_string(),
cursor: Some("opaque".to_string()),
limit: 5,
output: OutputFormat::Json,
}),
};
let result = cmd.execute().await;
clear_atlassian_env();
assert!(result.is_ok(), "{result:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn delete_command_execute_force_runs_through_dispatch() {
let _lock = ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/attachments/att-1"))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
set_atlassian_env(&server.uri());
let cmd = AttachmentCommand {
command: AttachmentSubcommands::Delete(DeleteCommand {
attachment_id: "att-1".to_string(),
force: true,
purge: true,
}),
};
let result = cmd.execute().await;
clear_atlassian_env();
assert!(result.is_ok(), "{result:?}");
}
#[test]
fn delete_command_prompt_yes_returns_true() {
let cmd = DeleteCommand {
attachment_id: "att-1".to_string(),
force: false,
purge: false,
};
let mut input = Cursor::new(b"y\n");
assert!(cmd.prompt(&mut input).unwrap());
}
#[test]
fn delete_command_prompt_no_returns_false() {
let cmd = DeleteCommand {
attachment_id: "att-1".to_string(),
force: false,
purge: true,
};
let mut input = Cursor::new(b"n\n");
assert!(!cmd.prompt(&mut input).unwrap());
}
#[tokio::test(flavor = "current_thread")]
async fn delete_command_run_delete_unconfirmed_skips_api() {
let _lock = ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = wiremock::MockServer::start().await;
set_atlassian_env(&server.uri());
let cmd = DeleteCommand {
attachment_id: "att-1".to_string(),
force: false,
purge: false,
};
let result = cmd.run_delete(false).await;
clear_atlassian_env();
assert!(result.is_ok(), "{result:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn delete_command_execute_propagates_api_error() {
let _lock = ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path("/wiki/api/v2/attachments/missing"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
set_atlassian_env(&server.uri());
let cmd = DeleteCommand {
attachment_id: "missing".to_string(),
force: true,
purge: false,
};
let result = cmd.execute().await;
clear_atlassian_env();
let err = result.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[test]
fn confirm_yes_uppercase() {
let mut input = Cursor::new(b"Y\n");
assert!(confirm_with_reader("Delete? ", &mut input).unwrap());
}
#[test]
fn confirm_random_text_is_no() {
let mut input = Cursor::new(b"maybe\n");
assert!(!confirm_with_reader("Delete? ", &mut input).unwrap());
}
#[test]
fn print_upload_confirmation_prints() {
let attachment = sample_attachment("att-1", "x.txt");
print_upload_confirmation(&attachment, "12345");
}
#[test]
fn print_attachments_without_cursor() {
let page = sample_page(vec![sample_attachment("a", "x.txt")], None);
print_attachments(&page);
}
}