use anyhow::Result;
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use crate::atlassian::confluence_api::{ConfluenceApi, ConfluenceLabel};
use crate::cli::atlassian::confirm::{guard_destructive_with_io, GuardOptions, GuardOutcome};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct LabelCommand {
#[command(subcommand)]
pub command: LabelSubcommands,
}
#[derive(Subcommand)]
pub enum LabelSubcommands {
List(ListCommand),
Add(AddCommand),
Remove(RemoveCommand),
}
impl LabelCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
LabelSubcommands::List(cmd) => cmd.execute().await,
LabelSubcommands::Add(cmd) => cmd.execute().await,
LabelSubcommands::Remove(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
pub id: String,
#[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.id, &self.output).await
}
}
async fn run_list(api: &ConfluenceApi, id: &str, output: &OutputFormat) -> Result<()> {
let labels = api.get_labels(id).await?;
display_labels(&labels, output)
}
fn display_labels(labels: &Vec<ConfluenceLabel>, output: &OutputFormat) -> Result<()> {
if output_as(labels, output)? {
return Ok(());
}
print_labels(labels);
Ok(())
}
#[derive(Parser)]
pub struct AddCommand {
pub id: String,
#[arg(long, value_delimiter = ',', required = true)]
pub labels: Vec<String>,
}
impl AddCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
run_add(&api, &self.id, &self.labels).await
}
}
async fn run_add(api: &ConfluenceApi, id: &str, labels: &[String]) -> Result<()> {
api.add_labels(id, labels).await?;
print_add_confirmation(labels.len(), id);
Ok(())
}
fn print_add_confirmation(count: usize, id: &str) {
println!("Added {count} label(s) to page {id}.");
}
#[derive(Parser)]
pub struct RemoveCommand {
pub id: String,
#[arg(long, value_delimiter = ',', required = true)]
pub labels: Vec<String>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub dry_run: bool,
}
impl RemoveCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
let mut reader = io::BufReader::new(io::stdin());
let mut writer = io::stdout();
self.execute_with_io(&api, &mut reader, &mut writer).await
}
async fn execute_with_io(
self,
api: &ConfluenceApi,
reader: &mut (dyn BufRead + Send),
writer: &mut (dyn Write + Send),
) -> Result<()> {
let joined = self.labels.join(", ");
let count = self.labels.len();
let prompt = format!(
"Remove {count} label(s) [{joined}] from page {}? [y/N] ",
self.id
);
let dry_run_message = format!(
"Would remove {count} label(s) from page {}: {joined}.",
self.id
);
let outcome = guard_destructive_with_io(
&GuardOptions {
prompt: &prompt,
dry_run_message: &dry_run_message,
force: self.force,
dry_run: self.dry_run,
},
reader,
writer,
)?;
match outcome {
GuardOutcome::Proceed => {
for label in &self.labels {
api.remove_label(&self.id, label).await?;
}
writeln!(writer, "Removed {} label(s) from page {}.", count, self.id)?;
Ok(())
}
GuardOutcome::Cancelled | GuardOutcome::DryRun => Ok(()),
}
}
}
fn print_labels(labels: &[crate::atlassian::confluence_api::ConfluenceLabel]) {
if labels.is_empty() {
println!("No labels found.");
return;
}
let name_width = labels
.iter()
.map(|l| l.name.len())
.max()
.unwrap_or(4)
.max(4);
let prefix_width = labels
.iter()
.map(|l| l.prefix.len())
.max()
.unwrap_or(6)
.max(6);
println!("{:<name_width$} {:<prefix_width$}", "NAME", "PREFIX");
println!(
"{:<name_width$} {:<prefix_width$}",
"-".repeat(name_width),
"-".repeat(prefix_width),
);
for label in labels {
println!(
"{:<name_width$} {:<prefix_width$}",
label.name, label.prefix
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_label(id: &str, name: &str, prefix: &str) -> ConfluenceLabel {
ConfluenceLabel {
id: id.to_string(),
name: name.to_string(),
prefix: prefix.to_string(),
}
}
#[test]
fn label_subcommands_list_variant() {
let cmd = LabelCommand {
command: LabelSubcommands::List(ListCommand {
id: "12345".to_string(),
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, LabelSubcommands::List(_)));
}
#[test]
fn label_subcommands_add_variant() {
let cmd = LabelCommand {
command: LabelSubcommands::Add(AddCommand {
id: "12345".to_string(),
labels: vec!["architecture".to_string(), "draft".to_string()],
}),
};
assert!(matches!(cmd.command, LabelSubcommands::Add(_)));
}
#[test]
fn label_subcommands_remove_variant() {
let cmd = LabelCommand {
command: LabelSubcommands::Remove(RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: true,
dry_run: false,
}),
};
assert!(matches!(cmd.command, LabelSubcommands::Remove(_)));
}
#[test]
fn display_labels_table() {
let labels = vec![
sample_label("1", "architecture", "global"),
sample_label("2", "draft", "global"),
];
assert!(display_labels(&labels, &OutputFormat::Table).is_ok());
}
#[test]
fn display_labels_json() {
let labels = vec![sample_label("1", "architecture", "global")];
assert!(display_labels(&labels, &OutputFormat::Json).is_ok());
}
#[test]
fn display_labels_yaml() {
let labels = vec![sample_label("1", "architecture", "global")];
assert!(display_labels(&labels, &OutputFormat::Yaml).is_ok());
}
#[test]
fn display_labels_empty_table() {
assert!(display_labels(&vec![], &OutputFormat::Table).is_ok());
}
#[test]
fn print_labels_empty() {
print_labels(&[]);
}
#[test]
fn print_labels_with_entries() {
let labels = vec![
sample_label("1", "architecture", "global"),
sample_label("2", "draft", "global"),
];
print_labels(&labels);
}
#[test]
fn print_add_confirmation_single() {
print_add_confirmation(1, "12345");
}
#[test]
fn print_add_confirmation_multiple() {
print_add_confirmation(3, "12345");
}
#[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/labels"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "1", "name": "architecture", "prefix": "global"},
{"id": "2", "name": "draft", "prefix": "global"}
]
})),
)
.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", &OutputFormat::Table).await.is_ok());
}
#[tokio::test]
async fn run_list_json_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12345/labels"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "1", "name": "architecture", "prefix": "global"}
]
})),
)
.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", &OutputFormat::Json).await.is_ok());
}
#[tokio::test]
async fn run_add_success() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"prefix": "global", "name": "arch", "id": "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_add(&api, "12345", &["arch".to_string()]).await.is_ok());
}
fn label_api(server: &wiremock::MockServer) -> ConfluenceApi {
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
ConfluenceApi::new(client)
}
#[tokio::test]
async fn remove_execute_force_calls_api() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label/draft",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&api, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Removed 1 label(s) from page 12345."));
}
#[tokio::test]
async fn remove_execute_force_calls_api_for_each_label() {
let server = wiremock::MockServer::start().await;
for label in &["draft", "old"] {
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(format!(
"/wiki/rest/api/content/12345/label/{label}"
)))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
}
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string(), "old".to_string()],
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&api, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Removed 2 label(s) from page 12345."));
}
#[tokio::test]
async fn remove_execute_dry_run_skips_api_and_lists_labels() {
let server = wiremock::MockServer::start().await;
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string(), "old".to_string()],
force: false,
dry_run: true,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&api, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Would remove 2 label(s) from page 12345: draft, old."));
assert!(!out.contains("Removed"));
}
#[tokio::test]
async fn remove_execute_prompt_yes_calls_api() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label/draft",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: false,
dry_run: false,
};
let mut input = std::io::Cursor::new(b"y\n".to_vec());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&api, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Remove 1 label(s) [draft] from page 12345?"));
assert!(out.contains("Removed 1 label(s) from page 12345."));
}
#[tokio::test]
async fn remove_execute_prompt_no_skips_api() {
let server = wiremock::MockServer::start().await;
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: false,
dry_run: false,
};
let mut input = std::io::Cursor::new(b"n\n".to_vec());
let mut output = Vec::<u8>::new();
cmd.execute_with_io(&api, &mut input, &mut output)
.await
.unwrap();
let out = String::from_utf8(output).unwrap();
assert!(out.contains("Cancelled."));
assert!(!out.contains("Removed"));
}
#[test]
fn add_command_fields() {
let cmd = AddCommand {
id: "12345".to_string(),
labels: vec!["test".to_string()],
};
assert_eq!(cmd.id, "12345");
assert_eq!(cmd.labels, vec!["test"]);
}
#[test]
fn remove_command_fields() {
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["test".to_string()],
force: false,
dry_run: false,
};
assert_eq!(cmd.id, "12345");
assert_eq!(cmd.labels, vec!["test"]);
assert!(!cmd.force);
assert!(!cmd.dry_run);
}
#[test]
fn remove_command_dry_run_field() {
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["test".to_string()],
force: false,
dry_run: true,
};
assert!(cmd.dry_run);
}
#[tokio::test]
async fn remove_execute_force_propagates_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label/draft",
))
.respond_with(wiremock::ResponseTemplate::new(403).set_body_string("Forbidden"))
.mount(&server)
.await;
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut output = Vec::<u8>::new();
let err = cmd
.execute_with_io(&api, &mut input, &mut output)
.await
.unwrap_err();
assert!(err.to_string().contains("403"));
}
#[tokio::test]
async fn remove_execute_force_propagates_writeln_error() {
use crate::test_support::failing_io::FailingWriter;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label/draft",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.mount(&server)
.await;
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: true,
dry_run: false,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut writer = FailingWriter;
let err = cmd
.execute_with_io(&api, &mut input, &mut writer)
.await
.unwrap_err();
assert!(err.to_string().contains("simulated write failure"));
}
#[tokio::test]
async fn remove_execute_dry_run_propagates_guard_error() {
use crate::test_support::failing_io::FailingWriter;
let server = wiremock::MockServer::start().await;
let api = label_api(&server);
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: false,
dry_run: true,
};
let mut input = std::io::Cursor::new(Vec::<u8>::new());
let mut writer = FailingWriter;
let err = cmd
.execute_with_io(&api, &mut input, &mut writer)
.await
.unwrap_err();
assert!(err.to_string().contains("simulated write failure"));
}
#[tokio::test]
async fn remove_execute_drives_create_client_and_calls_api() {
use crate::test_support::atlassian_env::AtlassianEnvGuard;
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("DELETE"))
.and(wiremock::matchers::path(
"/wiki/rest/api/content/12345/label/draft",
))
.respond_with(wiremock::ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "u@t.com", "tok");
let cmd = RemoveCommand {
id: "12345".to_string(),
labels: vec!["draft".to_string()],
force: true,
dry_run: false,
};
cmd.execute().await.unwrap();
}
}