Skip to main content

omni_dev/cli/atlassian/confluence/
compare.rs

1//! CLI command for diffing two Confluence page versions.
2//!
3//! Wires up [`crate::atlassian::diff`] (structural ADF diff) and
4//! [`crate::atlassian::diff_format`] (output rendering) on top of the
5//! Confluence v2 API.
6
7use 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/// Compares two versions of a Confluence page.
24#[derive(Parser)]
25pub struct CompareCommand {
26    /// Confluence page ID.
27    pub id: String,
28
29    /// `from` version reference. Accepts `latest`, `previous`, `v-N`,
30    /// a numeric version, or an ISO 8601 date.
31    #[arg(long, default_value = "previous")]
32    pub from: String,
33
34    /// `to` version reference. Same accepted forms as `--from`.
35    #[arg(long, default_value = "latest")]
36    pub to: String,
37
38    /// Detail level: `summary`, `outline`, or `full`.
39    #[arg(long, value_enum, default_value_t = DetailArg::Outline)]
40    pub detail: DetailArg,
41
42    /// Top-level fields to include. Comma-separated. Accepted values:
43    /// `body`, `title`, `labels`, `metadata`. Defaults to `body,title,metadata`.
44    #[arg(long, default_value = "body,title,metadata")]
45    pub include: String,
46
47    /// When set, runs of whitespace inside text nodes are collapsed to a
48    /// single space before diffing.
49    #[arg(long, default_value_t = true)]
50    pub ignore_whitespace: bool,
51
52    /// Drop section deltas with fewer than this many characters of total
53    /// changed text. `0` disables the filter.
54    #[arg(long, default_value_t = 0)]
55    pub min_change_chars: u32,
56
57    /// Restrict to sections whose path matches one of the given strings.
58    /// Repeatable: `--filter-section /h2#a --filter-section /h2#b`.
59    #[arg(long = "filter-section")]
60    pub filter_sections: Vec<String>,
61
62    /// Output budget in bytes. Defaults to ~16 KiB (≈4000 tokens).
63    #[arg(long, default_value_t = DEFAULT_OUTPUT_BUDGET)]
64    pub budget: usize,
65
66    /// Output format. `yaml` is the most useful target for AI agents.
67    //
68    // Intentionally defaults to `Yaml` rather than the `Table` used by sibling
69    // `-o/--output` commands: a diff summary is consumed by agents, not read as
70    // a table. This deviation was reviewed and kept under #1125.
71    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Yaml)]
72    pub output: OutputFormat,
73}
74
75/// Detail level (CLI surface for [`Detail`]).
76#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
77pub enum DetailArg {
78    /// Counts only.
79    Summary,
80    /// Per-section change kind, one-line summaries, drill-in cursors.
81    Outline,
82    /// Embed per-section deltas. Budget-truncated.
83    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/// Drill-in subcommand: returns a per-section diff in a chosen text format.
97#[derive(Parser)]
98pub struct CompareSectionCommand {
99    /// Cursor returned by an outline-mode `confluence compare` call.
100    #[arg(long)]
101    pub cursor: String,
102
103    /// Output text format.
104    #[arg(long, value_enum, default_value_t = SectionFormatArg::Unified)]
105    pub format: SectionFormatArg,
106}
107
108/// Output format for a single section diff (CLI surface for [`SectionFormat`]).
109#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
110pub enum SectionFormatArg {
111    /// Unified diff (`+`/`-` markers).
112    Unified,
113    /// Side-by-side: `from` on the left, `to` on the right.
114    SideBySide,
115    /// Markdown with inline `+added+` / `~~removed~~` markers.
116    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/// Top-level compare command grouping (`compare` + `compare-section`).
130#[derive(Parser)]
131pub struct CompareCommandGroup {
132    /// Subcommand to dispatch.
133    #[command(subcommand)]
134    pub command: CompareSubcommands,
135}
136
137/// Subcommands inside the compare group.
138#[derive(Subcommand)]
139pub enum CompareSubcommands {
140    /// Diff two versions of a Confluence page (mirrors the `confluence_compare` MCP tool).
141    Run(CompareCommand),
142    /// Drill in to a section diff using a cursor from a prior `run` (mirrors the `confluence_compare_section` MCP tool).
143    Section(CompareSectionCommand),
144}
145
146impl CompareCommandGroup {
147    /// Dispatches to the requested subcommand.
148    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    /// Executes the command end-to-end (network + render + output).
158    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        // Default (non-`-o` form) prints YAML.
171        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
183/// Builds a [`CompareOutput`] for the given page and version pair. Used by
184/// both the CLI and the MCP tool so the schema stays in lockstep.
185pub async fn run_compare(
186    api: &ConfluenceApi,
187    instance_url: &str,
188    cmd: &CompareCommand,
189) -> Result<CompareOutput> {
190    // 1. List versions (single fetch — feeds both `from` and `to` resolvers).
191    //    Cap at 200 versions; pages with deeper history must use explicit
192    //    numeric refs.
193    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    // 2. Resolve `to` first (with anchor = latest), then `from` relative to `to`.
202    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    // 3. Fetch each version. Both use `body-format=atlas_doc_format`.
214    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    // 4. Convert ADF JSON values to AdfDocument trees.
220    let from_doc = adf_from_item_body(&from_item, "from")?;
221    let to_doc = adf_from_item_body(&to_item, "to")?;
222
223    // 5. Run structural diff.
224    let opts = DiffOptions {
225        ignore_whitespace: cmd.ignore_whitespace,
226    };
227    let diff = diff_documents(&from_doc, &to_doc, &opts);
228
229    // 6. Build the render context.
230    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    // 7. Build filter and renderer arguments.
247    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    /// Executes the section drill-in.
318    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
328/// Re-fetches the cursor's version pair and renders a single section's
329/// diff in the requested format.
330pub 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        // Background section was modified (12 → 14).
495        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    // ── SectionFormatArg::From ────────────────────────────────────
604
605    #[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    // ── parse_includes: empty / unknown ───────────────────────────
622
623    #[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    // ── version_info_for: hit and miss ────────────────────────────
636
637    #[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    // ── page_url ──────────────────────────────────────────────────
661
662    #[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    // ── run_compare error: failed list_versions ───────────────────
718
719    #[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        // The error from list_page_versions is wrapped in a context.
733        assert!(
734            err.to_string().contains("Failed to list page versions"),
735            "got: {err}"
736        );
737    }
738
739    // ── run_compare error: bad include ────────────────────────────
740
741    #[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    // ── run_compare error: bad to ─────────────────────────────────
763
764    #[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    // ── run_compare error: bad from ───────────────────────────────
782
783    #[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    // ── adf_from_item_body: missing body returns default ─────────
802
803    #[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            // Wrong shape — missing required fields.
828            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    // ── run_compare_section returns rendered text ─────────────────
841
842    #[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    // ── execute paths via env-based create_client ─────────────────
861
862    /// Sets Atlassian credentials, runs a closure that may call
863    /// `create_client()`, then unsets credentials. Tests serialize on the one
864    /// canonical env mutex (issue #950) because env vars are process-global —
865    /// an independent lock would not exclude the other Atlassian credential
866    /// tests.
867    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        // Allow this to fail (no real server); we only care that the dispatch
893        // line runs and exercise create_client + the run path.
894        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        // Pointing ATLASSIAN_INSTANCE_URL at a real (mock) server lets the
928        // happy-path body of `CompareSectionCommand::execute` run all the way
929        // through `println!` + `Ok(())` — exercises the success branch that
930        // the dispatch-only test cannot reach.
931        let _lock = env_lock();
932        let server = wiremock::MockServer::start().await;
933        // Both versions for the cursor.
934        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        // Drives `CompareCommand::execute` all the way through to the
995        // YAML-print branch using a real wiremock server.
996        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    // ── CompareCommand::run prints YAML on default output ─────────
1142
1143    #[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        // Force the YAML default-print path (not handled by output_as).
1161        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    // ── JsonlSerialize for CompareOutput ──────────────────────────
1186
1187    #[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}