use std::collections::HashMap;
use std::path::Path;
use base64::Engine;
use crate::cli::AttachmentAction;
use crate::client::BugzillaClient;
use crate::error::Result;
use crate::output::resources::attachment::{
write_attachment_batch, write_attachments, AttachmentBatchResult, AttachmentDownloadResult,
BatchSummary, BugDownloadResult, DownloadedFile, TargetStatus,
};
use crate::output::result_types::{
write_result, ActionResult, DownloadResult, ResourceKind, UploadResult,
};
use crate::output::writers::Writers;
use crate::types::ApiMode;
use crate::types::Attachment;
use crate::types::OutputFormat;
use crate::types::{UpdateAttachmentParams, UploadAttachmentParams};
pub async fn execute(
action: &AttachmentAction,
server: Option<&str>,
format: OutputFormat,
api: Option<ApiMode>,
w: &mut Writers<'_>,
) -> Result<()> {
validate_action(action)?;
let client = super::shared::connect_and_configure(server, api).await?;
match action {
AttachmentAction::List { bug_id } => {
let attachments = client.get_attachments(*bug_id).await?;
write_attachments(&attachments, format, w.out);
}
AttachmentAction::Download {
ids,
bug_ids,
out,
out_dir,
} => {
if bug_ids.is_empty() && ids.len() == 1 {
download_single(&client, ids[0], out.as_deref(), format, w).await?;
} else {
let targets = BatchTargets {
ids,
bug_ids,
out_dir,
};
download_batch(&client, targets, format, w).await?;
}
}
AttachmentAction::Upload {
bug_id,
file,
summary,
content_type,
private,
is_patch,
comment,
comment_private,
flag,
} => {
let path = Path::new(file);
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(file);
let data = std::fs::read(path)?;
let summary = summary.as_deref().unwrap_or(file_name);
let ct = match (content_type.as_deref(), *is_patch) {
(Some(explicit), _) => explicit.to_string(),
(None, true) => "text/plain".to_string(),
(None, false) => guess_content_type(file_name).to_string(),
};
let flags = super::flags::parse_flags(flag)?;
let size = data.len();
let upload_params = UploadAttachmentParams {
bug_id: *bug_id,
file_name: file_name.to_string(),
summary: summary.to_string(),
content_type: ct,
data,
flags,
is_private: *private,
comment: comment.clone(),
is_patch: *is_patch,
};
let att_id = client.upload_attachment(&upload_params).await?;
if *comment_private {
flip_new_comment_private(&client, *bug_id, att_id, w).await?;
}
write_result(
&UploadResult::new(att_id, *bug_id, size),
&format!("Uploaded attachment #{att_id} to bug #{bug_id} ({size} bytes)"),
format,
w.out,
);
}
AttachmentAction::Update {
id,
summary,
file_name,
content_type,
obsolete,
is_patch,
is_private,
flag,
} => {
let flags = super::flags::parse_flags(flag)?;
let params = UpdateAttachmentParams {
summary: summary.clone(),
file_name: file_name.clone(),
content_type: content_type.clone(),
is_obsolete: *obsolete,
is_patch: *is_patch,
is_private: *is_private,
flags,
};
client.update_attachment(*id, ¶ms).await?;
write_result(
&ActionResult::updated(*id, ResourceKind::Attachment),
&format!("Updated attachment #{id}"),
format,
w.out,
);
}
}
Ok(())
}
fn validate_action(action: &AttachmentAction) -> Result<()> {
match action {
AttachmentAction::Upload {
comment_private: true,
comment: None,
..
} => Err(crate::error::BzrError::InputValidation(
"--comment-private requires --comment".into(),
)),
AttachmentAction::Download { ids, bug_ids, .. } if ids.is_empty() && bug_ids.is_empty() => {
Err(crate::error::BzrError::InputValidation(
"specify at least one attachment ID or --bug <ID>".into(),
))
}
AttachmentAction::Download {
ids, out: Some(_), ..
} if ids.len() != 1 => Err(crate::error::BzrError::InputValidation(
"--out requires exactly one attachment ID".into(),
)),
_ => Ok(()),
}
}
fn guess_content_type(filename: &str) -> &'static str {
match filename
.rsplit('.')
.next()
.map(str::to_lowercase)
.as_deref()
{
Some(
"txt" | "log" | "c" | "h" | "cpp" | "rs" | "py" | "sh" | "pl" | "rb" | "js" | "ts",
) => "text/plain",
Some("html" | "htm") => "text/html",
Some("json") => "application/json",
Some("xml") => "application/xml",
Some("pdf") => "application/pdf",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("gz" | "tgz") => "application/gzip",
Some("zip") => "application/zip",
Some("tar") => "application/x-tar",
Some("patch" | "diff") => "text/x-diff",
_ => "application/octet-stream",
}
}
async fn flip_new_comment_private(
client: &BugzillaClient,
bug_id: u64,
new_attachment_id: u64,
w: &mut Writers<'_>,
) -> Result<()> {
let comments = client
.get_comments_since(bug_id, None)
.await
.inspect_err(|e| warn_partial(new_attachment_id, e, w))?;
let Some(comment_id) = comments
.iter()
.find(|c| c.attachment_id == Some(new_attachment_id))
.map(|c| c.id)
else {
let err = crate::error::BzrError::DataIntegrity(format!(
"could not locate the new attachment-bound comment on bug #{bug_id} \
(no comment with attachment_id={new_attachment_id})",
));
warn_partial(new_attachment_id, &err, w);
return Err(err);
};
let mut map = HashMap::new();
map.insert(comment_id, true);
let params = crate::types::UpdateBugParams {
comment_is_private: map,
..Default::default()
};
client
.update_bug(bug_id, ¶ms)
.await
.inspect_err(|e| warn_partial(new_attachment_id, e, w))
}
fn warn_partial(att_id: u64, err: &crate::error::BzrError, w: &mut Writers<'_>) {
let _ = writeln!(
w.err,
"warning: attachment #{att_id} uploaded but comment privacy flip failed: {err}",
);
let _ = writeln!(
w.err,
" the comment was created public; mark it private via the Bugzilla web UI or with elevated credentials",
);
}
fn ensure_batch_complete(succeeded: usize, failed: usize) -> Result<()> {
if failed > 0 {
Err(crate::error::BzrError::BatchPartialFailure { succeeded, failed })
} else {
Ok(())
}
}
async fn download_single(
client: &BugzillaClient,
id: u64,
out: Option<&str>,
format: OutputFormat,
w: &mut Writers<'_>,
) -> Result<()> {
let (filename, data) = client.download_attachment(id).await?;
let dest = out.unwrap_or(&filename);
std::fs::write(dest, &data)?;
write_result(
&DownloadResult::new(id, dest, data.len()),
&format!(
"Downloaded attachment #{id} to {dest} ({} bytes)",
data.len(),
),
format,
w.out,
);
Ok(())
}
async fn write_one_attachment(
client: &BugzillaClient,
att: &Attachment,
out_dir: &str,
) -> Result<DownloadedFile> {
let bytes = if let Some(b64) = att.data.as_deref() {
base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| {
crate::error::BzrError::DataIntegrity(format!(
"failed to decode attachment #{}: {e}",
att.id,
))
})?
} else {
let (_, fetched) = client.download_attachment(att.id).await?;
fetched
};
let bug_subdir = Path::new(out_dir).join(att.bug_id.to_string());
std::fs::create_dir_all(&bug_subdir)?;
let dest = bug_subdir.join(format!("{}.{}", att.id, att.file_name));
let dest_str = dest.to_string_lossy().into_owned();
std::fs::write(&dest, &bytes)?;
tracing::info!(
att_id = att.id,
bug_id = att.bug_id,
path = %dest_str,
bytes = bytes.len(),
"downloaded attachment",
);
Ok(DownloadedFile {
attachment_id: att.id,
path: dest_str,
bytes: bytes.len(),
})
}
#[derive(Clone, Copy)]
struct BatchTargets<'a> {
ids: &'a [u64],
bug_ids: &'a [u64],
out_dir: &'a str,
}
async fn download_batch(
client: &BugzillaClient,
targets: BatchTargets<'_>,
format: OutputFormat,
w: &mut Writers<'_>,
) -> Result<()> {
std::fs::create_dir_all(targets.out_dir)?;
let mut bug_results: Vec<BugDownloadResult> = Vec::new();
let mut attachment_results: Vec<AttachmentDownloadResult> = Vec::new();
for &bug_id in targets.bug_ids {
bug_results.push(download_bug_target(client, bug_id, targets.out_dir).await);
}
for &att_id in targets.ids {
attachment_results.push(download_attachment_target(client, att_id, targets.out_dir).await);
}
let summary = BatchSummary::from_results(&bug_results, &attachment_results);
let result = AttachmentBatchResult {
out_dir: targets.out_dir.to_string(),
bug_results,
attachment_results,
summary,
};
write_attachment_batch(&result, format, w.out, w.err);
ensure_batch_complete(result.summary.succeeded, result.summary.failed)
}
async fn download_bug_target(
client: &BugzillaClient,
bug_id: u64,
out_dir: &str,
) -> BugDownloadResult {
let atts = match client.get_attachments(bug_id).await {
Ok(atts) => atts,
Err(e) => {
return BugDownloadResult {
bug_id,
status: TargetStatus::Error,
files: vec![],
error: Some(e.to_string()),
};
}
};
let mut files = Vec::new();
let mut first_error: Option<String> = None;
for att in &atts {
match write_one_attachment(client, att, out_dir).await {
Ok(file) => {
files.push(file);
}
Err(e) => {
if first_error.is_none() {
first_error = Some(e.to_string());
}
}
}
}
let status = if first_error.is_some() {
TargetStatus::Error
} else {
TargetStatus::Ok
};
BugDownloadResult {
bug_id,
status,
files,
error: first_error,
}
}
async fn download_attachment_target(
client: &BugzillaClient,
att_id: u64,
out_dir: &str,
) -> AttachmentDownloadResult {
let att = match client.get_attachment(att_id).await {
Ok(att) => att,
Err(e) => {
return AttachmentDownloadResult {
attachment_id: att_id,
status: TargetStatus::Error,
bug_id: None,
path: None,
bytes: None,
error: Some(e.to_string()),
};
}
};
match write_one_attachment(client, &att, out_dir).await {
Ok(file) => AttachmentDownloadResult {
attachment_id: att_id,
status: TargetStatus::Ok,
bug_id: Some(att.bug_id),
path: Some(file.path),
bytes: Some(file.bytes),
error: None,
},
Err(e) => AttachmentDownloadResult {
attachment_id: att_id,
status: TargetStatus::Error,
bug_id: Some(att.bug_id),
path: None,
bytes: None,
error: Some(e.to_string()),
},
}
}
#[cfg(test)]
#[path = "attachment_tests.rs"]
mod tests;