1use anyhow::{anyhow, Context, Result};
8use clap::{Parser, Subcommand, ValueEnum};
9
10use crate::atlassian::adf::AdfDocument;
11use crate::atlassian::api::ContentMetadata;
12use crate::atlassian::confluence_api::{resolve_version, ConfluenceApi};
13use crate::atlassian::confluence_types::PageVersion;
14use crate::atlassian::diff::{diff_documents, DiffOptions};
15use crate::atlassian::diff_format::{
16 render, render_section, CompareContext, CompareOutput, Cursor, Detail, Filter, Includes,
17 SectionFormat, VersionInfo, DEFAULT_OUTPUT_BUDGET,
18};
19use crate::cli::atlassian::format::{output_as, JsonlSerialize, OutputFormat};
20use crate::cli::atlassian::helpers::create_client;
21use crate::data::yaml::to_yaml;
22
23#[derive(Parser)]
25pub struct CompareCommand {
26 pub id: String,
28
29 #[arg(long, default_value = "previous")]
32 pub from: String,
33
34 #[arg(long, default_value = "latest")]
36 pub to: String,
37
38 #[arg(long, value_enum, default_value_t = DetailArg::Outline)]
40 pub detail: DetailArg,
41
42 #[arg(long, default_value = "body,title,metadata")]
45 pub include: String,
46
47 #[arg(long, default_value_t = true)]
50 pub ignore_whitespace: bool,
51
52 #[arg(long, default_value_t = 0)]
55 pub min_change_chars: u32,
56
57 #[arg(long = "filter-section")]
60 pub filter_sections: Vec<String>,
61
62 #[arg(long, default_value_t = DEFAULT_OUTPUT_BUDGET)]
64 pub budget: usize,
65
66 #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Yaml)]
72 pub output: OutputFormat,
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
77pub enum DetailArg {
78 Summary,
80 Outline,
82 Full,
84}
85
86impl From<DetailArg> for Detail {
87 fn from(d: DetailArg) -> Self {
88 match d {
89 DetailArg::Summary => Self::Summary,
90 DetailArg::Outline => Self::Outline,
91 DetailArg::Full => Self::Full,
92 }
93 }
94}
95
96#[derive(Parser)]
98pub struct CompareSectionCommand {
99 #[arg(long)]
101 pub cursor: String,
102
103 #[arg(long, value_enum, default_value_t = SectionFormatArg::Unified)]
105 pub format: SectionFormatArg,
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
110pub enum SectionFormatArg {
111 Unified,
113 SideBySide,
115 MarkdownInline,
117}
118
119impl From<SectionFormatArg> for SectionFormat {
120 fn from(f: SectionFormatArg) -> Self {
121 match f {
122 SectionFormatArg::Unified => Self::Unified,
123 SectionFormatArg::SideBySide => Self::SideBySide,
124 SectionFormatArg::MarkdownInline => Self::MarkdownInline,
125 }
126 }
127}
128
129#[derive(Parser)]
131pub struct CompareCommandGroup {
132 #[command(subcommand)]
134 pub command: CompareSubcommands,
135}
136
137#[derive(Subcommand)]
139pub enum CompareSubcommands {
140 Run(CompareCommand),
142 Section(CompareSectionCommand),
144}
145
146impl CompareCommandGroup {
147 pub async fn execute(self) -> Result<()> {
149 match self.command {
150 CompareSubcommands::Run(cmd) => cmd.execute().await,
151 CompareSubcommands::Section(cmd) => cmd.execute().await,
152 }
153 }
154}
155
156impl CompareCommand {
157 pub async fn execute(self) -> Result<()> {
159 let (client, instance_url) = create_client()?;
160 let api = ConfluenceApi::new(client);
161 self.run(&api, &instance_url).await
162 }
163
164 async fn run(self, api: &ConfluenceApi, instance_url: &str) -> Result<()> {
165 let output = self.output.clone();
166 let compare = run_compare(api, instance_url, &self).await?;
167 if output_as(&compare, &output)? {
168 return Ok(());
169 }
170 let yaml = to_yaml(&compare)?;
172 println!("{yaml}");
173 Ok(())
174 }
175}
176
177impl JsonlSerialize for CompareOutput {
178 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
179 crate::cli::atlassian::format::write_scalar_jsonl(self, out)
180 }
181}
182
183pub async fn run_compare(
186 api: &ConfluenceApi,
187 instance_url: &str,
188 cmd: &CompareCommand,
189) -> Result<CompareOutput> {
190 let (versions, _truncated) = api
194 .list_page_versions(&cmd.id, None, 200)
195 .await
196 .context("Failed to list page versions")?;
197 if versions.is_empty() {
198 anyhow::bail!("Page {} has no version history", cmd.id);
199 }
200
201 let latest = versions[0].number;
203 let to_v = resolve_version(&cmd.to, &versions, latest)
204 .with_context(|| format!("Failed to resolve --to \"{}\"", cmd.to))?;
205 let from_v = resolve_version(&cmd.from, &versions, to_v)
206 .with_context(|| format!("Failed to resolve --from \"{}\"", cmd.from))?;
207 if from_v == to_v {
208 anyhow::bail!(
209 "--from and --to resolved to the same version ({from_v}); nothing to compare"
210 );
211 }
212
213 let (from_item, to_item) = tokio::try_join!(
215 api.get_page_at_version(&cmd.id, from_v),
216 api.get_page_at_version(&cmd.id, to_v),
217 )?;
218
219 let from_doc = adf_from_item_body(&from_item, "from")?;
221 let to_doc = adf_from_item_body(&to_item, "to")?;
222
223 let opts = DiffOptions {
225 ignore_whitespace: cmd.ignore_whitespace,
226 };
227 let diff = diff_documents(&from_doc, &to_doc, &opts);
228
229 let from_v_meta = version_info_for(&versions, from_v);
231 let to_v_meta = version_info_for(&versions, to_v);
232 let url = page_url(instance_url, &to_item);
233
234 let ctx = CompareContext {
235 page_id: cmd.id.clone(),
236 page_title: to_item.title.clone(),
237 page_url: url,
238 from_version: from_v_meta,
239 to_version: to_v_meta,
240 from_title: from_item.title,
241 to_title: to_item.title,
242 from_labels: Vec::new(),
243 to_labels: Vec::new(),
244 };
245
246 let includes = parse_includes(&cmd.include)?;
248 let filter = Filter {
249 sections: cmd.filter_sections.clone(),
250 min_change_chars: cmd.min_change_chars,
251 kinds: Vec::new(),
252 };
253
254 render(diff, &ctx, cmd.detail.into(), includes, &filter, cmd.budget)
255}
256
257fn version_info_for(versions: &[PageVersion], n: u32) -> VersionInfo {
258 versions
259 .iter()
260 .find(|v| v.number == n)
261 .map(|v| VersionInfo {
262 number: v.number,
263 created_at: v.created_at.clone(),
264 author: v.author_id.clone(),
265 message: v.message.clone(),
266 })
267 .unwrap_or(VersionInfo {
268 number: n,
269 ..VersionInfo::default()
270 })
271}
272
273fn adf_from_item_body(
274 item: &crate::atlassian::api::ContentItem,
275 side: &str,
276) -> Result<AdfDocument> {
277 match &item.body_adf {
278 Some(value) => serde_json::from_value(value.clone())
279 .with_context(|| format!("Failed to parse ADF document for {side} version")),
280 None => Ok(AdfDocument::default()),
281 }
282}
283
284fn page_url(instance_url: &str, item: &crate::atlassian::api::ContentItem) -> Option<String> {
285 if let ContentMetadata::Confluence { space_key, .. } = &item.metadata {
286 if !space_key.is_empty() {
287 return Some(format!(
288 "{instance_url}/wiki/spaces/{space_key}/pages/{}",
289 item.id
290 ));
291 }
292 }
293 None
294}
295
296fn parse_includes(spec: &str) -> Result<Includes> {
297 let mut inc = Includes {
298 body: false,
299 title: false,
300 labels: false,
301 metadata: false,
302 };
303 for raw in spec.split(',') {
304 match raw.trim().to_ascii_lowercase().as_str() {
305 "body" => inc.body = true,
306 "title" => inc.title = true,
307 "labels" => inc.labels = true,
308 "metadata" => inc.metadata = true,
309 "" => {}
310 other => return Err(anyhow!("Unknown include flag \"{other}\"")),
311 }
312 }
313 Ok(inc)
314}
315
316impl CompareSectionCommand {
317 pub async fn execute(self) -> Result<()> {
319 let (client, _instance_url) = create_client()?;
320 let api = ConfluenceApi::new(client);
321 let cur = Cursor::decode(&self.cursor).context("Invalid --cursor")?;
322 let text = run_compare_section(&api, &cur, self.format.into()).await?;
323 println!("{text}");
324 Ok(())
325 }
326}
327
328pub async fn run_compare_section(
331 api: &ConfluenceApi,
332 cur: &Cursor,
333 format: SectionFormat,
334) -> Result<String> {
335 let (from_item, to_item) = tokio::try_join!(
336 api.get_page_at_version(&cur.page_id, cur.from_v),
337 api.get_page_at_version(&cur.page_id, cur.to_v),
338 )?;
339 let from_doc = adf_from_item_body(&from_item, "from")?;
340 let to_doc = adf_from_item_body(&to_item, "to")?;
341 let opts = DiffOptions {
342 ignore_whitespace: true,
343 };
344 let diff = diff_documents(&from_doc, &to_doc, &opts);
345 render_section(&diff, cur, format)
346}
347
348#[cfg(test)]
349#[allow(clippy::unwrap_used, clippy::expect_used, clippy::await_holding_lock)]
350mod tests {
351 use super::*;
352 use crate::atlassian::client::AtlassianClient;
353 use serde_json::json;
354
355 async fn setup_api() -> (wiremock::MockServer, ConfluenceApi) {
356 let server = wiremock::MockServer::start().await;
357 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
358 let api = ConfluenceApi::new(client);
359 (server, api)
360 }
361
362 fn page_response(id: &str, title: &str, version: u32, body_text: &str) -> serde_json::Value {
363 let adf = format!(
364 r#"{{"version":1,"type":"doc","content":[{{"type":"heading","attrs":{{"level":2}},"content":[{{"type":"text","text":"Background"}}]}},{{"type":"paragraph","content":[{{"type":"text","text":"{body_text}"}}]}}]}}"#
365 );
366 json!({
367 "id": id,
368 "title": title,
369 "status": "current",
370 "spaceId": "98",
371 "version": {"number": version},
372 "body": {
373 "atlas_doc_format": {"value": adf}
374 }
375 })
376 }
377
378 async fn mount_page(
379 server: &wiremock::MockServer,
380 id: &str,
381 version: u32,
382 title: &str,
383 body: &str,
384 ) {
385 wiremock::Mock::given(wiremock::matchers::method("GET"))
386 .and(wiremock::matchers::path(format!("/wiki/api/v2/pages/{id}")))
387 .and(wiremock::matchers::query_param(
388 "version",
389 version.to_string(),
390 ))
391 .respond_with(
392 wiremock::ResponseTemplate::new(200)
393 .set_body_json(page_response(id, title, version, body)),
394 )
395 .mount(server)
396 .await;
397 }
398
399 async fn mount_versions(server: &wiremock::MockServer, id: &str, results: serde_json::Value) {
400 wiremock::Mock::given(wiremock::matchers::method("GET"))
401 .and(wiremock::matchers::path(format!(
402 "/wiki/api/v2/pages/{id}/versions"
403 )))
404 .respond_with(
405 wiremock::ResponseTemplate::new(200).set_body_json(json!({ "results": results })),
406 )
407 .mount(server)
408 .await;
409 }
410
411 async fn mount_space(server: &wiremock::MockServer) {
412 wiremock::Mock::given(wiremock::matchers::method("GET"))
413 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
414 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"key": "ENG"})))
415 .mount(server)
416 .await;
417 }
418
419 fn cmd(id: &str) -> CompareCommand {
420 CompareCommand {
421 id: id.to_string(),
422 from: "previous".to_string(),
423 to: "latest".to_string(),
424 detail: DetailArg::Outline,
425 include: "body,title,metadata".to_string(),
426 ignore_whitespace: true,
427 min_change_chars: 0,
428 filter_sections: Vec::new(),
429 budget: DEFAULT_OUTPUT_BUDGET,
430 output: OutputFormat::Yaml,
431 }
432 }
433
434 #[test]
435 fn detail_arg_to_detail() {
436 assert_eq!(Detail::from(DetailArg::Summary), Detail::Summary);
437 assert_eq!(Detail::from(DetailArg::Outline), Detail::Outline);
438 assert_eq!(Detail::from(DetailArg::Full), Detail::Full);
439 }
440
441 #[test]
442 fn parse_includes_default_set() {
443 let inc = parse_includes("body,title,metadata").unwrap();
444 assert!(inc.body && inc.title && inc.metadata);
445 assert!(!inc.labels);
446 }
447
448 #[test]
449 fn parse_includes_with_labels() {
450 let inc = parse_includes("body,labels").unwrap();
451 assert!(inc.body && inc.labels);
452 assert!(!inc.title && !inc.metadata);
453 }
454
455 #[test]
456 fn parse_includes_unknown_value_errors() {
457 let err = parse_includes("body,attachments").unwrap_err();
458 assert!(err.to_string().contains("Unknown include flag"));
459 }
460
461 #[test]
462 fn parse_includes_handles_whitespace_and_empty_segments() {
463 let inc = parse_includes("body, , title").unwrap();
464 assert!(inc.body && inc.title);
465 }
466
467 #[tokio::test]
468 async fn run_compare_outline_against_mock() {
469 let (server, api) = setup_api().await;
470 mount_versions(
471 &server,
472 "12",
473 json!([
474 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "alice", "message": "v2", "minorEdit": false},
475 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "bob", "message": "v1", "minorEdit": false},
476 ]),
477 )
478 .await;
479 mount_page(&server, "12", 1, "Page v1", "version 12").await;
480 mount_page(&server, "12", 2, "Page v2", "version 14").await;
481 mount_space(&server).await;
482
483 let out = run_compare(&api, &server.uri(), &cmd("12")).await.unwrap();
484 assert_eq!(out.page.id, "12");
485 assert_eq!(out.page.title, "Page v2");
486 assert_eq!(
487 out.versions.as_ref().expect("versions present").from.number,
488 1
489 );
490 assert_eq!(
491 out.versions.as_ref().expect("versions present").to.number,
492 2
493 );
494 assert!(out.summary.by_kind.sections_modified >= 1);
496 let bg = out
497 .sections
498 .iter()
499 .find(|s| s.path == "/h2#background")
500 .expect("background section");
501 assert!(!bg.cursor.is_empty());
502 }
503
504 #[tokio::test]
505 async fn run_compare_same_version_errors() {
506 let (server, api) = setup_api().await;
507 mount_versions(
508 &server,
509 "12",
510 json!([
511 {"number": 5, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
512 ]),
513 )
514 .await;
515 let mut c = cmd("12");
516 c.from = "5".to_string();
517 c.to = "latest".to_string();
518 let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
519 assert!(err.to_string().contains("same version"));
520 }
521
522 #[tokio::test]
523 async fn run_compare_no_versions_errors() {
524 let (server, api) = setup_api().await;
525 mount_versions(&server, "12", json!([])).await;
526 let err = run_compare(&api, &server.uri(), &cmd("12"))
527 .await
528 .unwrap_err();
529 assert!(err.to_string().contains("no version history"));
530 }
531
532 #[tokio::test]
533 async fn run_compare_section_round_trip() {
534 let (server, api) = setup_api().await;
535 mount_page(&server, "12", 1, "T", "version 12").await;
536 mount_page(&server, "12", 2, "T", "version 14").await;
537 mount_space(&server).await;
538
539 let cur = Cursor {
540 page_id: "12".to_string(),
541 from_v: 1,
542 to_v: 2,
543 section_path: "/h2#background".to_string(),
544 };
545 let text = run_compare_section(&api, &cur, SectionFormat::Unified)
546 .await
547 .unwrap();
548 assert!(text.contains("/h2#background"));
549 assert!(text.contains("version 12"));
550 assert!(text.contains("version 14"));
551 }
552
553 #[tokio::test]
554 async fn run_compare_full_includes_diff_payload() {
555 let (server, api) = setup_api().await;
556 mount_versions(
557 &server,
558 "12",
559 json!([
560 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
561 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
562 ]),
563 )
564 .await;
565 mount_page(&server, "12", 1, "T", "version 12").await;
566 mount_page(&server, "12", 2, "T", "version 14").await;
567 mount_space(&server).await;
568
569 let mut c = cmd("12");
570 c.detail = DetailArg::Full;
571 let out = run_compare(&api, &server.uri(), &c).await.unwrap();
572 let bg = out
573 .sections
574 .iter()
575 .find(|s| s.path == "/h2#background")
576 .expect("background section");
577 assert!(!bg.diff.is_empty(), "full mode should embed diff payload");
578 }
579
580 #[tokio::test]
581 async fn run_compare_summary_omits_sections() {
582 let (server, api) = setup_api().await;
583 mount_versions(
584 &server,
585 "12",
586 json!([
587 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
588 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
589 ]),
590 )
591 .await;
592 mount_page(&server, "12", 1, "T", "v1").await;
593 mount_page(&server, "12", 2, "T", "v2").await;
594 mount_space(&server).await;
595
596 let mut c = cmd("12");
597 c.detail = DetailArg::Summary;
598 let out = run_compare(&api, &server.uri(), &c).await.unwrap();
599 assert!(out.sections.is_empty());
600 assert!(out.summary.total_changes >= 1);
601 }
602
603 #[test]
606 fn section_format_arg_to_section_format() {
607 assert_eq!(
608 SectionFormat::from(SectionFormatArg::Unified),
609 SectionFormat::Unified
610 );
611 assert_eq!(
612 SectionFormat::from(SectionFormatArg::SideBySide),
613 SectionFormat::SideBySide
614 );
615 assert_eq!(
616 SectionFormat::from(SectionFormatArg::MarkdownInline),
617 SectionFormat::MarkdownInline
618 );
619 }
620
621 #[test]
624 fn parse_includes_empty_returns_all_disabled() {
625 let inc = parse_includes("").unwrap();
626 assert!(!inc.body && !inc.title && !inc.labels && !inc.metadata);
627 }
628
629 #[test]
630 fn parse_includes_all_four_flags() {
631 let inc = parse_includes("body,title,labels,metadata").unwrap();
632 assert!(inc.body && inc.title && inc.labels && inc.metadata);
633 }
634
635 #[test]
638 fn version_info_for_hit_returns_full_metadata() {
639 let versions = vec![PageVersion {
640 number: 4,
641 created_at: "2026-05-08T10:00:00Z".to_string(),
642 author_id: "alice".to_string(),
643 message: "v4".to_string(),
644 minor_edit: false,
645 }];
646 let info = version_info_for(&versions, 4);
647 assert_eq!(info.number, 4);
648 assert_eq!(info.author, "alice");
649 assert_eq!(info.message, "v4");
650 }
651
652 #[test]
653 fn version_info_for_miss_returns_default_with_number() {
654 let info = version_info_for(&[], 99);
655 assert_eq!(info.number, 99);
656 assert!(info.created_at.is_empty());
657 assert!(info.author.is_empty());
658 }
659
660 #[test]
663 fn page_url_built_when_space_key_present() {
664 use crate::atlassian::api::{ContentItem, ContentMetadata};
665 let item = ContentItem {
666 id: "12".to_string(),
667 title: "T".to_string(),
668 body_adf: None,
669 metadata: ContentMetadata::Confluence {
670 space_key: "ENG".to_string(),
671 status: None,
672 version: Some(1),
673 parent_id: None,
674 },
675 };
676 assert_eq!(
677 page_url("https://x.atlassian.net", &item).as_deref(),
678 Some("https://x.atlassian.net/wiki/spaces/ENG/pages/12")
679 );
680 }
681
682 #[test]
683 fn page_url_none_when_space_key_empty() {
684 use crate::atlassian::api::{ContentItem, ContentMetadata};
685 let item = ContentItem {
686 id: "12".to_string(),
687 title: "T".to_string(),
688 body_adf: None,
689 metadata: ContentMetadata::Confluence {
690 space_key: String::new(),
691 status: None,
692 version: None,
693 parent_id: None,
694 },
695 };
696 assert!(page_url("https://x.atlassian.net", &item).is_none());
697 }
698
699 #[test]
700 fn page_url_none_when_jira_metadata() {
701 use crate::atlassian::api::{ContentItem, ContentMetadata};
702 let item = ContentItem {
703 id: "PROJ-1".to_string(),
704 title: "T".to_string(),
705 body_adf: None,
706 metadata: ContentMetadata::Jira {
707 status: Some("Open".to_string()),
708 issue_type: Some("Bug".to_string()),
709 assignee: None,
710 priority: None,
711 labels: Vec::new(),
712 },
713 };
714 assert!(page_url("https://x.atlassian.net", &item).is_none());
715 }
716
717 #[tokio::test]
720 async fn run_compare_propagates_list_versions_error() {
721 let server = wiremock::MockServer::start().await;
722 wiremock::Mock::given(wiremock::matchers::method("GET"))
723 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
724 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("Not Found"))
725 .mount(&server)
726 .await;
727 let client = AtlassianClient::new(&server.uri(), "u@t.com", "tok").unwrap();
728 let api = ConfluenceApi::new(client);
729 let err = run_compare(&api, &server.uri(), &cmd("12"))
730 .await
731 .unwrap_err();
732 assert!(
734 err.to_string().contains("Failed to list page versions"),
735 "got: {err}"
736 );
737 }
738
739 #[tokio::test]
742 async fn run_compare_propagates_bad_include() {
743 let (server, api) = setup_api().await;
744 mount_versions(
745 &server,
746 "12",
747 json!([
748 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
749 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
750 ]),
751 )
752 .await;
753 mount_page(&server, "12", 1, "T", "v1").await;
754 mount_page(&server, "12", 2, "T", "v2").await;
755 mount_space(&server).await;
756 let mut c = cmd("12");
757 c.include = "body,bogus".to_string();
758 let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
759 assert!(err.to_string().contains("Unknown include flag"));
760 }
761
762 #[tokio::test]
765 async fn run_compare_propagates_bad_to() {
766 let (server, api) = setup_api().await;
767 mount_versions(
768 &server,
769 "12",
770 json!([
771 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
772 ]),
773 )
774 .await;
775 let mut c = cmd("12");
776 c.to = "garbage".to_string();
777 let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
778 assert!(err.to_string().contains("Failed to resolve --to"));
779 }
780
781 #[tokio::test]
784 async fn run_compare_propagates_bad_from() {
785 let (server, api) = setup_api().await;
786 mount_versions(
787 &server,
788 "12",
789 json!([
790 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
791 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
792 ]),
793 )
794 .await;
795 let mut c = cmd("12");
796 c.from = "garbage".to_string();
797 let err = run_compare(&api, &server.uri(), &c).await.unwrap_err();
798 assert!(err.to_string().contains("Failed to resolve --from"));
799 }
800
801 #[test]
804 fn adf_from_item_body_missing_body_returns_default() {
805 use crate::atlassian::api::{ContentItem, ContentMetadata};
806 let item = ContentItem {
807 id: "12".to_string(),
808 title: "T".to_string(),
809 body_adf: None,
810 metadata: ContentMetadata::Confluence {
811 space_key: "ENG".to_string(),
812 status: None,
813 version: None,
814 parent_id: None,
815 },
816 };
817 let doc = adf_from_item_body(&item, "test").unwrap();
818 assert_eq!(doc.content.len(), 0);
819 }
820
821 #[test]
822 fn adf_from_item_body_invalid_json_errors() {
823 use crate::atlassian::api::{ContentItem, ContentMetadata};
824 let item = ContentItem {
825 id: "12".to_string(),
826 title: "T".to_string(),
827 body_adf: Some(json!({"unexpected": "shape"})),
829 metadata: ContentMetadata::Confluence {
830 space_key: "ENG".to_string(),
831 status: None,
832 version: None,
833 parent_id: None,
834 },
835 };
836 let err = adf_from_item_body(&item, "from").unwrap_err();
837 assert!(err.to_string().contains("from"));
838 }
839
840 #[tokio::test]
843 async fn run_compare_section_unified_via_helper() {
844 let (server, api) = setup_api().await;
845 mount_page(&server, "12", 1, "T", "v1").await;
846 mount_page(&server, "12", 2, "T", "v2").await;
847 mount_space(&server).await;
848 let cur = Cursor {
849 page_id: "12".to_string(),
850 from_v: 1,
851 to_v: 2,
852 section_path: "/h2#background".to_string(),
853 };
854 let text = run_compare_section(&api, &cur, SectionFormat::Unified)
855 .await
856 .unwrap();
857 assert!(text.contains("/h2#background"));
858 }
859
860 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
868 crate::atlassian::auth::test_util::AUTH_ENV_MUTEX
869 .lock()
870 .unwrap_or_else(std::sync::PoisonError::into_inner)
871 }
872
873 #[tokio::test(flavor = "current_thread")]
874 async fn compare_command_execute_dispatches_with_creds() {
875 let _lock = env_lock();
876 std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
877 std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
878 std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
879
880 let cmd = CompareCommand {
881 id: "12".to_string(),
882 from: "previous".to_string(),
883 to: "latest".to_string(),
884 detail: DetailArg::Outline,
885 include: "body,title,metadata".to_string(),
886 ignore_whitespace: true,
887 min_change_chars: 0,
888 filter_sections: Vec::new(),
889 budget: DEFAULT_OUTPUT_BUDGET,
890 output: OutputFormat::Yaml,
891 };
892 let _ = cmd.execute().await;
895
896 std::env::remove_var("ATLASSIAN_INSTANCE_URL");
897 std::env::remove_var("ATLASSIAN_EMAIL");
898 std::env::remove_var("ATLASSIAN_API_TOKEN");
899 }
900
901 #[tokio::test(flavor = "current_thread")]
902 async fn compare_section_command_execute_dispatches_with_creds() {
903 let _lock = env_lock();
904 std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
905 std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
906 std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
907
908 let cur = Cursor {
909 page_id: "12".to_string(),
910 from_v: 1,
911 to_v: 2,
912 section_path: "/h2#background".to_string(),
913 };
914 let cmd = CompareSectionCommand {
915 cursor: cur.encode().unwrap(),
916 format: SectionFormatArg::Unified,
917 };
918 let _ = cmd.execute().await;
919
920 std::env::remove_var("ATLASSIAN_INSTANCE_URL");
921 std::env::remove_var("ATLASSIAN_EMAIL");
922 std::env::remove_var("ATLASSIAN_API_TOKEN");
923 }
924
925 #[tokio::test(flavor = "current_thread")]
926 async fn compare_section_command_execute_happy_path_via_mock() {
927 let _lock = env_lock();
932 let server = wiremock::MockServer::start().await;
933 let mount_page = |version: u32, body: &'static str| {
935 let s = &server;
936 async move {
937 wiremock::Mock::given(wiremock::matchers::method("GET"))
938 .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
939 .and(wiremock::matchers::query_param(
940 "version",
941 version.to_string(),
942 ))
943 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
944 "id": "12",
945 "title": "T",
946 "status": "current",
947 "spaceId": "98",
948 "version": {"number": version},
949 "body": {"atlas_doc_format": {"value": body}}
950 })))
951 .mount(s)
952 .await;
953 }
954 };
955 mount_page(
956 1,
957 r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Background"}]},{"type":"paragraph","content":[{"type":"text","text":"v1"}]}]}"#,
958 )
959 .await;
960 mount_page(
961 2,
962 r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Background"}]},{"type":"paragraph","content":[{"type":"text","text":"v2"}]}]}"#,
963 )
964 .await;
965 wiremock::Mock::given(wiremock::matchers::method("GET"))
966 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
967 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"key": "ENG"})))
968 .mount(&server)
969 .await;
970
971 std::env::set_var("ATLASSIAN_INSTANCE_URL", server.uri());
972 std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
973 std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
974
975 let cur = Cursor {
976 page_id: "12".to_string(),
977 from_v: 1,
978 to_v: 2,
979 section_path: "/h2#background".to_string(),
980 };
981 let cmd = CompareSectionCommand {
982 cursor: cur.encode().unwrap(),
983 format: SectionFormatArg::Unified,
984 };
985 cmd.execute().await.unwrap();
986
987 std::env::remove_var("ATLASSIAN_INSTANCE_URL");
988 std::env::remove_var("ATLASSIAN_EMAIL");
989 std::env::remove_var("ATLASSIAN_API_TOKEN");
990 }
991
992 #[tokio::test(flavor = "current_thread")]
993 async fn compare_command_execute_happy_path_via_mock() {
994 let _lock = env_lock();
997 let server = wiremock::MockServer::start().await;
998 wiremock::Mock::given(wiremock::matchers::method("GET"))
999 .and(wiremock::matchers::path("/wiki/api/v2/pages/12/versions"))
1000 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
1001 "results": [
1002 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
1003 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
1004 ]
1005 })))
1006 .mount(&server)
1007 .await;
1008 let mount_page = |version: u32, body: &'static str| {
1009 let s = &server;
1010 async move {
1011 wiremock::Mock::given(wiremock::matchers::method("GET"))
1012 .and(wiremock::matchers::path("/wiki/api/v2/pages/12"))
1013 .and(wiremock::matchers::query_param(
1014 "version",
1015 version.to_string(),
1016 ))
1017 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
1018 "id": "12",
1019 "title": "T",
1020 "status": "current",
1021 "spaceId": "98",
1022 "version": {"number": version},
1023 "body": {"atlas_doc_format": {"value": body}}
1024 })))
1025 .mount(s)
1026 .await;
1027 }
1028 };
1029 mount_page(
1030 1,
1031 r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"H"}]},{"type":"paragraph","content":[{"type":"text","text":"v1"}]}]}"#,
1032 )
1033 .await;
1034 mount_page(
1035 2,
1036 r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"H"}]},{"type":"paragraph","content":[{"type":"text","text":"v2"}]}]}"#,
1037 )
1038 .await;
1039 wiremock::Mock::given(wiremock::matchers::method("GET"))
1040 .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
1041 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"key": "ENG"})))
1042 .mount(&server)
1043 .await;
1044
1045 std::env::set_var("ATLASSIAN_INSTANCE_URL", server.uri());
1046 std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
1047 std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
1048
1049 let cmd = CompareCommand {
1050 id: "12".to_string(),
1051 from: "previous".to_string(),
1052 to: "latest".to_string(),
1053 detail: DetailArg::Outline,
1054 include: "body,title,metadata".to_string(),
1055 ignore_whitespace: true,
1056 min_change_chars: 0,
1057 filter_sections: Vec::new(),
1058 budget: DEFAULT_OUTPUT_BUDGET,
1059 output: OutputFormat::Yaml,
1060 };
1061 cmd.execute().await.unwrap();
1062
1063 std::env::remove_var("ATLASSIAN_INSTANCE_URL");
1064 std::env::remove_var("ATLASSIAN_EMAIL");
1065 std::env::remove_var("ATLASSIAN_API_TOKEN");
1066 }
1067
1068 #[tokio::test(flavor = "current_thread")]
1069 async fn compare_section_command_execute_invalid_cursor_errors() {
1070 let _lock = env_lock();
1071 std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
1072 std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
1073 std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
1074
1075 let cmd = CompareSectionCommand {
1076 cursor: "!!!not-a-cursor".to_string(),
1077 format: SectionFormatArg::Unified,
1078 };
1079 let err = cmd.execute().await.unwrap_err();
1080 assert!(err.to_string().contains("Invalid --cursor"));
1081
1082 std::env::remove_var("ATLASSIAN_INSTANCE_URL");
1083 std::env::remove_var("ATLASSIAN_EMAIL");
1084 std::env::remove_var("ATLASSIAN_API_TOKEN");
1085 }
1086
1087 #[tokio::test(flavor = "current_thread")]
1088 async fn compare_command_group_execute_dispatches_run() {
1089 let _lock = env_lock();
1090 std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
1091 std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
1092 std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
1093
1094 let group = CompareCommandGroup {
1095 command: CompareSubcommands::Run(CompareCommand {
1096 id: "12".to_string(),
1097 from: "previous".to_string(),
1098 to: "latest".to_string(),
1099 detail: DetailArg::Outline,
1100 include: "body".to_string(),
1101 ignore_whitespace: true,
1102 min_change_chars: 0,
1103 filter_sections: Vec::new(),
1104 budget: DEFAULT_OUTPUT_BUDGET,
1105 output: OutputFormat::Yaml,
1106 }),
1107 };
1108 let _ = group.execute().await;
1109
1110 std::env::remove_var("ATLASSIAN_INSTANCE_URL");
1111 std::env::remove_var("ATLASSIAN_EMAIL");
1112 std::env::remove_var("ATLASSIAN_API_TOKEN");
1113 }
1114
1115 #[tokio::test(flavor = "current_thread")]
1116 async fn compare_command_group_execute_dispatches_section() {
1117 let _lock = env_lock();
1118 std::env::set_var("ATLASSIAN_INSTANCE_URL", "http://127.0.0.1:1");
1119 std::env::set_var("ATLASSIAN_EMAIL", "u@t.com");
1120 std::env::set_var("ATLASSIAN_API_TOKEN", "fake");
1121
1122 let cur = Cursor {
1123 page_id: "12".to_string(),
1124 from_v: 1,
1125 to_v: 2,
1126 section_path: "/h2#background".to_string(),
1127 };
1128 let group = CompareCommandGroup {
1129 command: CompareSubcommands::Section(CompareSectionCommand {
1130 cursor: cur.encode().unwrap(),
1131 format: SectionFormatArg::Unified,
1132 }),
1133 };
1134 let _ = group.execute().await;
1135
1136 std::env::remove_var("ATLASSIAN_INSTANCE_URL");
1137 std::env::remove_var("ATLASSIAN_EMAIL");
1138 std::env::remove_var("ATLASSIAN_API_TOKEN");
1139 }
1140
1141 #[tokio::test]
1144 async fn compare_command_run_prints_yaml_for_default_output() {
1145 let (server, api) = setup_api().await;
1146 mount_versions(
1147 &server,
1148 "12",
1149 json!([
1150 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
1151 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
1152 ]),
1153 )
1154 .await;
1155 mount_page(&server, "12", 1, "T", "v1").await;
1156 mount_page(&server, "12", 2, "T", "v2").await;
1157 mount_space(&server).await;
1158
1159 let mut c = cmd("12");
1160 c.output = OutputFormat::Table;
1162 c.run(&api, &server.uri()).await.unwrap();
1163 }
1164
1165 #[tokio::test]
1166 async fn compare_command_run_yaml_explicit_output() {
1167 let (server, api) = setup_api().await;
1168 mount_versions(
1169 &server,
1170 "12",
1171 json!([
1172 {"number": 2, "createdAt": "2026-05-08T10:00:00Z", "authorId": "a", "message": "", "minorEdit": false},
1173 {"number": 1, "createdAt": "2026-05-07T10:00:00Z", "authorId": "b", "message": "", "minorEdit": false},
1174 ]),
1175 )
1176 .await;
1177 mount_page(&server, "12", 1, "T", "v1").await;
1178 mount_page(&server, "12", 2, "T", "v2").await;
1179 mount_space(&server).await;
1180
1181 let c = cmd("12");
1182 c.run(&api, &server.uri()).await.unwrap();
1183 }
1184
1185 #[test]
1188 fn compare_output_jsonl_emits_single_line() {
1189 use crate::atlassian::diff_format::{
1190 ByKind, CompareOutput, NetCounts, PageHeader, SummaryBlock,
1191 };
1192 let out = CompareOutput {
1193 page: PageHeader {
1194 id: "1".to_string(),
1195 title: "T".to_string(),
1196 url: None,
1197 },
1198 versions: None,
1199 summary: SummaryBlock {
1200 total_changes: 0,
1201 by_kind: ByKind::default(),
1202 net: NetCounts::default(),
1203 },
1204 title_change: None,
1205 labels: None,
1206 sections: Vec::new(),
1207 truncated: false,
1208 continuation: None,
1209 };
1210 let mut buf: Vec<u8> = Vec::new();
1211 out.write_jsonl(&mut buf).unwrap();
1212 let s = String::from_utf8(buf).unwrap();
1213 assert_eq!(s.lines().count(), 1);
1214 assert!(s.contains("\"id\":\"1\""));
1215 }
1216}