Skip to main content

omni_dev/atlassian/
inline_comment.rs

1//! Inline-comment drift auditing and re-anchoring for Confluence pages.
2//!
3//! Confluence inline comments are anchored to a run of characters in the page
4//! ADF via an `annotation` mark carrying an `id`. When the underlying text is
5//! edited, Confluence keeps the mark on whatever *original characters* survive —
6//! it does not follow the *meaning* of the annotated text. Substantive rewrites
7//! therefore leave inline comments "torn" across disjoint fragments, slid onto
8//! unrelated text, or dropped entirely.
9//!
10//! This module provides:
11//!
12//! - [`audit_inline_comments`] — read-only: for every inline comment on a page,
13//!   compare the text currently bearing its annotation mark against the
14//!   reviewer's original highlight (`inlineOriginalSelection`, stored durably on
15//!   the comment) and classify the drift ([`DriftStatus`]).
16//! - [`reanchor_inline_comment`] — write: move a comment's annotation mark to a
17//!   new run in the current-version ADF and PUT the page back in one update.
18//!
19//! Both operate directly on the typed ADF (`atlas_doc_format`) — never via JFM,
20//! which would lose the mark structure the anchor depends on.
21
22use anyhow::{bail, Context, Result};
23use serde::Serialize;
24
25use crate::atlassian::adf::{AdfDocument, AdfMark, AdfNode};
26use crate::atlassian::adf_validated::ValidatedAdfDocument;
27use crate::atlassian::api::AtlassianApi;
28use crate::atlassian::confluence_api::ConfluenceApi;
29use crate::atlassian::convert::adf_to_plain_text;
30
31// ── Drift classification ────────────────────────────────────────────
32
33/// How an inline comment's anchor relates to the reviewer's original highlight.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum DriftStatus {
37    /// A single run still bears the mark and its text matches the original
38    /// highlight — the anchor is healthy.
39    Ok,
40    /// The mark survives and the joined text still matches the original, but it
41    /// is split across multiple disjoint runs (a "torn" anchor). Cosmetic; the
42    /// comment still points at the right words.
43    Torn,
44    /// No run in the current ADF bears the mark — Confluence dropped it during
45    /// an edit. The comment no longer highlights anything.
46    MarkLost,
47    /// One or more runs bear the mark but their joined text no longer matches
48    /// the original highlight — the anchor has drifted onto different text.
49    Drifted,
50}
51
52/// Per-comment drift report produced by [`audit_inline_comments`].
53#[derive(Debug, Clone, Serialize)]
54pub struct CommentDrift {
55    /// The inline comment's ID.
56    pub comment_id: String,
57    /// Comment author account ID.
58    pub author: String,
59    /// ISO 8601 creation timestamp.
60    pub created: String,
61    /// The `annotation`-mark `id` this comment is anchored to.
62    pub marker_ref: String,
63    /// Drift classification.
64    pub status: DriftStatus,
65    /// The plaintext the reviewer originally highlighted.
66    pub original_selection: String,
67    /// The text currently bearing the annotation mark (joined across runs).
68    /// `None` when the mark was lost.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub current_anchored_text: Option<String>,
71    /// A suggested new anchor for a drifted/lost comment: the original
72    /// selection, when it still appears verbatim in the current page text.
73    /// `None` when it no longer appears (low confidence — a human should pick).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub suggested_new_anchor: Option<String>,
76    /// How many times `suggested_new_anchor` appears in the current page text
77    /// (so the caller can pass a match index to [`reanchor_inline_comment`]).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub suggested_match_count: Option<usize>,
80}
81
82/// Result of a successful [`reanchor_inline_comment`] call.
83#[derive(Debug, Clone, Serialize)]
84pub struct ReanchorOutcome {
85    /// The re-anchored comment's ID.
86    pub comment_id: String,
87    /// The annotation-mark `id` that was moved.
88    pub marker_ref: String,
89    /// The text the mark was moved to.
90    pub new_anchor_text: String,
91    /// The text that previously bore the mark (joined across runs), if any.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub previous_anchored_text: Option<String>,
94    /// The reviewer's original highlight, for context.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub original_selection: Option<String>,
97    /// True when this was a dry run — the page was validated and the move
98    /// computed, but no write was performed.
99    pub dry_run: bool,
100}
101
102// ── Orchestration ───────────────────────────────────────────────────
103
104/// Audits every inline comment on `page_id` for anchor drift.
105///
106/// Read-only. Fetches the current ADF and the inline comments (which carry the
107/// durable `inlineMarkerRef` / `inlineOriginalSelection` properties), then for
108/// each comment compares the currently-annotated text against the original
109/// highlight. Comments with no marker reference are skipped.
110pub async fn audit_inline_comments(
111    api: &ConfluenceApi,
112    page_id: &str,
113) -> Result<Vec<CommentDrift>> {
114    let doc = fetch_page_adf(api, page_id).await?;
115    let plain = adf_to_plain_text(&doc);
116    let comments = api.get_page_inline_comments(page_id).await?;
117
118    let mut reports = Vec::new();
119    for comment in comments {
120        let Some(marker) = comment.inline_marker_ref.as_deref() else {
121            continue;
122        };
123        let original = comment
124            .inline_original_selection
125            .clone()
126            .unwrap_or_default();
127
128        let runs = collect_annotation_runs(&doc, marker);
129        let joined = runs.concat();
130        let status = classify(&runs, &joined, &original);
131
132        let (suggested_new_anchor, suggested_match_count) = match status {
133            DriftStatus::Ok | DriftStatus::Torn => (None, None),
134            DriftStatus::MarkLost | DriftStatus::Drifted => {
135                let count = count_non_overlapping(&plain, &original);
136                if !original.is_empty() && count > 0 {
137                    (Some(original.clone()), Some(count))
138                } else {
139                    (None, None)
140                }
141            }
142        };
143
144        reports.push(CommentDrift {
145            comment_id: comment.id,
146            author: comment.author,
147            created: comment.created,
148            marker_ref: marker.to_string(),
149            status,
150            original_selection: original,
151            current_anchored_text: if runs.is_empty() { None } else { Some(joined) },
152            suggested_new_anchor,
153            suggested_match_count,
154        });
155    }
156
157    Ok(reports)
158}
159
160/// Moves the annotation mark of inline comment `comment_id` to a new run of
161/// text (`anchor_text`) in the current-version ADF and writes the page back.
162///
163/// `match_index` (1-based) disambiguates when `anchor_text` occurs more than
164/// once. The mark is removed from every run currently bearing it and re-applied
165/// to the chosen run in a single mutated document, so the page is written in one
166/// PUT — the server never observes a half-applied state.
167///
168/// When `dry_run` is true, the page is fetched, the move is computed, and the
169/// resulting ADF is validated, but no write is performed — so callers can
170/// preview (and surface anchor/validation errors) without mutating the page.
171///
172/// Operates entirely on ADF (`atlas_doc_format`); it never round-trips through
173/// JFM, which would discard the annotation marks the anchor depends on.
174pub async fn reanchor_inline_comment(
175    api: &ConfluenceApi,
176    page_id: &str,
177    comment_id: &str,
178    anchor_text: &str,
179    match_index: Option<usize>,
180    dry_run: bool,
181) -> Result<ReanchorOutcome> {
182    let comments = api.get_page_inline_comments(page_id).await?;
183    let comment = comments
184        .iter()
185        .find(|c| c.id == comment_id)
186        .with_context(|| format!("inline comment {comment_id} not found on page {page_id}"))?;
187    let marker = comment.inline_marker_ref.clone().with_context(|| {
188        format!("comment {comment_id} has no inline marker reference (is it an inline comment?)")
189    })?;
190    let original_selection = comment.inline_original_selection.clone();
191
192    let mut doc = fetch_page_adf(api, page_id).await?;
193
194    let previous_runs = collect_annotation_runs(&doc, &marker);
195    let previous_anchored_text = if previous_runs.is_empty() {
196        None
197    } else {
198        Some(previous_runs.concat())
199    };
200
201    remove_annotation(&mut doc, &marker);
202    apply_annotation(&mut doc, anchor_text, match_index, &marker)?;
203
204    let validated =
205        ValidatedAdfDocument::try_new(doc).context("re-anchored ADF failed schema validation")?;
206    if !dry_run {
207        api.update_content(page_id, &validated, None).await?;
208    }
209
210    Ok(ReanchorOutcome {
211        comment_id: comment_id.to_string(),
212        marker_ref: marker,
213        new_anchor_text: anchor_text.to_string(),
214        previous_anchored_text,
215        original_selection,
216        dry_run,
217    })
218}
219
220/// Fetches the current-version ADF of `page_id` as a typed [`AdfDocument`].
221async fn fetch_page_adf(api: &ConfluenceApi, page_id: &str) -> Result<AdfDocument> {
222    let page = api.get_content(page_id).await?;
223    match page.body_adf {
224        Some(value) => serde_json::from_value(value).context("failed to parse page ADF"),
225        None => Ok(AdfDocument::new()),
226    }
227}
228
229/// Classifies a comment's anchor from its current runs vs. the original highlight.
230fn classify(runs: &[String], joined: &str, original: &str) -> DriftStatus {
231    if runs.is_empty() {
232        DriftStatus::MarkLost
233    } else if normalize(joined) == normalize(original) {
234        if runs.len() == 1 {
235            DriftStatus::Ok
236        } else {
237            DriftStatus::Torn
238        }
239    } else {
240        DriftStatus::Drifted
241    }
242}
243
244// ── ADF mark helpers ────────────────────────────────────────────────
245
246/// Returns the text of each run that bears an `annotation` mark with `marker_id`,
247/// in document order.
248fn collect_annotation_runs(doc: &AdfDocument, marker_id: &str) -> Vec<String> {
249    let mut out = Vec::new();
250    for node in &doc.content {
251        collect_runs_in_node(node, marker_id, &mut out);
252    }
253    out
254}
255
256fn collect_runs_in_node(node: &AdfNode, marker_id: &str, out: &mut Vec<String>) {
257    if is_text_node(node) && has_annotation(node, marker_id) {
258        if let Some(text) = &node.text {
259            out.push(text.clone());
260        }
261    }
262    if let Some(content) = &node.content {
263        for child in content {
264            collect_runs_in_node(child, marker_id, out);
265        }
266    }
267}
268
269/// Removes the `annotation` mark with `marker_id` from every run in the document.
270/// Runs left with no marks have their `marks` collapsed to `None`.
271fn remove_annotation(doc: &mut AdfDocument, marker_id: &str) {
272    for node in &mut doc.content {
273        remove_annotation_in_node(node, marker_id);
274    }
275}
276
277fn remove_annotation_in_node(node: &mut AdfNode, marker_id: &str) {
278    if let Some(marks) = &mut node.marks {
279        marks.retain(|m| !is_target_annotation(m, marker_id));
280        if marks.is_empty() {
281            node.marks = None;
282        }
283    }
284    if let Some(content) = &mut node.content {
285        for child in content {
286            remove_annotation_in_node(child, marker_id);
287        }
288    }
289}
290
291/// State threaded through the mutating anchor-application walk.
292struct ApplyState {
293    anchor: String,
294    marker_id: String,
295    /// 1-based occurrence to annotate.
296    target: usize,
297    /// Occurrences seen so far, in document order.
298    current: usize,
299    /// Set once the target occurrence has been annotated.
300    applied: bool,
301}
302
303/// Adds an `annotation` mark (`marker_id`) to the run of text matching
304/// `anchor_text`.
305///
306/// Supports **cross-run** anchors: a phrase split across sibling text runs by
307/// formatting (e.g. `the **bold** word`) is matched within its inline container
308/// and the covered runs are split at the match boundaries and re-marked. A
309/// selection that crosses a non-text inline node or a block boundary is not
310/// matched (it is not a contiguous run) and yields a "not found" error.
311///
312/// `match_index` (1-based) selects which occurrence to annotate; `None` requires
313/// the anchor to be unique.
314fn apply_annotation(
315    doc: &mut AdfDocument,
316    anchor_text: &str,
317    match_index: Option<usize>,
318    marker_id: &str,
319) -> Result<()> {
320    if anchor_text.is_empty() {
321        bail!("anchor text must not be empty");
322    }
323
324    let total = count_anchor_occurrences(doc, anchor_text);
325    if total == 0 {
326        bail!(
327            "anchor text {anchor_text:?} was not found as a contiguous run of text on the page; \
328             it may span a formatting or block boundary — choose a phrase that appears intact"
329        );
330    }
331
332    let target = match match_index {
333        Some(i) if i == 0 || i > total => bail!(
334            "match index {i} out of range: anchor text {anchor_text:?} appears \
335             {total} time(s) on the page (valid range: 1..={total})"
336        ),
337        Some(i) => i,
338        None if total > 1 => bail!(
339            "anchor text {anchor_text:?} appears {total} times on the page; \
340             specify a 1-based match index to choose which occurrence to anchor to"
341        ),
342        None => 1,
343    };
344
345    let mut state = ApplyState {
346        anchor: anchor_text.to_string(),
347        marker_id: marker_id.to_string(),
348        target,
349        current: 0,
350        applied: false,
351    };
352    apply_in_children(&mut doc.content, &mut state);
353
354    if !state.applied {
355        bail!("internal error: failed to apply annotation for occurrence {target}");
356    }
357    Ok(())
358}
359
360fn apply_in_node(node: &mut AdfNode, state: &mut ApplyState) {
361    if state.applied {
362        return;
363    }
364    if let Some(content) = &mut node.content {
365        apply_in_children(content, state);
366    }
367}
368
369fn apply_in_children(children: &mut Vec<AdfNode>, state: &mut ApplyState) {
370    if state.applied {
371        return;
372    }
373    let old = std::mem::take(children);
374    let mut rebuilt: Vec<AdfNode> = Vec::with_capacity(old.len());
375    let mut span: Vec<AdfNode> = Vec::new();
376
377    for mut child in old {
378        if is_text_node(&child) {
379            span.push(child);
380        } else {
381            flush_span(&mut span, state, &mut rebuilt);
382            apply_in_node(&mut child, state);
383            rebuilt.push(child);
384        }
385    }
386    flush_span(&mut span, state, &mut rebuilt);
387
388    *children = rebuilt;
389}
390
391/// Processes a maximal run of consecutive text-node siblings: searches for the
392/// anchor within their concatenated text and, if the target occurrence falls
393/// here, splits the covered runs and applies the annotation mark.
394fn flush_span(span: &mut Vec<AdfNode>, state: &mut ApplyState, out: &mut Vec<AdfNode>) {
395    if span.is_empty() {
396        return;
397    }
398    let nodes = std::mem::take(span);
399
400    if state.applied {
401        out.extend(nodes);
402        return;
403    }
404
405    // Concatenate the span and record each node's byte range within it.
406    let mut combined = String::new();
407    let mut ranges: Vec<(usize, usize)> = Vec::with_capacity(nodes.len());
408    for node in &nodes {
409        let start = combined.len();
410        combined.push_str(node.text.as_deref().unwrap_or(""));
411        ranges.push((start, combined.len()));
412    }
413
414    // Count occurrences in document order until we reach the target.
415    let mut target_range: Option<(usize, usize)> = None;
416    let mut search_from = 0;
417    while let Some(pos) = combined[search_from..].find(&state.anchor) {
418        let match_start = search_from + pos;
419        let match_end = match_start + state.anchor.len();
420        state.current += 1;
421        if state.current == state.target {
422            target_range = Some((match_start, match_end));
423            state.applied = true;
424            break;
425        }
426        search_from = match_end;
427    }
428
429    match target_range {
430        Some((s, e)) => {
431            for (idx, node) in nodes.iter().enumerate() {
432                let (a, b) = ranges[idx];
433                slice_node(node, a, b, s, e, &state.marker_id, out);
434            }
435        }
436        None => out.extend(nodes),
437    }
438}
439
440/// Emits `node`, split so the portion overlapping the global byte range `[s, e)`
441/// bears the annotation mark. `[a, b)` is `node`'s byte range within the span.
442fn slice_node(
443    node: &AdfNode,
444    a: usize,
445    b: usize,
446    s: usize,
447    e: usize,
448    marker_id: &str,
449    out: &mut Vec<AdfNode>,
450) {
451    let text = node.text.as_deref().unwrap_or("");
452    if text.is_empty() {
453        out.push(node.clone());
454        return;
455    }
456
457    let s_c = s.clamp(a, b);
458    let e_c = e.clamp(a, b);
459    for (lo, hi, annotate) in [(a, s_c, false), (s_c, e_c, true), (e_c, b, false)] {
460        if lo >= hi {
461            continue;
462        }
463        let segment = &text[(lo - a)..(hi - a)];
464        out.push(make_segment(node, segment, annotate, marker_id));
465    }
466}
467
468/// Clones `base` as a text node carrying `text`, optionally adding the
469/// annotation mark on top of `base`'s existing marks.
470fn make_segment(base: &AdfNode, text: &str, annotate: bool, marker_id: &str) -> AdfNode {
471    let mut node = base.clone();
472    node.text = Some(text.to_string());
473    if annotate {
474        let mark = AdfMark::annotation(marker_id, "inlineComment");
475        match &mut node.marks {
476            Some(marks) => {
477                if !marks.iter().any(|m| is_target_annotation(m, marker_id)) {
478                    marks.push(mark);
479                }
480            }
481            None => node.marks = Some(vec![mark]),
482        }
483    }
484    node
485}
486
487/// Counts occurrences of `anchor` across all contiguous text-run spans in the
488/// document, in the same order [`apply_in_children`] visits them.
489fn count_anchor_occurrences(doc: &AdfDocument, anchor: &str) -> usize {
490    let mut total = 0;
491    count_in_children(&doc.content, anchor, &mut total);
492    total
493}
494
495fn count_in_children(children: &[AdfNode], anchor: &str, total: &mut usize) {
496    let mut span = String::new();
497    for child in children {
498        if is_text_node(child) {
499            span.push_str(child.text.as_deref().unwrap_or(""));
500        } else {
501            if !span.is_empty() {
502                *total += count_non_overlapping(&span, anchor);
503                span.clear();
504            }
505            count_in_children_of(child, anchor, total);
506        }
507    }
508    if !span.is_empty() {
509        *total += count_non_overlapping(&span, anchor);
510    }
511}
512
513fn count_in_children_of(node: &AdfNode, anchor: &str, total: &mut usize) {
514    if let Some(content) = &node.content {
515        count_in_children(content, anchor, total);
516    }
517}
518
519// ── Small predicates / string utilities ─────────────────────────────
520
521fn is_text_node(node: &AdfNode) -> bool {
522    node.node_type == "text" && node.text.is_some()
523}
524
525fn has_annotation(node: &AdfNode, marker_id: &str) -> bool {
526    node.marks
527        .iter()
528        .flatten()
529        .any(|m| is_target_annotation(m, marker_id))
530}
531
532fn is_target_annotation(mark: &AdfMark, marker_id: &str) -> bool {
533    mark.mark_type == "annotation"
534        && mark
535            .attrs
536            .as_ref()
537            .and_then(|a| a.get("id"))
538            .and_then(|v| v.as_str())
539            == Some(marker_id)
540}
541
542/// Collapses all runs of whitespace to a single space and trims, so anchored
543/// text and the original highlight compare equal despite incidental whitespace
544/// differences (e.g. a line wrap re-flow).
545fn normalize(text: &str) -> String {
546    text.split_whitespace().collect::<Vec<_>>().join(" ")
547}
548
549/// Counts non-overlapping occurrences of `needle` in `haystack`.
550fn count_non_overlapping(haystack: &str, needle: &str) -> usize {
551    if needle.is_empty() {
552        return 0;
553    }
554    let mut count = 0;
555    let mut start = 0;
556    while let Some(pos) = haystack[start..].find(needle) {
557        count += 1;
558        start += pos + needle.len();
559    }
560    count
561}
562
563#[cfg(test)]
564#[allow(clippy::unwrap_used, clippy::expect_used)]
565mod tests {
566    use super::*;
567
568    fn strong() -> AdfMark {
569        AdfMark {
570            mark_type: "strong".to_string(),
571            attrs: None,
572        }
573    }
574
575    fn annotated(text: &str, id: &str) -> AdfNode {
576        AdfNode::text_with_marks(text, vec![AdfMark::annotation(id, "inlineComment")])
577    }
578
579    fn doc(paras: Vec<Vec<AdfNode>>) -> AdfDocument {
580        AdfDocument {
581            version: 1,
582            doc_type: "doc".to_string(),
583            content: paras.into_iter().map(AdfNode::paragraph).collect(),
584        }
585    }
586
587    // ── collect_annotation_runs ─────────────────────────────────────
588
589    #[test]
590    fn collect_runs_single() {
591        let d = doc(vec![vec![
592            AdfNode::text("The cache TTL is "),
593            annotated("200ms", "aaa"),
594            AdfNode::text(" by default."),
595        ]]);
596        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["200ms"]);
597    }
598
599    #[test]
600    fn collect_runs_torn_across_fragments_in_order() {
601        let d = doc(vec![vec![
602            AdfNode::text("The cache TTL is "),
603            annotated("200ms", "aaa"),
604            AdfNode::text(". Downstream services "),
605            annotated("depend on this", "aaa"),
606            AdfNode::text(" value."),
607        ]]);
608        assert_eq!(
609            collect_annotation_runs(&d, "aaa"),
610            vec!["200ms", "depend on this"]
611        );
612    }
613
614    #[test]
615    fn collect_runs_ignores_other_ids() {
616        let d = doc(vec![vec![annotated("x", "bbb")]]);
617        assert!(collect_annotation_runs(&d, "aaa").is_empty());
618    }
619
620    // ── classify ────────────────────────────────────────────────────
621
622    #[test]
623    fn classify_ok_torn_lost_drifted() {
624        assert_eq!(
625            classify(&["hello".into()], "hello", "hello"),
626            DriftStatus::Ok
627        );
628        assert_eq!(
629            classify(&["hel".into(), "lo".into()], "hello", "hello"),
630            DriftStatus::Torn
631        );
632        assert_eq!(classify(&[], "", "hello"), DriftStatus::MarkLost);
633        assert_eq!(
634            classify(&["goodbye".into()], "goodbye", "hello"),
635            DriftStatus::Drifted
636        );
637    }
638
639    #[test]
640    fn classify_normalizes_whitespace() {
641        assert_eq!(
642            classify(&["a  b\n c".into()], "a  b\n c", "a b c"),
643            DriftStatus::Ok
644        );
645    }
646
647    // ── remove_annotation ───────────────────────────────────────────
648
649    #[test]
650    fn remove_annotation_strips_mark_and_collapses_empty_marks() {
651        let mut d = doc(vec![vec![annotated("200ms", "aaa")]]);
652        remove_annotation(&mut d, "aaa");
653        assert!(collect_annotation_runs(&d, "aaa").is_empty());
654        // The run had only the annotation mark, so marks collapses to None.
655        let run = &d.content[0].content.as_ref().unwrap()[0];
656        assert!(run.marks.is_none());
657        assert_eq!(run.text.as_deref(), Some("200ms"));
658    }
659
660    #[test]
661    fn remove_annotation_preserves_other_marks() {
662        let run = AdfNode::text_with_marks(
663            "x",
664            vec![strong(), AdfMark::annotation("aaa", "inlineComment")],
665        );
666        let mut d = doc(vec![vec![run]]);
667        remove_annotation(&mut d, "aaa");
668        let run = &d.content[0].content.as_ref().unwrap()[0];
669        let marks = run.marks.as_ref().unwrap();
670        assert_eq!(marks.len(), 1);
671        assert_eq!(marks[0].mark_type, "strong");
672    }
673
674    // ── apply_annotation ────────────────────────────────────────────
675
676    fn first_para_runs(d: &AdfDocument) -> &Vec<AdfNode> {
677        d.content[0].content.as_ref().unwrap()
678    }
679
680    #[test]
681    fn apply_annotation_splits_substring_within_single_run() {
682        let mut d = doc(vec![vec![AdfNode::text(
683            "The cache TTL is 200ms by default.",
684        )]]);
685        apply_annotation(&mut d, "200ms", None, "aaa").unwrap();
686        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["200ms"]);
687        // Round-trips: three runs "The cache TTL is " / "200ms" / " by default.".
688        let runs = first_para_runs(&d);
689        assert_eq!(runs.len(), 3);
690        assert_eq!(runs[0].text.as_deref(), Some("The cache TTL is "));
691        assert_eq!(runs[1].text.as_deref(), Some("200ms"));
692        assert_eq!(runs[2].text.as_deref(), Some(" by default."));
693    }
694
695    #[test]
696    fn apply_annotation_cross_run_phrase_split_by_formatting() {
697        // "the bold word" is split across three runs by a strong mark.
698        let mut d = doc(vec![vec![
699            AdfNode::text("say the "),
700            AdfNode::text_with_marks("bold", vec![strong()]),
701            AdfNode::text(" word now"),
702        ]]);
703        apply_annotation(&mut d, "the bold word", None, "aaa").unwrap();
704        // The joined annotated text spans the phrase.
705        assert_eq!(collect_annotation_runs(&d, "aaa").concat(), "the bold word");
706        // The strong mark is preserved on the "bold" run alongside the annotation.
707        let bold_run = first_para_runs(&d)
708            .iter()
709            .find(|n| n.text.as_deref() == Some("bold"))
710            .unwrap();
711        let marks = bold_run.marks.as_ref().unwrap();
712        assert!(marks.iter().any(|m| m.mark_type == "strong"));
713        assert!(marks.iter().any(|m| is_target_annotation(m, "aaa")));
714    }
715
716    #[test]
717    fn apply_annotation_respects_match_index() {
718        let mut d = doc(vec![vec![AdfNode::text("foo bar foo baz foo")]]);
719        apply_annotation(&mut d, "foo", Some(2), "aaa").unwrap();
720        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["foo"]);
721        // The annotated "foo" is the middle occurrence: preceded by "foo bar ".
722        let runs = first_para_runs(&d);
723        let annotated_pos = runs.iter().position(|n| has_annotation(n, "aaa")).unwrap();
724        let before: String = runs[..annotated_pos]
725            .iter()
726            .filter_map(|n| n.text.clone())
727            .collect();
728        assert_eq!(before, "foo bar ");
729    }
730
731    #[test]
732    fn apply_annotation_ambiguous_without_index_errors() {
733        let mut d = doc(vec![vec![AdfNode::text("foo and foo")]]);
734        let err = apply_annotation(&mut d, "foo", None, "aaa").unwrap_err();
735        assert!(err.to_string().contains("appears 2 times"));
736    }
737
738    #[test]
739    fn apply_annotation_not_found_errors() {
740        let mut d = doc(vec![vec![AdfNode::text("hello world")]]);
741        let err = apply_annotation(&mut d, "missing", None, "aaa").unwrap_err();
742        assert!(err.to_string().contains("not found"));
743    }
744
745    #[test]
746    fn apply_annotation_index_out_of_range_errors() {
747        let mut d = doc(vec![vec![AdfNode::text("foo foo")]]);
748        let err = apply_annotation(&mut d, "foo", Some(3), "aaa").unwrap_err();
749        assert!(err.to_string().contains("out of range"));
750    }
751
752    #[test]
753    fn apply_annotation_cross_block_not_matched() {
754        // The phrase spans two paragraphs — not a contiguous run.
755        let mut d = doc(vec![
756            vec![AdfNode::text("end of first")],
757            vec![AdfNode::text("second begins")],
758        ]);
759        let err = apply_annotation(&mut d, "first second", None, "aaa").unwrap_err();
760        assert!(err.to_string().contains("not found"));
761    }
762
763    #[test]
764    fn apply_annotation_matches_occurrence_in_second_block() {
765        let mut d = doc(vec![
766            vec![AdfNode::text("alpha here")],
767            vec![AdfNode::text("beta there")],
768        ]);
769        apply_annotation(&mut d, "beta", None, "aaa").unwrap();
770        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["beta"]);
771    }
772
773    // ── remove + apply round trip (the reanchor core) ───────────────
774
775    #[test]
776    fn reanchor_core_moves_mark_from_old_to_new_run() {
777        // Mark starts torn across two fragments; reanchor should move it whole.
778        let mut d = doc(vec![vec![
779            AdfNode::text("The cache TTL is "),
780            annotated("200ms", "aaa"),
781            AdfNode::text(". Downstream services "),
782            annotated("depend on this", "aaa"),
783            AdfNode::text(" value, so it is fixed."),
784        ]]);
785        remove_annotation(&mut d, "aaa");
786        assert!(collect_annotation_runs(&d, "aaa").is_empty());
787        apply_annotation(
788            &mut d,
789            "Downstream services depend on this value",
790            None,
791            "aaa",
792        )
793        .unwrap();
794        assert_eq!(
795            collect_annotation_runs(&d, "aaa").concat(),
796            "Downstream services depend on this value"
797        );
798    }
799
800    #[test]
801    fn apply_annotation_handles_multibyte_text() {
802        let mut d = doc(vec![vec![AdfNode::text("café serves 200ms résumé")]]);
803        apply_annotation(&mut d, "200ms", None, "aaa").unwrap();
804        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["200ms"]);
805        // Surrounding multibyte text is preserved intact.
806        let joined: String = first_para_runs(&d)
807            .iter()
808            .filter_map(|n| n.text.clone())
809            .collect();
810        assert_eq!(joined, "café serves 200ms résumé");
811    }
812
813    // ── audit / reanchor over a mocked Confluence API ───────────────
814
815    use crate::atlassian::client::AtlassianClient;
816
817    fn mock_api(server: &wiremock::MockServer) -> ConfluenceApi {
818        let client = AtlassianClient::new(&server.uri(), "user@test.com", "token").unwrap();
819        ConfluenceApi::new(client)
820    }
821
822    /// Mounts the page-read endpoints (`get_content` + its space lookup) so the
823    /// page returns `page` as its ADF body.
824    async fn mount_page(server: &wiremock::MockServer, id: &str, page: &AdfDocument) {
825        let value = serde_json::to_string(page).unwrap();
826        wiremock::Mock::given(wiremock::matchers::method("GET"))
827            .and(wiremock::matchers::path(format!("/wiki/api/v2/pages/{id}")))
828            .respond_with(
829                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
830                    "id": id,
831                    "title": "Mock",
832                    "status": "current",
833                    "spaceId": "98",
834                    "version": {"number": 1},
835                    "body": {"atlas_doc_format": {"value": value}}
836                })),
837            )
838            .mount(server)
839            .await;
840        wiremock::Mock::given(wiremock::matchers::method("GET"))
841            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
842            .respond_with(
843                wiremock::ResponseTemplate::new(200)
844                    .set_body_json(serde_json::json!({"key": "ENG"})),
845            )
846            .mount(server)
847            .await;
848    }
849
850    async fn mount_inline_comments(
851        server: &wiremock::MockServer,
852        id: &str,
853        body: serde_json::Value,
854    ) {
855        wiremock::Mock::given(wiremock::matchers::method("GET"))
856            .and(wiremock::matchers::path(format!(
857                "/wiki/api/v2/pages/{id}/inline-comments"
858            )))
859            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
860            .mount(server)
861            .await;
862    }
863
864    #[tokio::test]
865    async fn audit_classifies_ok_mark_lost_and_drifted() {
866        let server = wiremock::MockServer::start().await;
867        let page = doc(vec![vec![
868            AdfNode::text("The cache TTL is "),
869            annotated("200ms", "aaa"),
870            AdfNode::text(" by default. "),
871            annotated("wrong", "bbb"),
872            AdfNode::text(" end."),
873        ]]);
874        mount_page(&server, "12345", &page).await;
875        mount_inline_comments(
876            &server,
877            "12345",
878            serde_json::json!({
879                "results": [
880                    {"id": "ic1", "version": {"authorId": "a", "createdAt": "t"},
881                     "properties": {"inlineMarkerRef": "aaa", "inlineOriginalSelection": "200ms"}},
882                    {"id": "ic2", "version": {"authorId": "b", "createdAt": "t"},
883                     "properties": {"inlineMarkerRef": "zzz", "inlineOriginalSelection": "missing text"}},
884                    {"id": "ic3", "version": {"authorId": "c", "createdAt": "t"},
885                     "properties": {"inlineMarkerRef": "bbb", "inlineOriginalSelection": "200ms"}}
886                ]
887            }),
888        )
889        .await;
890
891        let api = mock_api(&server);
892        let drifts = audit_inline_comments(&api, "12345").await.unwrap();
893        assert_eq!(drifts.len(), 3);
894
895        let ic1 = drifts.iter().find(|d| d.comment_id == "ic1").unwrap();
896        assert_eq!(ic1.status, DriftStatus::Ok);
897        assert_eq!(ic1.current_anchored_text.as_deref(), Some("200ms"));
898
899        let ic2 = drifts.iter().find(|d| d.comment_id == "ic2").unwrap();
900        assert_eq!(ic2.status, DriftStatus::MarkLost);
901        assert!(ic2.current_anchored_text.is_none());
902        assert!(ic2.suggested_new_anchor.is_none());
903
904        let ic3 = drifts.iter().find(|d| d.comment_id == "ic3").unwrap();
905        assert_eq!(ic3.status, DriftStatus::Drifted);
906        assert_eq!(ic3.current_anchored_text.as_deref(), Some("wrong"));
907        assert_eq!(ic3.suggested_new_anchor.as_deref(), Some("200ms"));
908        assert_eq!(ic3.suggested_match_count, Some(1));
909    }
910
911    #[tokio::test]
912    async fn reanchor_moves_mark_and_puts_bumped_version() {
913        let server = wiremock::MockServer::start().await;
914        let page = doc(vec![vec![
915            AdfNode::text("Before "),
916            annotated("old", "aaa"),
917            AdfNode::text(" and 200ms here."),
918        ]]);
919        mount_page(&server, "12345", &page).await;
920        mount_inline_comments(
921            &server,
922            "12345",
923            serde_json::json!({
924                "results": [
925                    {"id": "ic1", "version": {"authorId": "a", "createdAt": "t"},
926                     "properties": {"inlineMarkerRef": "aaa", "inlineOriginalSelection": "old"}}
927                ]
928            }),
929        )
930        .await;
931        wiremock::Mock::given(wiremock::matchers::method("PUT"))
932            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
933            .and(wiremock::matchers::body_partial_json(
934                serde_json::json!({"version": {"number": 2}}),
935            ))
936            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
937            .expect(1)
938            .mount(&server)
939            .await;
940
941        let api = mock_api(&server);
942        let outcome = reanchor_inline_comment(&api, "12345", "ic1", "200ms", None, false)
943            .await
944            .unwrap();
945        assert_eq!(outcome.new_anchor_text, "200ms");
946        assert_eq!(outcome.previous_anchored_text.as_deref(), Some("old"));
947        assert!(!outcome.dry_run);
948
949        // Inspect the PUT body: the mark must now sit on "200ms", not "old".
950        let requests = server.received_requests().await.unwrap();
951        let put = requests
952            .iter()
953            .find(|r| r.method == wiremock::http::Method::PUT)
954            .unwrap();
955        let sent: serde_json::Value = serde_json::from_slice(&put.body).unwrap();
956        let adf_value = sent["body"]["value"].as_str().unwrap();
957        let updated: AdfDocument = serde_json::from_str(adf_value).unwrap();
958        assert_eq!(collect_annotation_runs(&updated, "aaa"), vec!["200ms"]);
959    }
960
961    #[tokio::test]
962    async fn reanchor_dry_run_does_not_put() {
963        let server = wiremock::MockServer::start().await;
964        let page = doc(vec![vec![
965            AdfNode::text("Before "),
966            annotated("old", "aaa"),
967            AdfNode::text(" and 200ms here."),
968        ]]);
969        mount_page(&server, "12345", &page).await;
970        mount_inline_comments(
971            &server,
972            "12345",
973            serde_json::json!({
974                "results": [
975                    {"id": "ic1", "version": {"authorId": "a", "createdAt": "t"},
976                     "properties": {"inlineMarkerRef": "aaa", "inlineOriginalSelection": "old"}}
977                ]
978            }),
979        )
980        .await;
981        // No PUT is mounted: if reanchor tried to write, update_content would 404.
982
983        let api = mock_api(&server);
984        let outcome = reanchor_inline_comment(&api, "12345", "ic1", "200ms", None, true)
985            .await
986            .unwrap();
987        assert!(outcome.dry_run);
988
989        let requests = server.received_requests().await.unwrap();
990        assert!(!requests
991            .iter()
992            .any(|r| r.method == wiremock::http::Method::PUT));
993    }
994
995    #[tokio::test]
996    async fn reanchor_unknown_comment_errors() {
997        let server = wiremock::MockServer::start().await;
998        mount_inline_comments(&server, "12345", serde_json::json!({ "results": [] })).await;
999        let api = mock_api(&server);
1000        let err = reanchor_inline_comment(&api, "12345", "nope", "x", None, false)
1001            .await
1002            .unwrap_err();
1003        assert!(err.to_string().contains("not found"));
1004    }
1005
1006    #[tokio::test]
1007    async fn reanchor_comment_without_marker_ref_errors() {
1008        let server = wiremock::MockServer::start().await;
1009        mount_inline_comments(
1010            &server,
1011            "12345",
1012            serde_json::json!({
1013                "results": [
1014                    {"id": "ic1", "version": {"authorId": "a", "createdAt": "t"}}
1015                ]
1016            }),
1017        )
1018        .await;
1019        let api = mock_api(&server);
1020        let err = reanchor_inline_comment(&api, "12345", "ic1", "x", None, false)
1021            .await
1022            .unwrap_err();
1023        assert!(err.to_string().contains("no inline marker reference"));
1024    }
1025
1026    #[tokio::test]
1027    async fn reanchor_lost_mark_reports_no_previous_text() {
1028        let server = wiremock::MockServer::start().await;
1029        // The page no longer bears marker "aaa" anywhere — the mark was lost.
1030        let page = doc(vec![vec![AdfNode::text("Plain text with 200ms here.")]]);
1031        mount_page(&server, "12345", &page).await;
1032        mount_inline_comments(
1033            &server,
1034            "12345",
1035            serde_json::json!({
1036                "results": [
1037                    {"id": "ic1", "version": {"authorId": "a", "createdAt": "t"},
1038                     "properties": {"inlineMarkerRef": "aaa", "inlineOriginalSelection": "old"}}
1039                ]
1040            }),
1041        )
1042        .await;
1043        wiremock::Mock::given(wiremock::matchers::method("PUT"))
1044            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1045            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1046            .expect(1)
1047            .mount(&server)
1048            .await;
1049
1050        let api = mock_api(&server);
1051        let outcome = reanchor_inline_comment(&api, "12345", "ic1", "200ms", None, false)
1052            .await
1053            .unwrap();
1054        assert!(outcome.previous_anchored_text.is_none());
1055        assert_eq!(outcome.new_anchor_text, "200ms");
1056    }
1057
1058    #[tokio::test]
1059    async fn audit_skips_comments_without_marker_ref() {
1060        let server = wiremock::MockServer::start().await;
1061        let page = doc(vec![vec![annotated("200ms", "aaa")]]);
1062        mount_page(&server, "12345", &page).await;
1063        mount_inline_comments(
1064            &server,
1065            "12345",
1066            serde_json::json!({
1067                "results": [
1068                    {"id": "ic1", "version": {"authorId": "a", "createdAt": "t"}},
1069                    {"id": "ic2", "version": {"authorId": "b", "createdAt": "t"},
1070                     "properties": {"inlineMarkerRef": "aaa", "inlineOriginalSelection": "200ms"}}
1071                ]
1072            }),
1073        )
1074        .await;
1075
1076        let api = mock_api(&server);
1077        let drifts = audit_inline_comments(&api, "12345").await.unwrap();
1078        // ic1 has no marker reference and is skipped.
1079        assert_eq!(drifts.len(), 1);
1080        assert_eq!(drifts[0].comment_id, "ic2");
1081    }
1082
1083    #[tokio::test]
1084    async fn audit_page_without_adf_body_reports_mark_lost() {
1085        let server = wiremock::MockServer::start().await;
1086        // Page response carries no `body` — `fetch_page_adf` yields an empty doc.
1087        wiremock::Mock::given(wiremock::matchers::method("GET"))
1088            .and(wiremock::matchers::path("/wiki/api/v2/pages/12345"))
1089            .respond_with(
1090                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1091                    "id": "12345",
1092                    "title": "Mock",
1093                    "status": "current",
1094                    "spaceId": "98",
1095                    "version": {"number": 1}
1096                })),
1097            )
1098            .mount(&server)
1099            .await;
1100        wiremock::Mock::given(wiremock::matchers::method("GET"))
1101            .and(wiremock::matchers::path("/wiki/api/v2/spaces/98"))
1102            .respond_with(
1103                wiremock::ResponseTemplate::new(200)
1104                    .set_body_json(serde_json::json!({"key": "ENG"})),
1105            )
1106            .mount(&server)
1107            .await;
1108        mount_inline_comments(
1109            &server,
1110            "12345",
1111            serde_json::json!({
1112                "results": [
1113                    {"id": "ic1", "version": {"authorId": "a", "createdAt": "t"},
1114                     "properties": {"inlineMarkerRef": "aaa", "inlineOriginalSelection": "200ms"}}
1115                ]
1116            }),
1117        )
1118        .await;
1119
1120        let api = mock_api(&server);
1121        let drifts = audit_inline_comments(&api, "12345").await.unwrap();
1122        assert_eq!(drifts.len(), 1);
1123        assert_eq!(drifts[0].status, DriftStatus::MarkLost);
1124        // The empty page contains no occurrence of the original selection.
1125        assert!(drifts[0].suggested_new_anchor.is_none());
1126    }
1127
1128    // ── edge branches in the anchor-application walk ────────────────
1129
1130    #[test]
1131    fn apply_annotation_empty_anchor_errors() {
1132        let mut d = doc(vec![vec![AdfNode::text("hello")]]);
1133        let err = apply_annotation(&mut d, "", None, "aaa").unwrap_err();
1134        assert!(err.to_string().contains("must not be empty"));
1135    }
1136
1137    #[test]
1138    fn apply_annotation_match_in_first_block_leaves_later_blocks_untouched() {
1139        let mut d = doc(vec![
1140            vec![AdfNode::text("alpha here")],
1141            vec![AdfNode::text("beta there")],
1142        ]);
1143        apply_annotation(&mut d, "alpha", None, "aaa").unwrap();
1144        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["alpha"]);
1145        // The second paragraph is untouched (a single unsplit run).
1146        let second = d.content[1].content.as_ref().unwrap();
1147        assert_eq!(second.len(), 1);
1148        assert_eq!(second[0].text.as_deref(), Some("beta there"));
1149    }
1150
1151    fn hard_break() -> AdfNode {
1152        AdfNode {
1153            node_type: "hardBreak".to_string(),
1154            attrs: None,
1155            content: None,
1156            text: None,
1157            marks: None,
1158            local_id: None,
1159            parameters: None,
1160        }
1161    }
1162
1163    #[test]
1164    fn apply_annotation_spans_reset_at_inline_non_text_nodes() {
1165        // "foo" appears once before and once after a hard break: two separate
1166        // spans, so the anchor is ambiguous and match_index selects the first.
1167        let mut d = doc(vec![vec![
1168            AdfNode::text("see foo here"),
1169            hard_break(),
1170            AdfNode::text("and foo there"),
1171        ]]);
1172        apply_annotation(&mut d, "foo", Some(1), "aaa").unwrap();
1173        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["foo"]);
1174        // The annotated occurrence is the one before the break.
1175        let runs = first_para_runs(&d);
1176        let pos = runs.iter().position(|n| has_annotation(n, "aaa")).unwrap();
1177        let break_pos = runs
1178            .iter()
1179            .position(|n| n.node_type == "hardBreak")
1180            .unwrap();
1181        assert!(pos < break_pos);
1182    }
1183
1184    #[test]
1185    fn apply_annotation_preserves_empty_text_runs() {
1186        let mut d = doc(vec![vec![AdfNode::text(""), AdfNode::text("foo bar")]]);
1187        apply_annotation(&mut d, "foo", None, "aaa").unwrap();
1188        assert_eq!(collect_annotation_runs(&d, "aaa"), vec!["foo"]);
1189        // The empty run survives the split untouched.
1190        let runs = first_para_runs(&d);
1191        assert_eq!(runs[0].text.as_deref(), Some(""));
1192    }
1193
1194    #[test]
1195    fn count_non_overlapping_empty_needle_is_zero() {
1196        assert_eq!(count_non_overlapping("abc", ""), 0);
1197    }
1198
1199    #[test]
1200    fn apply_in_children_is_noop_once_applied() {
1201        // The short-circuit guard: once the target occurrence has been
1202        // annotated, later subtree walks must leave nodes untouched even if
1203        // they contain further matches.
1204        let mut state = ApplyState {
1205            anchor: "foo".to_string(),
1206            marker_id: "aaa".to_string(),
1207            target: 1,
1208            current: 0,
1209            applied: true,
1210        };
1211        let mut children = vec![AdfNode::text("foo bar")];
1212        apply_in_children(&mut children, &mut state);
1213        assert_eq!(children.len(), 1);
1214        assert!(children[0].marks.is_none());
1215        assert_eq!(children[0].text.as_deref(), Some("foo bar"));
1216    }
1217}