use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use crate::atlassian::adf::AdfDocument;
use crate::atlassian::api::ContentMetadata;
use crate::atlassian::confluence_api::{resolve_version, ConfluenceApi};
use crate::atlassian::confluence_types::PageVersion;
use crate::atlassian::diff::{diff_documents, DiffOptions};
use crate::atlassian::diff_format::{
render, render_section, CompareContext, CompareOutput, Cursor, Detail, Filter, Includes,
SectionFormat, VersionInfo, DEFAULT_OUTPUT_BUDGET,
};
use crate::cli::atlassian::format::{output_as, JsonlSerialize, OutputFormat};
use crate::cli::atlassian::helpers::create_client;
use crate::data::yaml::to_yaml;
#[derive(Parser)]
pub struct CompareCommand {
pub id: String,
#[arg(long, default_value = "previous")]
pub from: String,
#[arg(long, default_value = "latest")]
pub to: String,
#[arg(long, value_enum, default_value_t = DetailArg::Outline)]
pub detail: DetailArg,
#[arg(long, default_value = "body,title,metadata")]
pub include: String,
#[arg(long, default_value_t = true)]
pub ignore_whitespace: bool,
#[arg(long, default_value_t = 0)]
pub min_change_chars: u32,
#[arg(long = "filter-section")]
pub filter_sections: Vec<String>,
#[arg(long, default_value_t = DEFAULT_OUTPUT_BUDGET)]
pub budget: usize,
#[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Yaml)]
pub output: OutputFormat,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum DetailArg {
Summary,
Outline,
Full,
}
impl From<DetailArg> for Detail {
fn from(d: DetailArg) -> Self {
match d {
DetailArg::Summary => Self::Summary,
DetailArg::Outline => Self::Outline,
DetailArg::Full => Self::Full,
}
}
}
#[derive(Parser)]
pub struct CompareSectionCommand {
#[arg(long)]
pub cursor: String,
#[arg(long, value_enum, default_value_t = SectionFormatArg::Unified)]
pub format: SectionFormatArg,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum SectionFormatArg {
Unified,
SideBySide,
MarkdownInline,
}
impl From<SectionFormatArg> for SectionFormat {
fn from(f: SectionFormatArg) -> Self {
match f {
SectionFormatArg::Unified => Self::Unified,
SectionFormatArg::SideBySide => Self::SideBySide,
SectionFormatArg::MarkdownInline => Self::MarkdownInline,
}
}
}
#[derive(Parser)]
pub struct CompareCommandGroup {
#[command(subcommand)]
pub command: CompareSubcommands,
}
#[derive(Subcommand)]
pub enum CompareSubcommands {
Run(CompareCommand),
Section(CompareSectionCommand),
}
impl CompareCommandGroup {
pub async fn execute(self) -> Result<()> {
match self.command {
CompareSubcommands::Run(cmd) => cmd.execute().await,
CompareSubcommands::Section(cmd) => cmd.execute().await,
}
}
}
impl CompareCommand {
pub async fn execute(self) -> Result<()> {
let (client, instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
self.run(&api, &instance_url).await
}
async fn run(self, api: &ConfluenceApi, instance_url: &str) -> Result<()> {
let output = self.output.clone();
let compare = run_compare(api, instance_url, &self).await?;
if output_as(&compare, &output)? {
return Ok(());
}
let yaml = to_yaml(&compare)?;
println!("{yaml}");
Ok(())
}
}
impl JsonlSerialize for CompareOutput {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
crate::cli::atlassian::format::write_scalar_jsonl(self, out)
}
}
pub async fn run_compare(
api: &ConfluenceApi,
instance_url: &str,
cmd: &CompareCommand,
) -> Result<CompareOutput> {
let (versions, _truncated) = api
.list_page_versions(&cmd.id, None, 200)
.await
.context("Failed to list page versions")?;
if versions.is_empty() {
anyhow::bail!("Page {} has no version history", cmd.id);
}
let latest = versions[0].number;
let to_v = resolve_version(&cmd.to, &versions, latest)
.with_context(|| format!("Failed to resolve --to \"{}\"", cmd.to))?;
let from_v = resolve_version(&cmd.from, &versions, to_v)
.with_context(|| format!("Failed to resolve --from \"{}\"", cmd.from))?;
if from_v == to_v {
anyhow::bail!(
"--from and --to resolved to the same version ({from_v}); nothing to compare"
);
}
let (from_item, to_item) = tokio::try_join!(
api.get_page_at_version(&cmd.id, from_v),
api.get_page_at_version(&cmd.id, to_v),
)?;
let from_doc = adf_from_item_body(&from_item, "from")?;
let to_doc = adf_from_item_body(&to_item, "to")?;
let opts = DiffOptions {
ignore_whitespace: cmd.ignore_whitespace,
};
let diff = diff_documents(&from_doc, &to_doc, &opts);
let from_v_meta = version_info_for(&versions, from_v);
let to_v_meta = version_info_for(&versions, to_v);
let url = page_url(instance_url, &to_item);
let ctx = CompareContext {
page_id: cmd.id.clone(),
page_title: to_item.title.clone(),
page_url: url,
from_version: from_v_meta,
to_version: to_v_meta,
from_title: from_item.title,
to_title: to_item.title,
from_labels: Vec::new(),
to_labels: Vec::new(),
};
let includes = parse_includes(&cmd.include)?;
let filter = Filter {
sections: cmd.filter_sections.clone(),
min_change_chars: cmd.min_change_chars,
kinds: Vec::new(),
};
render(diff, &ctx, cmd.detail.into(), includes, &filter, cmd.budget)
}
fn version_info_for(versions: &[PageVersion], n: u32) -> VersionInfo {
versions
.iter()
.find(|v| v.number == n)
.map(|v| VersionInfo {
number: v.number,
created_at: v.created_at.clone(),
author: v.author_id.clone(),
message: v.message.clone(),
})
.unwrap_or(VersionInfo {
number: n,
..VersionInfo::default()
})
}
fn adf_from_item_body(
item: &crate::atlassian::api::ContentItem,
side: &str,
) -> Result<AdfDocument> {
match &item.body_adf {
Some(value) => serde_json::from_value(value.clone())
.with_context(|| format!("Failed to parse ADF document for {side} version")),
None => Ok(AdfDocument::default()),
}
}
fn page_url(instance_url: &str, item: &crate::atlassian::api::ContentItem) -> Option<String> {
if let ContentMetadata::Confluence { space_key, .. } = &item.metadata {
if !space_key.is_empty() {
return Some(format!(
"{instance_url}/wiki/spaces/{space_key}/pages/{}",
item.id
));
}
}
None
}
fn parse_includes(spec: &str) -> Result<Includes> {
let mut inc = Includes {
body: false,
title: false,
labels: false,
metadata: false,
};
for raw in spec.split(',') {
match raw.trim().to_ascii_lowercase().as_str() {
"body" => inc.body = true,
"title" => inc.title = true,
"labels" => inc.labels = true,
"metadata" => inc.metadata = true,
"" => {}
other => return Err(anyhow!("Unknown include flag \"{other}\"")),
}
}
Ok(inc)
}
impl CompareSectionCommand {
pub async fn execute(self) -> Result<()> {
let (client, _instance_url) = create_client()?;
let api = ConfluenceApi::new(client);
let cur = Cursor::decode(&self.cursor).context("Invalid --cursor")?;
let text = run_compare_section(&api, &cur, self.format.into()).await?;
println!("{text}");
Ok(())
}
}
pub async fn run_compare_section(
api: &ConfluenceApi,
cur: &Cursor,
format: SectionFormat,
) -> Result<String> {
let (from_item, to_item) = tokio::try_join!(
api.get_page_at_version(&cur.page_id, cur.from_v),
api.get_page_at_version(&cur.page_id, cur.to_v),
)?;
let from_doc = adf_from_item_body(&from_item, "from")?;
let to_doc = adf_from_item_body(&to_item, "to")?;
let opts = DiffOptions {
ignore_whitespace: true,
};
let diff = diff_documents(&from_doc, &to_doc, &opts);
render_section(&diff, cur, format)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::await_holding_lock)]
mod tests {
use super::*;
use crate::atlassian::client::AtlassianClient;
use serde_json::json;
async fn setup_api() -> (wiremock::MockServer, ConfluenceApi) {
let server = wiremock::MockServer::start().await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
(server, api)
}
fn page_response(id: &str, title: &str, version: u32, body_text: &str) -> serde_json::Value {
let adf = format!(
r#"{{"version":1,"type":"doc","content":[{{"type":"heading","attrs":{{"level":2}},"content":[{{"type":"text","text":"Background"}}]}},{{"type":"paragraph","content":[{{"type":"text","text":"{body_text}"}}]}}]}}"#
);
json!({
"id": id,
"title": title,
"status": "current",
"spaceId": "98",
"version": {"number": version},
"body": {
"atlas_doc_format": {"value": adf}
}
})
}
async fn mount_page(
server: &wiremock::MockServer,
id: &str,
version: u32,
title: &str,
body: &str,
) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/wiki/api/v2/pages/{id}")))
.and(wiremock::matchers::query_param(
"version",
version.to_string(),
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(page_response(id, title, version, body)),
)
.mount(server)
.await;
}
async fn mount_versions(server: &wiremock::MockServer, id: &str, results: serde_json::Value) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/wiki/api/v2/pages/{id}/versions"
)))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(json!({ "results": results })),
)
.mount(server)
.await;
}
async fn mount_space(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"key": "ENG"})))
.mount(server)
.await;
}
fn cmd(id: &str) -> CompareCommand {
CompareCommand {
id: id.to_string(),
from: "previous".to_string(),
to: "latest".to_string(),
detail: DetailArg::Outline,
include: "body,title,metadata".to_string(),
ignore_whitespace: true,
min_change_chars: 0,
filter_sections: Vec::new(),
budget: DEFAULT_OUTPUT_BUDGET,
output: OutputFormat::Yaml,
}
}
#[test]
fn detail_arg_to_detail() {
assert_eq!(Detail::from(DetailArg::Summary), Detail::Summary);
assert_eq!(Detail::from(DetailArg::Outline), Detail::Outline);
assert_eq!(Detail::from(DetailArg::Full), Detail::Full);
}
#[test]
fn parse_includes_default_set() {
let inc = parse_includes("body,title,metadata").unwrap();
assert!(inc.body && inc.title && inc.metadata);
assert!(!inc.labels);
}
#[test]
fn parse_includes_with_labels() {
let inc = parse_includes("body,labels").unwrap();
assert!(inc.body && inc.labels);
assert!(!inc.title && !inc.metadata);
}
#[test]
fn parse_includes_unknown_value_errors() {
let err = parse_includes("body,attachments").unwrap_err();
assert!(err.to_string().contains("Unknown include flag"));
}
#[test]
fn parse_includes_handles_whitespace_and_empty_segments() {
let inc = parse_includes("body, , title").unwrap();
assert!(inc.body && inc.title);
}
#[tokio::test]
async fn run_compare_outline_against_mock() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "alice", "message": "v2", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "bob", "message": "v1", "minorEdit": false},
]),
)
.await;
mount_page(&server, "12", 1, "Page v1", "version 12").await;
mount_page(&server, "12", 2, "Page v2", "version 14").await;
mount_space(&server).await;
let out = run_compare(&api, &server.uri(), &cmd("12")).await.unwrap();
assert_eq!(out.page.id, "12");
assert_eq!(out.page.title, "Page v2");
assert_eq!(
out.versions.as_ref().expect("versions present").from.number,
1
);
assert_eq!(
out.versions.as_ref().expect("versions present").to.number,
2
);
assert!(out.summary.by_kind.sections_modified >= 1);
let bg = out
.sections
.iter()
.find(|s| s.path == "/h2#background")
.expect("background section");
assert!(!bg.cursor.is_empty());
}
#[tokio::test]
async fn run_compare_same_version_errors() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 5, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
]),
)
.await;
let mut c = cmd("12");
c.from = "5".to_string();
c.to = "latest".to_string();
let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
assert!(err.to_string().contains("same version"));
}
#[tokio::test]
async fn run_compare_no_versions_errors() {
let (server, api) = setup_api().await;
mount_versions(&server, "12", json!([])).await;
let err = run_compare(&api, &server.uri(), &cmd("12"))
.await
.unwrap_err();
assert!(err.to_string().contains("no version history"));
}
#[tokio::test]
async fn run_compare_section_round_trip() {
let (server, api) = setup_api().await;
mount_page(&server, "12", 1, "T", "version 12").await;
mount_page(&server, "12", 2, "T", "version 14").await;
mount_space(&server).await;
let cur = Cursor {
page_id: "12".to_string(),
from_v: 1,
to_v: 2,
section_path: "/h2#background".to_string(),
};
let text = run_compare_section(&api, &cur, SectionFormat::Unified)
.await
.unwrap();
assert!(text.contains("/h2#background"));
assert!(text.contains("version 12"));
assert!(text.contains("version 14"));
}
#[tokio::test]
async fn run_compare_full_includes_diff_payload() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]),
)
.await;
mount_page(&server, "12", 1, "T", "version 12").await;
mount_page(&server, "12", 2, "T", "version 14").await;
mount_space(&server).await;
let mut c = cmd("12");
c.detail = DetailArg::Full;
let out = run_compare(&api, &server.uri(), &c).await.unwrap();
let bg = out
.sections
.iter()
.find(|s| s.path == "/h2#background")
.expect("background section");
assert!(!bg.diff.is_empty(), "full mode should embed diff payload");
}
#[tokio::test]
async fn run_compare_summary_omits_sections() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]),
)
.await;
mount_page(&server, "12", 1, "T", "v1").await;
mount_page(&server, "12", 2, "T", "v2").await;
mount_space(&server).await;
let mut c = cmd("12");
c.detail = DetailArg::Summary;
let out = run_compare(&api, &server.uri(), &c).await.unwrap();
assert!(out.sections.is_empty());
assert!(out.summary.total_changes >= 1);
}
#[test]
fn section_format_arg_to_section_format() {
assert_eq!(
SectionFormat::from(SectionFormatArg::Unified),
SectionFormat::Unified
);
assert_eq!(
SectionFormat::from(SectionFormatArg::SideBySide),
SectionFormat::SideBySide
);
assert_eq!(
SectionFormat::from(SectionFormatArg::MarkdownInline),
SectionFormat::MarkdownInline
);
}
#[test]
fn parse_includes_empty_returns_all_disabled() {
let inc = parse_includes("").unwrap();
assert!(!inc.body && !inc.title && !inc.labels && !inc.metadata);
}
#[test]
fn parse_includes_all_four_flags() {
let inc = parse_includes("body,title,labels,metadata").unwrap();
assert!(inc.body && inc.title && inc.labels && inc.metadata);
}
#[test]
fn version_info_for_hit_returns_full_metadata() {
let versions = vec![PageVersion {
number: 4,
created_at: "2026-05-08T10:00:00Z".to_string(),
author_id: "alice".to_string(),
message: "v4".to_string(),
minor_edit: false,
}];
let info = version_info_for(&versions, 4);
assert_eq!(info.number, 4);
assert_eq!(info.author, "alice");
assert_eq!(info.message, "v4");
}
#[test]
fn version_info_for_miss_returns_default_with_number() {
let info = version_info_for(&[], 99);
assert_eq!(info.number, 99);
assert!(info.created_at.is_empty());
assert!(info.author.is_empty());
}
#[test]
fn page_url_built_when_space_key_present() {
use crate::atlassian::api::{ContentItem, ContentMetadata};
let item = ContentItem {
id: "12".to_string(),
title: "T".to_string(),
body_adf: None,
metadata: ContentMetadata::Confluence {
space_key: "ENG".to_string(),
status: None,
version: Some(1),
parent_id: None,
},
};
assert_eq!(
page_url("https://x.atlassian.net", &item).as_deref(),
Some("https://x.atlassian.net/wiki/spaces/ENG/pages/12")
);
}
#[test]
fn page_url_none_when_space_key_empty() {
use crate::atlassian::api::{ContentItem, ContentMetadata};
let item = ContentItem {
id: "12".to_string(),
title: "T".to_string(),
body_adf: None,
metadata: ContentMetadata::Confluence {
space_key: String::new(),
status: None,
version: None,
parent_id: None,
},
};
assert!(page_url("https://x.atlassian.net", &item).is_none());
}
#[test]
fn page_url_none_when_jira_metadata() {
use crate::atlassian::api::{ContentItem, ContentMetadata};
let item = ContentItem {
id: "PROJ-1".to_string(),
title: "T".to_string(),
body_adf: None,
metadata: ContentMetadata::Jira {
status: Some("Open".to_string()),
issue_type: Some("Bug".to_string()),
assignee: None,
priority: None,
labels: Vec::new(),
},
};
assert!(page_url("https://x.atlassian.net", &item).is_none());
}
#[tokio::test]
async fn run_compare_propagates_list_versions_error() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&server)
.await;
let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
let api = ConfluenceApi::new(client);
let err = run_compare(&api, &server.uri(), &cmd("12"))
.await
.unwrap_err();
assert!(
err.to_string().contains("Failed to list page versions"),
"got: {err}"
);
}
#[tokio::test]
async fn run_compare_propagates_bad_include() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]),
)
.await;
mount_page(&server, "12", 1, "T", "v1").await;
mount_page(&server, "12", 2, "T", "v2").await;
mount_space(&server).await;
let mut c = cmd("12");
c.include = "body,bogus".to_string();
let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
assert!(err.to_string().contains("Unknown include flag"));
}
#[tokio::test]
async fn run_compare_propagates_bad_to() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
]),
)
.await;
let mut c = cmd("12");
c.to = "garbage".to_string();
let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
assert!(err.to_string().contains("Failed to resolve --to"));
}
#[tokio::test]
async fn run_compare_propagates_bad_from() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]),
)
.await;
let mut c = cmd("12");
c.from = "garbage".to_string();
let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
assert!(err.to_string().contains("Failed to resolve --from"));
}
#[test]
fn adf_from_item_body_missing_body_returns_default() {
use crate::atlassian::api::{ContentItem, ContentMetadata};
let item = ContentItem {
id: "12".to_string(),
title: "T".to_string(),
body_adf: None,
metadata: ContentMetadata::Confluence {
space_key: "ENG".to_string(),
status: None,
version: None,
parent_id: None,
},
};
let doc = adf_from_item_body(&item, "test").unwrap();
assert_eq!(doc.content.len(), 0);
}
#[test]
fn adf_from_item_body_invalid_json_errors() {
use crate::atlassian::api::{ContentItem, ContentMetadata};
let item = ContentItem {
id: "12".to_string(),
title: "T".to_string(),
body_adf: Some(json!({"unexpected": "shape"})),
metadata: ContentMetadata::Confluence {
space_key: "ENG".to_string(),
status: None,
version: None,
parent_id: None,
},
};
let err = adf_from_item_body(&item, "from").unwrap_err();
assert!(err.to_string().contains("from"));
}
#[tokio::test]
async fn run_compare_section_unified_via_helper() {
let (server, api) = setup_api().await;
mount_page(&server, "12", 1, "T", "v1").await;
mount_page(&server, "12", 2, "T", "v2").await;
mount_space(&server).await;
let cur = Cursor {
page_id: "12".to_string(),
from_v: 1,
to_v: 2,
section_path: "/h2#background".to_string(),
};
let text = run_compare_section(&api, &cur, SectionFormat::Unified)
.await
.unwrap();
assert!(text.contains("/h2#background"));
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[tokio::test(flavor = "current_thread")]
async fn compare_command_execute_dispatches_with_creds() {
let _lock = env_lock();
std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
let cmd = CompareCommand {
id: "12".to_string(),
from: "previous".to_string(),
to: "latest".to_string(),
detail: DetailArg::Outline,
include: "body,title,metadata".to_string(),
ignore_whitespace: true,
min_change_chars: 0,
filter_sections: Vec::new(),
budget: DEFAULT_OUTPUT_BUDGET,
output: OutputFormat::Yaml,
};
let _ = cmd.execute().await;
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
}
#[tokio::test(flavor = "current_thread")]
async fn compare_section_command_execute_dispatches_with_creds() {
let _lock = env_lock();
std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
let cur = Cursor {
page_id: "12".to_string(),
from_v: 1,
to_v: 2,
section_path: "/h2#background".to_string(),
};
let cmd = CompareSectionCommand {
cursor: cur.encode().unwrap(),
format: SectionFormatArg::Unified,
};
let _ = cmd.execute().await;
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
}
#[tokio::test(flavor = "current_thread")]
async fn compare_section_command_execute_happy_path_via_mock() {
let _lock = env_lock();
let server = wiremock::MockServer::start().await;
let mount_page = |version: u32, body: &'static str| {
let s = &server;
async move {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
.and(wiremock::matchers::query_param(
"version",
version.to_string(),
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"id": "12",
"title": "T",
"status": "current",
"spaceId": "98",
"version": {"number": version},
"body": {"atlas_doc_format": {"value": body}}
})))
.mount(s)
.await;
}
};
mount_page(
1,
r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Background"}]},{"type":"paragraph","content":[{"type":"text","text":"v1"}]}]}"#,
)
.await;
mount_page(
2,
r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Background"}]},{"type":"paragraph","content":[{"type":"text","text":"v2"}]}]}"#,
)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"key": "ENG"})))
.mount(&server)
.await;
std::env::set_var("ATLASSIAN_INSTANCE_URL", server.uri());
std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
let cur = Cursor {
page_id: "12".to_string(),
from_v: 1,
to_v: 2,
section_path: "/h2#background".to_string(),
};
let cmd = CompareSectionCommand {
cursor: cur.encode().unwrap(),
format: SectionFormatArg::Unified,
};
cmd.execute().await.unwrap();
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
}
#[tokio::test(flavor = "current_thread")]
async fn compare_command_execute_happy_path_via_mock() {
let _lock = env_lock();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"results": [
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]
})))
.mount(&server)
.await;
let mount_page = |version: u32, body: &'static str| {
let s = &server;
async move {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
.and(wiremock::matchers::query_param(
"version",
version.to_string(),
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"id": "12",
"title": "T",
"status": "current",
"spaceId": "98",
"version": {"number": version},
"body": {"atlas_doc_format": {"value": body}}
})))
.mount(s)
.await;
}
};
mount_page(
1,
r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"H"}]},{"type":"paragraph","content":[{"type":"text","text":"v1"}]}]}"#,
)
.await;
mount_page(
2,
r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"H"}]},{"type":"paragraph","content":[{"type":"text","text":"v2"}]}]}"#,
)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"key": "ENG"})))
.mount(&server)
.await;
std::env::set_var("ATLASSIAN_INSTANCE_URL", server.uri());
std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
let cmd = CompareCommand {
id: "12".to_string(),
from: "previous".to_string(),
to: "latest".to_string(),
detail: DetailArg::Outline,
include: "body,title,metadata".to_string(),
ignore_whitespace: true,
min_change_chars: 0,
filter_sections: Vec::new(),
budget: DEFAULT_OUTPUT_BUDGET,
output: OutputFormat::Yaml,
};
cmd.execute().await.unwrap();
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
}
#[tokio::test(flavor = "current_thread")]
async fn compare_section_command_execute_invalid_cursor_errors() {
let _lock = env_lock();
std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
let cmd = CompareSectionCommand {
cursor: "!!!not-a-cursor".to_string(),
format: SectionFormatArg::Unified,
};
let err = cmd.execute().await.unwrap_err();
assert!(err.to_string().contains("Invalid --cursor"));
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
}
#[tokio::test(flavor = "current_thread")]
async fn compare_command_group_execute_dispatches_run() {
let _lock = env_lock();
std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
let group = CompareCommandGroup {
command: CompareSubcommands::Run(CompareCommand {
id: "12".to_string(),
from: "previous".to_string(),
to: "latest".to_string(),
detail: DetailArg::Outline,
include: "body".to_string(),
ignore_whitespace: true,
min_change_chars: 0,
filter_sections: Vec::new(),
budget: DEFAULT_OUTPUT_BUDGET,
output: OutputFormat::Yaml,
}),
};
let _ = group.execute().await;
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
}
#[tokio::test(flavor = "current_thread")]
async fn compare_command_group_execute_dispatches_section() {
let _lock = env_lock();
std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
let cur = Cursor {
page_id: "12".to_string(),
from_v: 1,
to_v: 2,
section_path: "/h2#background".to_string(),
};
let group = CompareCommandGroup {
command: CompareSubcommands::Section(CompareSectionCommand {
cursor: cur.encode().unwrap(),
format: SectionFormatArg::Unified,
}),
};
let _ = group.execute().await;
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
}
#[tokio::test]
async fn compare_command_run_prints_yaml_for_default_output() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]),
)
.await;
mount_page(&server, "12", 1, "T", "v1").await;
mount_page(&server, "12", 2, "T", "v2").await;
mount_space(&server).await;
let mut c = cmd("12");
c.output = OutputFormat::Table;
c.run(&api, &server.uri()).await.unwrap();
}
#[tokio::test]
async fn compare_command_run_yaml_explicit_output() {
let (server, api) = setup_api().await;
mount_versions(
&server,
"12",
json!([
{"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
{"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
]),
)
.await;
mount_page(&server, "12", 1, "T", "v1").await;
mount_page(&server, "12", 2, "T", "v2").await;
mount_space(&server).await;
let c = cmd("12");
c.run(&api, &server.uri()).await.unwrap();
}
#[test]
fn compare_output_jsonl_emits_single_line() {
use crate::atlassian::diff_format::{
ByKind, CompareOutput, NetCounts, PageHeader, SummaryBlock,
};
let out = CompareOutput {
page: PageHeader {
id: "1".to_string(),
title: "T".to_string(),
url: None,
},
versions: None,
summary: SummaryBlock {
total_changes: 0,
by_kind: ByKind::default(),
net: NetCounts::default(),
},
title_change: None,
labels: None,
sections: Vec::new(),
truncated: false,
continuation: None,
};
let mut buf: Vec<u8> = Vec::new();
out.write_jsonl(&mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert_eq!(s.lines().count(), 1);
assert!(s.contains("\"id\":\"1\""));
}
}