use anyhow::Result;
use clap::{Parser, Subcommand};
use crate::atlassian::confluence_api::{ConfluenceApi, ConfluenceSpacePage, PageSummaryPage};
use crate::cli::atlassian::format::{output_as, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct SpaceCommand {
#[command(subcommand)]
pub command: SpaceSubcommands,
}
#[derive(Subcommand)]
pub enum SpaceSubcommands {
List(ListCommand),
Pages(PagesCommand),
}
impl SpaceCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
SpaceSubcommands::List(cmd) => cmd.execute().await,
SpaceSubcommands::Pages(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long, value_delimiter = ',')]
pub keys: Vec<String>,
#[arg(long)]
pub r#type: Option<String>,
#[arg(long)]
pub status: Option<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);
let keys_refs: Vec<&str> = self.keys.iter().map(String::as_str).collect();
run_list(
&api,
&keys_refs,
self.r#type.as_deref(),
self.status.as_deref(),
self.cursor.as_deref(),
self.limit,
&self.output,
)
.await
}
}
async fn run_list(
api: &ConfluenceApi,
keys: &[&str],
type_: Option<&str>,
status: Option<&str>,
cursor: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let page = api.list_spaces(keys, type_, status, cursor, limit).await?;
display_spaces(&page, output)
}
fn display_spaces(page: &ConfluenceSpacePage, output: &OutputFormat) -> Result<()> {
if output_as(page, output)? {
return Ok(());
}
print_spaces(page);
Ok(())
}
fn print_spaces(page: &ConfluenceSpacePage) {
if page.results.is_empty() {
println!("No spaces found.");
return;
}
let id_width = page
.results
.iter()
.map(|s| s.id.len())
.max()
.unwrap_or(2)
.max(2);
let key_width = page
.results
.iter()
.map(|s| s.key.len())
.max()
.unwrap_or(3)
.max(3);
let name_width = page
.results
.iter()
.map(|s| s.name.len())
.max()
.unwrap_or(4)
.max(4);
let type_width = page
.results
.iter()
.map(|s| s.type_.len())
.max()
.unwrap_or(4)
.max(4);
let status_width = page
.results
.iter()
.map(|s| s.status.len())
.max()
.unwrap_or(6)
.max(6);
println!(
"{:<id_width$} {:<key_width$} {:<name_width$} {:<type_width$} {:<status_width$} HOMEPAGE",
"ID", "KEY", "NAME", "TYPE", "STATUS"
);
println!(
"{:<id_width$} {:<key_width$} {:<name_width$} {:<type_width$} {:<status_width$} {:<8}",
"-".repeat(id_width),
"-".repeat(key_width),
"-".repeat(name_width),
"-".repeat(type_width),
"-".repeat(status_width),
"-".repeat(8),
);
for s in &page.results {
let homepage = s.homepage_id.as_deref().unwrap_or("-");
println!(
"{:<id_width$} {:<key_width$} {:<name_width$} {:<type_width$} {:<status_width$} {homepage}",
s.id, s.key, s.name, s.type_, s.status,
);
}
if let Some(cursor) = &page.next_cursor {
println!();
println!("Next page: --cursor {cursor}");
}
}
#[derive(Parser)]
pub struct PagesCommand {
pub key: String,
#[arg(long)]
pub status: Option<String>,
#[arg(long, allow_hyphen_values = true)]
pub sort: Option<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 PagesCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
run_pages(
&api,
&self.key,
self.status.as_deref(),
self.sort.as_deref(),
self.cursor.as_deref(),
self.limit,
&self.output,
)
.await
}
}
async fn run_pages(
api: &ConfluenceApi,
key: &str,
status: Option<&str>,
sort: Option<&str>,
cursor: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let space_id = api.resolve_space_id(key).await?;
let page = api
.list_space_pages(&space_id, status, sort, cursor, limit)
.await?;
display_pages(&page, output)
}
fn display_pages(page: &PageSummaryPage, output: &OutputFormat) -> Result<()> {
if output_as(page, output)? {
return Ok(());
}
print_pages(page);
Ok(())
}
fn print_pages(page: &PageSummaryPage) {
if page.results.is_empty() {
println!("No pages found.");
return;
}
let id_width = page
.results
.iter()
.map(|p| p.id.len())
.max()
.unwrap_or(2)
.max(2);
let status_width = page
.results
.iter()
.map(|p| p.status.len())
.max()
.unwrap_or(6)
.max(6);
let parent_width = page
.results
.iter()
.map(|p| p.parent_id.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(6)
.max(6);
let author_width = page
.results
.iter()
.map(|p| p.author_id.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(6)
.max(6);
let created_width = page
.results
.iter()
.map(|p| p.created_at.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(7)
.max(7);
println!(
"{:<id_width$} {:<status_width$} {:<parent_width$} {:<author_width$} {:<created_width$} TITLE",
"ID", "STATUS", "PARENT", "AUTHOR", "CREATED",
);
println!(
"{:<id_width$} {:<status_width$} {:<parent_width$} {:<author_width$} {:<created_width$} {:<5}",
"-".repeat(id_width),
"-".repeat(status_width),
"-".repeat(parent_width),
"-".repeat(author_width),
"-".repeat(created_width),
"-".repeat(5),
);
for p in &page.results {
let parent = p.parent_id.as_deref().unwrap_or("-");
let author = p.author_id.as_deref().unwrap_or("-");
let created = p.created_at.as_deref().unwrap_or("-");
println!(
"{:<id_width$} {:<status_width$} {parent:<parent_width$} {author:<author_width$} {created:<created_width$} {}",
p.id, p.status, p.title,
);
}
if let Some(cursor) = &page.next_cursor {
println!();
println!("Next page: --cursor {cursor}");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::await_holding_lock)]
mod tests {
use super::*;
use crate::atlassian::confluence_api::ConfluenceSpace;
fn sample_space(id: &str, key: &str, archived: bool) -> ConfluenceSpace {
ConfluenceSpace {
id: id.to_string(),
key: key.to_string(),
name: format!("{key} space"),
type_: "global".to_string(),
status: if archived { "archived" } else { "current" }.to_string(),
homepage_id: Some(format!("{id}-home")),
}
}
fn sample_page(items: Vec<ConfluenceSpace>, cursor: Option<&str>) -> ConfluenceSpacePage {
ConfluenceSpacePage {
results: items,
next_cursor: cursor.map(str::to_string),
}
}
#[test]
fn space_subcommands_list_variant() {
let cmd = SpaceCommand {
command: SpaceSubcommands::List(ListCommand {
keys: vec![],
r#type: None,
status: None,
cursor: None,
limit: 25,
output: OutputFormat::Table,
}),
};
assert!(matches!(cmd.command, SpaceSubcommands::List(_)));
}
#[test]
fn display_spaces_table() {
let page = sample_page(vec![sample_space("1", "ENG", false)], None);
assert!(display_spaces(&page, &OutputFormat::Table).is_ok());
}
#[test]
fn display_spaces_json() {
let page = sample_page(vec![sample_space("1", "ENG", false)], None);
assert!(display_spaces(&page, &OutputFormat::Json).is_ok());
}
#[test]
fn display_spaces_yaml() {
let page = sample_page(vec![sample_space("1", "ENG", false)], None);
assert!(display_spaces(&page, &OutputFormat::Yaml).is_ok());
}
#[test]
fn display_spaces_yamls() {
let page = sample_page(vec![sample_space("1", "ENG", false)], None);
assert!(display_spaces(&page, &OutputFormat::Yamls).is_ok());
}
#[test]
fn display_spaces_jsonl() {
let page = sample_page(vec![sample_space("1", "ENG", false)], None);
assert!(display_spaces(&page, &OutputFormat::Jsonl).is_ok());
}
#[test]
fn display_spaces_empty_table() {
let page = sample_page(vec![], None);
assert!(display_spaces(&page, &OutputFormat::Table).is_ok());
}
#[test]
fn print_spaces_with_cursor() {
let page = sample_page(vec![sample_space("1", "ENG", false)], Some("NEXT"));
print_spaces(&page);
}
#[test]
fn print_spaces_archived_and_missing_homepage() {
let mut space = sample_space("2", "OPS", true);
space.homepage_id = None;
let page = sample_page(vec![space], None);
print_spaces(&page);
}
#[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/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{
"id": "1", "key": "ENG", "name": "Engineering",
"type": "global", "status": "current", "homepageId": "h-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, &[], None, None, None, 25, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn list_command_execute_propagates_create_client_error() {
let guard = crate::atlassian::auth::test_util::EnvGuard::take();
let _home = guard.clear_credentials();
let cmd = ListCommand {
keys: vec![],
r#type: None,
status: None,
cursor: None,
limit: 25,
output: OutputFormat::Yaml,
};
assert!(cmd.execute().await.is_err());
}
#[tokio::test]
async fn run_list_propagates_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
let api = ConfluenceApi::new(client);
let err = run_list(&api, &[], None, None, None, 25, &OutputFormat::Table)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
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);
}
#[tokio::test(flavor = "current_thread")]
async fn list_command_execute_runs_through_dispatch() {
let _lock = crate::atlassian::auth::test_util::AUTH_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/spaces"))
.and(wiremock::matchers::query_param("keys", "ENG,DEV"))
.and(wiremock::matchers::query_param("type", "knowledge_base"))
.and(wiremock::matchers::query_param("status", "archived"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.mount(&server)
.await;
set_atlassian_env(&server.uri());
let cmd = SpaceCommand {
command: SpaceSubcommands::List(ListCommand {
keys: vec!["ENG".to_string(), "DEV".to_string()],
r#type: Some("knowledge_base".to_string()),
status: Some("archived".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:?}");
}
use crate::atlassian::confluence_api::{PageSummary, PageSummaryPage};
fn sample_page_summary(id: &str, title: &str) -> PageSummary {
PageSummary {
id: id.to_string(),
title: title.to_string(),
status: "current".to_string(),
parent_id: Some("100".to_string()),
author_id: Some(format!("u-{id}")),
created_at: Some("2024-05-06T07:08:09Z".to_string()),
}
}
fn sample_pages(items: Vec<PageSummary>, cursor: Option<&str>) -> PageSummaryPage {
PageSummaryPage {
results: items,
next_cursor: cursor.map(str::to_string),
}
}
#[test]
fn pages_command_accepts_hyphenated_sort_values() {
use clap::Parser;
let cmd = PagesCommand::try_parse_from(["pages", "ENG", "--sort", "-created-date"])
.expect("clap should accept hyphenated sort values");
assert_eq!(cmd.sort.as_deref(), Some("-created-date"));
let cmd = PagesCommand::try_parse_from(["pages", "ENG", "--sort", "-title"])
.expect("clap should accept hyphenated sort values");
assert_eq!(cmd.sort.as_deref(), Some("-title"));
}
#[test]
fn space_subcommands_pages_variant() {
let pages = SpaceCommand {
command: SpaceSubcommands::Pages(PagesCommand {
key: "ENG".to_string(),
status: None,
sort: None,
cursor: None,
limit: 25,
output: OutputFormat::Table,
}),
};
let list = SpaceCommand {
command: SpaceSubcommands::List(ListCommand {
keys: vec![],
r#type: None,
status: None,
cursor: None,
limit: 25,
output: OutputFormat::Table,
}),
};
for (expected, cmd) in [(true, pages), (false, list)] {
assert_eq!(matches!(cmd.command, SpaceSubcommands::Pages(_)), expected);
}
}
#[test]
fn display_pages_table() {
let page = sample_pages(vec![sample_page_summary("1", "Home")], None);
assert!(display_pages(&page, &OutputFormat::Table).is_ok());
}
#[test]
fn display_pages_json() {
let page = sample_pages(vec![sample_page_summary("1", "Home")], None);
assert!(display_pages(&page, &OutputFormat::Json).is_ok());
}
#[test]
fn display_pages_yaml() {
let page = sample_pages(vec![sample_page_summary("1", "Home")], None);
assert!(display_pages(&page, &OutputFormat::Yaml).is_ok());
}
#[test]
fn display_pages_yamls() {
let page = sample_pages(vec![sample_page_summary("1", "Home")], None);
assert!(display_pages(&page, &OutputFormat::Yamls).is_ok());
}
#[test]
fn display_pages_jsonl() {
let page = sample_pages(vec![sample_page_summary("1", "Home")], None);
assert!(display_pages(&page, &OutputFormat::Jsonl).is_ok());
}
#[test]
fn display_pages_empty_table() {
let page = sample_pages(vec![], None);
assert!(display_pages(&page, &OutputFormat::Table).is_ok());
}
#[test]
fn print_pages_with_cursor_and_missing_fields() {
let bare = PageSummary {
id: "9".to_string(),
title: "Bare".to_string(),
status: String::new(),
parent_id: None,
author_id: None,
created_at: None,
};
let page = sample_pages(vec![sample_page_summary("1", "Home"), bare], Some("NEXT"));
print_pages(&page);
}
#[tokio::test]
async fn run_pages_table_output() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.and(wiremock::matchers::query_param("keys", "ENG"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
)
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "1", "title": "Home", "status": "current",
"authorId": "u1", "createdAt": "2024-01-02T03:04:05Z"}
]
})),
)
.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_pages(&api, "ENG", None, None, None, 25, &OutputFormat::Table)
.await
.is_ok()
);
}
#[tokio::test]
async fn run_pages_propagates_api_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "98765"}]})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98765/pages"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
let api = ConfluenceApi::new(client);
let err = run_pages(&api, "ENG", None, None, None, 25, &OutputFormat::Json)
.await
.unwrap_err();
assert!(err.to_string().contains("500"));
}
#[tokio::test]
async fn run_pages_propagates_resolve_space_id_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.expect(1)
.mount(&server)
.await;
let client =
crate::atlassian::client::AtlassianClient::new(&server.uri(), "u@t.com", "tok")
.unwrap();
let api = ConfluenceApi::new(client);
let err = run_pages(&api, "NOPE", None, None, None, 25, &OutputFormat::Json)
.await
.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[tokio::test]
async fn pages_command_execute_propagates_create_client_error() {
let guard = crate::atlassian::auth::test_util::EnvGuard::take();
let _home = guard.clear_credentials();
let cmd = PagesCommand {
key: "ENG".to_string(),
status: None,
sort: None,
cursor: None,
limit: 25,
output: OutputFormat::Yaml,
};
assert!(cmd.execute().await.is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn pages_command_execute_runs_through_dispatch() {
let _lock = crate::atlassian::auth::test_util::AUTH_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/spaces"))
.and(wiremock::matchers::query_param("keys", "ENG"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": [{"id": "55"}]})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/55/pages"))
.and(wiremock::matchers::query_param("status", "archived"))
.and(wiremock::matchers::query_param("sort", "-created-date"))
.and(wiremock::matchers::query_param("cursor", "opaque"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"results": []})),
)
.mount(&server)
.await;
set_atlassian_env(&server.uri());
let cmd = SpaceCommand {
command: SpaceSubcommands::Pages(PagesCommand {
key: "ENG".to_string(),
status: Some("archived".to_string()),
sort: Some("-created-date".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:?}");
}
}