1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum DriftStatus {
37 Ok,
40 Torn,
44 MarkLost,
47 Drifted,
50}
51
52#[derive(Debug, Clone, Serialize)]
54pub struct CommentDrift {
55 pub comment_id: String,
57 pub author: String,
59 pub created: String,
61 pub marker_ref: String,
63 pub status: DriftStatus,
65 pub original_selection: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
70 pub current_anchored_text: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
75 pub suggested_new_anchor: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
79 pub suggested_match_count: Option<usize>,
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct ReanchorOutcome {
85 pub comment_id: String,
87 pub marker_ref: String,
89 pub new_anchor_text: String,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub previous_anchored_text: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub original_selection: Option<String>,
97 pub dry_run: bool,
100}
101
102pub 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
160pub 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
220async 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
229fn 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
244fn 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
269fn 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
291struct ApplyState {
293 anchor: String,
294 marker_id: String,
295 target: usize,
297 current: usize,
299 applied: bool,
301}
302
303fn 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
391fn 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 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 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
440fn 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
468fn 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
487fn 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
519fn 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
542fn normalize(text: &str) -> String {
546 text.split_whitespace().collect::<Vec<_>>().join(" ")
547}
548
549fn 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 #[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 #[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 #[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 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 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 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 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 assert_eq!(collect_annotation_runs(&d, "aaa").concat(), "the bold word");
706 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 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 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 #[test]
776 fn reanchor_core_moves_mark_from_old_to_new_run() {
777 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 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 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 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 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 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 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 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 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 assert!(drifts[0].suggested_new_anchor.is_none());
1126 }
1127
1128 #[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 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 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 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 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 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}