use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use crate::atlassian::confluence_api::{ConfluenceApi, PageMetadata, PageVersion, SinceFilter};
use crate::cli::atlassian::format::{output_as, JsonlSerialize, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
#[derive(Parser)]
pub struct HistoryCommand {
pub id: String,
#[arg(long)]
pub since: Option<String>,
#[arg(long, default_value_t = 20)]
pub limit: u32,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
}
#[derive(Debug, Clone, Serialize)]
pub struct PageInfo {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_version: Option<u32>,
}
impl From<PageMetadata> for PageInfo {
fn from(m: PageMetadata) -> Self {
Self {
id: m.id,
title: m.title,
current_version: m.current_version,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HistoryOutput {
pub page: PageInfo,
pub versions: Vec<PageVersion>,
pub truncated: bool,
}
impl JsonlSerialize for HistoryOutput {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
crate::cli::atlassian::format::write_items_jsonl(self.versions.iter(), out)
}
}
impl HistoryCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
self.run(&ConfluenceApi::new(client)).await
}
async fn run(self, api: &ConfluenceApi) -> Result<()> {
run_history(
api,
&self.id,
self.since.as_deref(),
self.limit,
&self.output,
)
.await
}
}
pub async fn run_history(
api: &ConfluenceApi,
page_id: &str,
since: Option<&str>,
limit: u32,
output: &OutputFormat,
) -> Result<()> {
let history = fetch_history(api, page_id, since, limit).await?;
if output_as(&history, output)? {
return Ok(());
}
print_table(&history);
Ok(())
}
pub async fn fetch_history(
api: &ConfluenceApi,
page_id: &str,
since: Option<&str>,
limit: u32,
) -> Result<HistoryOutput> {
let parsed_since = since
.map(SinceFilter::parse)
.transpose()
.context("Invalid `since` value")?;
let metadata = api.get_page_metadata(page_id).await?;
let (versions, truncated) = api
.list_page_versions(page_id, parsed_since.as_ref(), limit)
.await?;
Ok(HistoryOutput {
page: PageInfo::from(metadata),
versions,
truncated,
})
}
fn print_table(history: &HistoryOutput) {
println!(
"PAGE: {} ({}){}",
history.page.title,
history.page.id,
match history.page.current_version {
Some(v) => format!(" — current version {v}"),
None => String::new(),
}
);
if history.versions.is_empty() {
println!("No versions found.");
return;
}
let num_width = history
.versions
.iter()
.map(|v| v.number.to_string().len())
.max()
.unwrap_or(1)
.max(3);
let date_width = history
.versions
.iter()
.map(|v| v.created_at.len())
.max()
.unwrap_or(4)
.max(4);
let author_width = history
.versions
.iter()
.map(|v| v.author_id.len())
.max()
.unwrap_or(6)
.max(6);
println!(
"{:>num_width$} {:<date_width$} {:<author_width$} MIN MESSAGE",
"VER", "DATE", "AUTHOR"
);
println!(
"{:>num_width$} {:<date_width$} {:<author_width$} --- -------",
"-".repeat(num_width),
"-".repeat(date_width),
"-".repeat(author_width),
);
for v in &history.versions {
let minor = if v.minor_edit { "yes" } else { "no" };
println!(
"{:>num_width$} {:<date_width$} {:<author_width$} {minor:<3} {}",
v.number, v.created_at, v.author_id, v.message
);
}
if history.truncated {
println!("(truncated — increase --limit or pass --since to scope further)");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::atlassian::client::AtlassianClient;
async fn setup_api() -> (wiremock::MockServer, ConfluenceApi) {
let server = wiremock::MockServer::start().await;
let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
let api = ConfluenceApi::new(client);
(server, api)
}
async fn mount_metadata(server: &wiremock::MockServer, page_id: &str, version: u32) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/wiki/api/v2/pages/{page_id}"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": page_id,
"title": format!("Page {page_id}"),
"status": "current",
"spaceId": "1",
"version": {"number": version}
})),
)
.mount(server)
.await;
}
async fn mount_versions(
server: &wiremock::MockServer,
page_id: &str,
results: serde_json::Value,
) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/wiki/api/v2/pages/{page_id}/versions"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": results
})),
)
.mount(server)
.await;
}
#[test]
fn page_info_from_metadata_copies_fields() {
let info = PageInfo::from(PageMetadata {
id: "1".to_string(),
title: "T".to_string(),
current_version: Some(5),
});
assert_eq!(info.id, "1");
assert_eq!(info.title, "T");
assert_eq!(info.current_version, Some(5));
}
#[test]
fn page_info_serialize_skips_missing_version() {
let info = PageInfo {
id: "1".to_string(),
title: "T".to_string(),
current_version: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(!json.contains("current_version"));
}
#[test]
fn history_command_defaults() {
let cmd = HistoryCommand {
id: "12345".to_string(),
since: None,
limit: 20,
output: OutputFormat::Table,
};
assert_eq!(cmd.id, "12345");
assert_eq!(cmd.limit, 20);
assert!(cmd.since.is_none());
}
#[tokio::test]
async fn run_history_table() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 3).await;
mount_versions(
&server,
"12",
serde_json::json!([
{"number": 3, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "third", "minorEdit": false},
{"number": 2, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": true},
{"number": 1, "createdAt": "2026-05-06T10:00:00Z", "authorId": "", "message": "first", "minorEdit": false},
]),
)
.await;
run_history(&api, "12", None, 20, &OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_history_json() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 1).await;
mount_versions(
&server,
"12",
serde_json::json!([
{"number": 1, "createdAt": "2026-05-06T10:00:00Z", "authorId": "", "message": "first", "minorEdit": false},
]),
)
.await;
run_history(&api, "12", None, 20, &OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn run_history_yaml() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 1).await;
mount_versions(&server, "12", serde_json::json!([])).await;
run_history(&api, "12", None, 20, &OutputFormat::Yaml)
.await
.unwrap();
}
#[tokio::test]
async fn run_history_jsonl() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 1).await;
mount_versions(
&server,
"12",
serde_json::json!([
{"number": 1, "createdAt": "2026-05-06T10:00:00Z", "authorId": "", "message": "first", "minorEdit": false},
]),
)
.await;
run_history(&api, "12", None, 20, &OutputFormat::Jsonl)
.await
.unwrap();
}
#[test]
fn print_table_with_no_current_version_falls_back_to_empty_suffix() {
let history = HistoryOutput {
page: PageInfo {
id: "1".to_string(),
title: "T".to_string(),
current_version: None,
},
versions: vec![PageVersion {
number: 1,
created_at: "2026-05-06T10:00:00Z".to_string(),
author_id: "a".to_string(),
message: "first".to_string(),
minor_edit: false,
}],
truncated: false,
};
print_table(&history);
}
#[tokio::test]
async fn run_history_empty_table() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 1).await;
mount_versions(&server, "12", serde_json::json!([])).await;
run_history(&api, "12", None, 20, &OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_history_truncates_with_marker() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 5).await;
mount_versions(
&server,
"12",
serde_json::json!([
{"number": 5, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "5", "minorEdit": false},
{"number": 4, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "4", "minorEdit": false},
{"number": 3, "createdAt": "2026-05-06T10:00:00Z", "authorId": "c", "message": "3", "minorEdit": false},
]),
)
.await;
let history = fetch_history(&api, "12", None, 2).await.unwrap();
assert!(history.truncated);
assert_eq!(history.versions.len(), 2);
run_history(&api, "12", None, 2, &OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_history_invalid_since_rejected() {
let (_server, api) = setup_api().await;
let err = run_history(&api, "12", Some("nope"), 20, &OutputFormat::Json)
.await
.unwrap_err();
assert!(err.to_string().contains("Invalid `since`"));
}
#[tokio::test]
async fn fetch_history_applies_numeric_since() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 5).await;
mount_versions(
&server,
"12",
serde_json::json!([
{"number": 5, "createdAt": "2026-05-09T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 4, "createdAt": "2026-05-08T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
{"number": 3, "createdAt": "2026-05-07T10:00:00Z", "authorId": "c", "message": "", "minorEdit": false},
]),
)
.await;
let history = fetch_history(&api, "12", Some("4"), 0).await.unwrap();
assert_eq!(
history
.versions
.iter()
.map(|v| v.number)
.collect::<Vec<_>>(),
vec![5, 4]
);
assert!(!history.truncated);
}
#[tokio::test]
async fn fetch_history_applies_iso_since() {
let (server, api) = setup_api().await;
mount_metadata(&server, "12", 3).await;
mount_versions(
&server,
"12",
serde_json::json!([
{"number": 3, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 2, "createdAt": "2026-04-01T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]),
)
.await;
let history = fetch_history(&api, "12", Some("2026-05-01"), 0)
.await
.unwrap();
assert_eq!(history.versions.len(), 1);
assert_eq!(history.versions[0].number, 3);
}
#[tokio::test]
async fn fetch_history_metadata_error_propagates() {
let (server, api) = setup_api().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/99"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let err = fetch_history(&api, "99", None, 20).await.unwrap_err();
assert!(err.to_string().contains("404"));
}
#[test]
fn history_output_jsonl_emits_versions() {
let history = HistoryOutput {
page: PageInfo {
id: "1".to_string(),
title: "T".to_string(),
current_version: Some(2),
},
versions: vec![
PageVersion {
number: 2,
created_at: "2026-05-08T10:00:00Z".to_string(),
author_id: "a".to_string(),
message: "two".to_string(),
minor_edit: false,
},
PageVersion {
number: 1,
created_at: "2026-05-07T10:00:00Z".to_string(),
author_id: "b".to_string(),
message: String::new(),
minor_edit: true,
},
],
truncated: false,
};
let mut buf: Vec<u8> = Vec::new();
history.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\"number\":2"));
assert!(lines[1].contains("\"number\":1"));
}
}