Skip to main content

omni_dev/atlassian/
adf_validated.rs

1//! Type-system enforcement of "validated once before send" for ADF documents.
2//!
3//! This module ties together:
4//! - the upstream-faithful schema validator from
5//!   [`crate::atlassian::adf_schema`] (introduced by ADR-0023), and
6//! - a [`ValidatedAdfDocument`] newtype whose only fallible constructor runs
7//!   that validator.
8//!
9//! Every API send signature in [`crate::atlassian::client`] and
10//! [`crate::atlassian::confluence_api`] accepts `&ValidatedAdfDocument`
11//! rather than `&AdfDocument`, which makes "I forgot to validate" a compile
12//! error rather than the opaque HTTP 500 from Confluence that motivates
13//! issue #714.
14//!
15//! See ADR-0024 for the wiring rationale and the per-`(parent, child)` hint
16//! table surfaced through [`AdfValidationError`]'s `Display` impl.
17
18use std::ops::Deref;
19
20use serde::Serialize;
21
22use crate::atlassian::adf::{AdfDocument, AdfNode};
23use crate::atlassian::adf_schema::{validate_document, AdfSchemaViolation};
24use crate::atlassian::convert::markdown_to_adf;
25
26/// A 1-based position in the original JFM markdown source.
27///
28/// Columns are counted in Unicode scalar values (chars), not bytes, so the
29/// reported column matches what an editor's cursor shows.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SourceLocation {
32    /// 1-based line number.
33    pub line: usize,
34    /// 1-based column (counted in chars).
35    pub column: usize,
36}
37
38/// Human-facing context resolved for a single violation: the offending node's
39/// type, a short excerpt of its text (for Ctrl-F recovery), and — when the
40/// original JFM source is available — the 1-based `line:column` of that text.
41///
42/// Populated by [`AdfValidationError::enriched`]; a [`ViolationContext`] with
43/// all-`None` fields means the violation's path could not be resolved to a
44/// node carrying text (so nothing extra is printed for it).
45#[derive(Debug, Clone, PartialEq, Default)]
46struct ViolationContext {
47    /// The offending node's `node_type` (e.g. `"text"`, `"paragraph"`).
48    node_type: Option<String>,
49    /// A short, display-truncated excerpt of the offending run's text.
50    excerpt: Option<String>,
51    /// 1-based `line:column` of the offending text in the JFM source, when a
52    /// source was supplied and the excerpt could be located in it.
53    location: Option<SourceLocation>,
54}
55
56/// Maximum number of chars shown in a violation's text excerpt before it is
57/// truncated with an ellipsis. Long enough to identify the run, short enough
58/// to keep the message on one line.
59const EXCERPT_MAX_CHARS: usize = 60;
60
61/// Resolves the [`AdfNode`] at `path` (an index path from the document root),
62/// or `None` if the path runs off the tree.
63fn node_at_path<'a>(doc: &'a AdfDocument, path: &[usize]) -> Option<&'a AdfNode> {
64    let mut children = doc.content.as_slice();
65    let mut found: Option<&AdfNode> = None;
66    for &idx in path {
67        let node = children.get(idx)?;
68        found = Some(node);
69        children = node.content.as_deref().unwrap_or(&[]);
70    }
71    found
72}
73
74/// Concatenates the text of a node and its inline descendants, stopping once
75/// [`EXCERPT_MAX_CHARS`] worth of characters have been collected. Used to give
76/// block-level violations (which have no `.text` of their own) a locator drawn
77/// from their first inline content.
78fn collect_text(node: &AdfNode) -> String {
79    let mut out = String::new();
80    gather_text(node, &mut out);
81    out
82}
83
84fn gather_text(node: &AdfNode, out: &mut String) {
85    if out.chars().count() >= EXCERPT_MAX_CHARS {
86        return;
87    }
88    if let Some(text) = &node.text {
89        out.push_str(text);
90    }
91    if let Some(children) = &node.content {
92        for child in children {
93            gather_text(child, out);
94            if out.chars().count() >= EXCERPT_MAX_CHARS {
95                return;
96            }
97        }
98    }
99}
100
101/// Truncates `s` to at most [`EXCERPT_MAX_CHARS`] chars, appending `…` when it
102/// was shortened. Operates on char boundaries so it never splits a codepoint.
103fn truncate_excerpt(s: &str) -> String {
104    if s.chars().count() <= EXCERPT_MAX_CHARS {
105        return s.to_string();
106    }
107    let mut out: String = s.chars().take(EXCERPT_MAX_CHARS).collect();
108    out.push('…');
109    out
110}
111
112/// Converts a byte offset in `source` to a 1-based `line:column`.
113fn offset_to_line_col(source: &str, byte_offset: usize) -> SourceLocation {
114    let mut line = 1;
115    let mut column = 1;
116    for (idx, ch) in source.char_indices() {
117        if idx >= byte_offset {
118            break;
119        }
120        if ch == '\n' {
121            line += 1;
122            column = 1;
123        } else {
124            column += 1;
125        }
126    }
127    SourceLocation { line, column }
128}
129
130/// Locates `needle` (an ADF text-node value) in the JFM `source` and returns
131/// its 1-based `line:column`.
132///
133/// The ADF text is the source run with inline markup stripped (e.g. a code
134/// span's backticks are gone), so it is a substring of the source and a direct
135/// search lands on the offending run. Reports the **first** occurrence; the
136/// excerpt printed alongside disambiguates when the same text repeats.
137fn locate_in_source(source: &str, needle: &str) -> Option<SourceLocation> {
138    let needle = needle.trim();
139    if needle.is_empty() {
140        return None;
141    }
142    let byte_offset = source.find(needle)?;
143    Some(offset_to_line_col(source, byte_offset))
144}
145
146/// Resolves the display context for one violation against `doc` (offending node
147/// + excerpt) and, when `source` is `Some`, its `line:column` in the source.
148fn resolve_context(
149    violation: &AdfSchemaViolation,
150    doc: &AdfDocument,
151    source: Option<&str>,
152) -> ViolationContext {
153    let Some(node) = node_at_path(doc, violation.path()) else {
154        return ViolationContext::default();
155    };
156    let node_type = Some(node.node_type.clone());
157
158    let full_text = match &node.text {
159        Some(text) => text.clone(),
160        None => collect_text(node),
161    };
162    let full_text = full_text.trim();
163    if full_text.is_empty() {
164        return ViolationContext {
165            node_type,
166            ..ViolationContext::default()
167        };
168    }
169
170    let location = source.and_then(|src| locate_in_source(src, full_text));
171    ViolationContext {
172        node_type,
173        excerpt: Some(truncate_excerpt(full_text)),
174        location,
175    }
176}
177
178/// One or more nesting violations discovered when validating an
179/// [`AdfDocument`] against the upstream content model.
180//
181// `Eq` is intentionally not derived: `AdfSchemaViolation::InvalidAttr`
182// carries an `AttrProblem` whose `OutOfRangeF` variant holds `f64`, which
183// does not implement `Eq`. `PartialEq` is sufficient for all uses.
184#[derive(Debug, Clone, PartialEq)]
185pub struct AdfValidationError {
186    /// All violations found, in document order.
187    pub violations: Vec<AdfSchemaViolation>,
188    /// Resolved per-violation source context, parallel to `violations` when
189    /// present. Empty when the error was built without a document to resolve
190    /// against (e.g. a hand-constructed error in a test); populated by
191    /// [`Self::enriched`] so [`Display`](std::fmt::Display) can point at the
192    /// offending source location. See issue #1087.
193    contexts: Vec<ViolationContext>,
194}
195
196impl AdfValidationError {
197    /// Builds an error from `violations` with no resolved source context.
198    ///
199    /// Use [`Self::enriched`] to attach the offending node/excerpt/location.
200    #[must_use]
201    pub fn new(violations: Vec<AdfSchemaViolation>) -> Self {
202        Self {
203            violations,
204            contexts: Vec::new(),
205        }
206    }
207
208    /// Resolves each violation's ADF path against `doc` (capturing the
209    /// offending node type and a text excerpt) and, when `source` is `Some`,
210    /// maps that excerpt to a 1-based `line:column` in the original JFM.
211    ///
212    /// This turns an opaque ADF index path (e.g. `/38/4/0/1`) into an
213    /// actionable message that names the offending run and, for JFM-sourced
214    /// documents, where to find it (issue #1087).
215    #[must_use]
216    fn enriched(mut self, doc: &AdfDocument, source: Option<&str>) -> Self {
217        self.contexts = self
218            .violations
219            .iter()
220            .map(|v| resolve_context(v, doc, source))
221            .collect();
222        self
223    }
224}
225
226impl std::fmt::Display for AdfValidationError {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        // Build the full message in a String first, then emit it with a
229        // single `write!`. That collapses several formatter-`?` branches into
230        // one, which keeps coverage tools from flagging each `writeln!` /
231        // `write!` site as a partially-covered branch.
232        let mut out = String::new();
233        for (i, v) in self.violations.iter().enumerate() {
234            if i > 0 {
235                out.push_str("\n\n");
236            }
237            let path = v
238                .path()
239                .iter()
240                .map(usize::to_string)
241                .collect::<Vec<_>>()
242                .join("/");
243            match v {
244                AdfSchemaViolation::DisallowedChild {
245                    child_type,
246                    parent_type,
247                    ..
248                } => {
249                    out.push_str(&format!(
250                        "invalid ADF nesting — `{child_type}` cannot be a child of `{parent_type}` at /{path}.\n",
251                    ));
252                    let hint = hint_for(parent_type, child_type).map_or_else(
253                        || {
254                            format!(
255                                "hint: restructure the document so `{child_type}` is not a direct child of `{parent_type}`.",
256                            )
257                        },
258                        |h| format!("hint: {h}"),
259                    );
260                    out.push_str(&hint);
261                }
262                AdfSchemaViolation::Arity { .. } => {
263                    out.push_str(&format!("invalid ADF nesting — {v}.\n"));
264                    out.push_str(
265                        "hint: adjust the number of children to match the schema's quantifier.",
266                    );
267                }
268                AdfSchemaViolation::MissingAttr { .. } | AdfSchemaViolation::InvalidAttr { .. } => {
269                    out.push_str(&format!("invalid ADF attribute — {v}.\n"));
270                    out.push_str("hint: fix the offending attribute on the node before retrying.");
271                }
272                AdfSchemaViolation::DisallowedMark { .. }
273                | AdfSchemaViolation::InvalidMarkAttr { .. } => {
274                    out.push_str(&format!("invalid ADF mark — {v}.\n"));
275                    out.push_str("hint: remove or correct the offending mark before retrying.");
276                }
277                AdfSchemaViolation::ForbiddenMarkCombination { .. } => {
278                    out.push_str(&format!("invalid ADF mark combination — {v}.\n"));
279                    out.push_str(
280                        "hint: ADF does not allow these marks on the same text — the `code` (monospace) mark only combines with a link; split the run so each piece carries a single style (e.g. drop the backticks or the surrounding bold/italic).",
281                    );
282                }
283            }
284            if let Some(ctx) = self.contexts.get(i) {
285                append_context(&mut out, ctx);
286            }
287        }
288        f.write_str(&out)
289    }
290}
291
292/// Appends the resolved source location and offending-text excerpt for one
293/// violation, each on its own indented line, when they are known.
294fn append_context(out: &mut String, ctx: &ViolationContext) {
295    if let Some(loc) = &ctx.location {
296        out.push_str(&format!("\n  at line {}, column {}", loc.line, loc.column));
297    }
298    if let Some(excerpt) = &ctx.excerpt {
299        out.push_str(&format!("\n  in text: {excerpt:?}"));
300    }
301}
302
303impl std::error::Error for AdfValidationError {}
304
305/// Returns the actionable hint for a known forbidden parent → child pair, or
306/// `None` when the pair is forbidden by the schema but we have no hand-written
307/// remediation guidance for it. The hint table covers the high-traffic
308/// combinations called out in issue #714 plus other known-painful cases; the
309/// generic fallback in [`AdfValidationError`]'s `Display` impl covers
310/// everything else.
311fn hint_for(parent: &str, child: &str) -> Option<&'static str> {
312    HINTS
313        .iter()
314        .find(|(p, c, _)| *p == parent && *c == child)
315        .map(|(_, _, h)| *h)
316}
317
318const HINTS: &[(&str, &str, &str)] = &[
319    (
320        "panel",
321        "expand",
322        "invert the nesting (put the panel inside the expand) or use siblings.",
323    ),
324    (
325        "panel",
326        "nestedExpand",
327        "invert the nesting (put the panel inside the expand) or use siblings.",
328    ),
329    (
330        "panel",
331        "panel",
332        "panels cannot nest; use siblings or convert one to a blockquote.",
333    ),
334    (
335        "expand",
336        "expand",
337        "expands cannot nest directly; consider a single expand with sectioned headings.",
338    ),
339    (
340        "expand",
341        "nestedExpand",
342        "use a plain `expand` at the inner level only inside table cells or layout columns.",
343    ),
344    (
345        "nestedExpand",
346        "expand",
347        "nestedExpand cannot contain another expand; flatten the structure.",
348    ),
349    (
350        "nestedExpand",
351        "nestedExpand",
352        "nestedExpand cannot nest; use siblings.",
353    ),
354    (
355        "nestedExpand",
356        "panel",
357        "move the panel outside the nestedExpand or replace it with a blockquote.",
358    ),
359    (
360        "tableCell",
361        "expand",
362        "use a `nestedExpand` inside table cells; `expand` is only valid at the top level or inside layout columns.",
363    ),
364    (
365        "tableHeader",
366        "expand",
367        "use a `nestedExpand` inside table headers; `expand` is only valid at the top level or inside layout columns.",
368    ),
369    (
370        "tableCell",
371        "panel",
372        "panels are not allowed inside table cells; move the panel outside the table.",
373    ),
374    (
375        "tableHeader",
376        "panel",
377        "panels are not allowed inside table headers; move the panel outside the table.",
378    ),
379    (
380        "layoutSection",
381        "layoutSection",
382        "layout sections cannot nest; use sibling sections.",
383    ),
384    (
385        "layoutColumn",
386        "layoutSection",
387        "a layout column cannot contain another layout section; flatten the structure.",
388    ),
389    (
390        "blockquote",
391        "blockquote",
392        "blockquotes cannot nest; use a single blockquote with paragraph siblings.",
393    ),
394    (
395        "blockquote",
396        "panel",
397        "move the panel outside the blockquote.",
398    ),
399    (
400        "blockquote",
401        "expand",
402        "move the expand outside the blockquote.",
403    ),
404    (
405        "listItem",
406        "panel",
407        "panels cannot appear inside list items; place the panel outside the list.",
408    ),
409    (
410        "listItem",
411        "expand",
412        "expands cannot appear inside list items; place the expand outside the list.",
413    ),
414];
415
416/// Returns `Ok(())` if `doc` has no nesting violations, else an
417/// [`AdfValidationError`] listing every violation found.
418///
419/// Borrows `doc`; use [`ValidatedAdfDocument::try_new`] when the goal is to
420/// produce a validated wrapper rather than just check.
421///
422/// # Errors
423///
424/// Returns [`AdfValidationError`] when the document violates one or more
425/// allowed-children rules in the upstream content model.
426pub fn validate(doc: &AdfDocument) -> Result<(), AdfValidationError> {
427    validate_with_source(doc, None)
428}
429
430/// Like [`validate`], but source-aware.
431///
432/// Keeps `source` (the JFM markdown `doc` was converted from, when applicable)
433/// so any violation is reported with the offending run's 1-based `line:column`.
434/// Pass `None` when there is no JFM source (e.g. ADF-format input). See issue
435/// #1087.
436///
437/// # Errors
438///
439/// Returns [`AdfValidationError`] when the document violates one or more
440/// allowed-children rules in the upstream content model.
441pub fn validate_with_source(
442    doc: &AdfDocument,
443    source: Option<&str>,
444) -> Result<(), AdfValidationError> {
445    let violations = validate_document(doc);
446    if violations.is_empty() {
447        Ok(())
448    } else {
449        Err(AdfValidationError::new(violations).enriched(doc, source))
450    }
451}
452
453/// Converts JFM markdown to a validated ADF document, keeping the source
454/// available so any validation failure can be reported with its origin.
455///
456/// On success this is equivalent to
457/// `ValidatedAdfDocument::try_new(markdown_to_adf(source)?)`. On a schema
458/// violation the returned error is enriched with the offending text excerpt
459/// and its 1-based `line:column` in `source`, so callers get an actionable
460/// message instead of a bare ADF index path (issue #1087).
461///
462/// # Errors
463///
464/// Returns an error if the document violates the ADF content model. (The
465/// markdown-to-ADF step itself is infallible in practice but its `Result` is
466/// propagated for forward compatibility.)
467pub fn markdown_to_validated_adf(source: &str) -> anyhow::Result<ValidatedAdfDocument> {
468    let doc = markdown_to_adf(source)?;
469    Ok(ValidatedAdfDocument::try_new_with_source(
470        doc,
471        Some(source),
472    )?)
473}
474
475/// An [`AdfDocument`] that has passed nesting validation against the
476/// upstream content model.
477///
478/// Constructing one is the only way to satisfy the type signatures of the
479/// API send functions in [`crate::atlassian::client`] and
480/// [`crate::atlassian::confluence_api`]. This makes "I forgot to validate"
481/// a compile error rather than a runtime opaque-500 error.
482///
483/// `Deref<Target = AdfDocument>` and a delegated `Serialize` impl let
484/// callers continue to use the validated document anywhere a `&AdfDocument`
485/// or serialized JSON value is needed.
486#[derive(Debug, Clone, PartialEq)]
487pub struct ValidatedAdfDocument(AdfDocument);
488
489impl ValidatedAdfDocument {
490    /// Validates `doc` against the upstream ADF content model and wraps it
491    /// on success.
492    ///
493    /// # Errors
494    ///
495    /// Returns [`AdfValidationError`] if `doc` contains any disallowed
496    /// nesting per the schema in [`crate::atlassian::adf_schema`].
497    pub fn try_new(doc: AdfDocument) -> Result<Self, AdfValidationError> {
498        Self::try_new_with_source(doc, None)
499    }
500
501    /// Like [`Self::try_new`], but keeps `source` (the JFM markdown `doc` was
502    /// converted from, when applicable) so a validation failure reports the
503    /// offending run's 1-based `line:column`. Pass `None` when there is no JFM
504    /// source (e.g. ADF-format input). See issue #1087.
505    ///
506    /// # Errors
507    ///
508    /// Returns [`AdfValidationError`] if `doc` contains any disallowed nesting
509    /// per the schema in [`crate::atlassian::adf_schema`].
510    pub fn try_new_with_source(
511        doc: AdfDocument,
512        source: Option<&str>,
513    ) -> Result<Self, AdfValidationError> {
514        let violations = validate_document(&doc);
515        if violations.is_empty() {
516            Ok(Self(doc))
517        } else {
518            Err(AdfValidationError::new(violations).enriched(&doc, source))
519        }
520    }
521
522    /// Returns a trivially-valid empty document without invoking the
523    /// validator. Useful for tests and for code paths that need an
524    /// "unset" placeholder.
525    #[must_use]
526    pub fn empty() -> Self {
527        Self(AdfDocument::new())
528    }
529
530    /// Test-only constructor that wraps `doc` *without* running the
531    /// validator.
532    ///
533    /// Reserved for tests that need to drive a send function with an
534    /// intentionally-invalid document — for example, the HTTP-500 diagnosis
535    /// path tests in [`crate::atlassian::confluence_api`] which assert the
536    /// post-response diagnoser fires when a violation slips past the local
537    /// validator.
538    ///
539    /// **Never use in production code.** Production callers must go through
540    /// [`Self::try_new`] so validation is guaranteed.
541    #[cfg(test)]
542    #[must_use]
543    pub fn trust(doc: AdfDocument) -> Self {
544        Self(doc)
545    }
546}
547
548impl Deref for ValidatedAdfDocument {
549    type Target = AdfDocument;
550
551    fn deref(&self) -> &Self::Target {
552        &self.0
553    }
554}
555
556impl Serialize for ValidatedAdfDocument {
557    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
558        self.0.serialize(serializer)
559    }
560}
561
562#[cfg(test)]
563#[allow(clippy::unwrap_used, clippy::expect_used)]
564mod tests {
565    use super::*;
566    use crate::atlassian::adf::AdfNode;
567
568    fn doc(nodes: Vec<AdfNode>) -> AdfDocument {
569        AdfDocument {
570            version: 1,
571            doc_type: "doc".to_string(),
572            content: nodes,
573        }
574    }
575
576    #[test]
577    fn try_new_accepts_clean_document() {
578        let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("ok")])]);
579        let v = ValidatedAdfDocument::try_new(d).unwrap();
580        assert_eq!(v.content.len(), 1);
581    }
582
583    #[test]
584    fn try_new_rejects_panel_with_expand() {
585        // Issue #714 reproducer. Since arity checking landed in #733, an
586        // empty `expand` (and the panel that lacks any valid children)
587        // also generate Arity violations — assertion is on the
588        // disallowed-child case, the one the user cares about.
589        let d = doc(vec![AdfNode::panel(
590            "info",
591            vec![AdfNode::expand(None, vec![])],
592        )]);
593        let err = ValidatedAdfDocument::try_new(d).unwrap_err();
594        assert!(err.violations.iter().any(|v| matches!(
595            v,
596            AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
597                if child_type == "expand" && parent_type == "panel"
598        )));
599    }
600
601    #[test]
602    fn try_new_rejects_table_cell_with_expand() {
603        let d = doc(vec![AdfNode::table(vec![AdfNode::table_row(vec![
604            AdfNode::table_cell(vec![AdfNode::expand(None, vec![])]),
605        ])])]);
606        let err = ValidatedAdfDocument::try_new(d).unwrap_err();
607        assert!(err.violations.iter().any(|v| matches!(
608            v,
609            AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
610                if child_type == "expand" && parent_type == "tableCell"
611        )));
612    }
613
614    #[test]
615    fn try_new_allows_expand_inside_layout_column() {
616        // layoutSection requires 2..=3 columns (Range quantifier) and the
617        // expand needs ≥1 child, so the document is composed accordingly.
618        let inner = || AdfNode::paragraph(vec![AdfNode::text("x")]);
619        let column = || AdfNode::layout_column(50, vec![AdfNode::expand(None, vec![inner()])]);
620        let d = doc(vec![AdfNode::layout_section(vec![column(), column()])]);
621        assert!(ValidatedAdfDocument::try_new(d).is_ok());
622    }
623
624    #[test]
625    fn empty_is_trivially_valid() {
626        let v = ValidatedAdfDocument::empty();
627        assert!(v.content.is_empty());
628    }
629
630    #[test]
631    fn serializes_as_inner_adf() {
632        let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hello")])]);
633        let v = ValidatedAdfDocument::try_new(d.clone()).unwrap();
634        let v_json = serde_json::to_string(&v).unwrap();
635        let d_json = serde_json::to_string(&d).unwrap();
636        assert_eq!(v_json, d_json);
637    }
638
639    #[test]
640    fn deref_exposes_inner_fields() {
641        let d = doc(vec![AdfNode::paragraph(vec![])]);
642        let v = ValidatedAdfDocument::try_new(d).unwrap();
643        assert_eq!(v.version, 1);
644        assert_eq!(v.doc_type, "doc");
645    }
646
647    #[test]
648    fn error_display_includes_path_and_hint_for_known_pair() {
649        let d = doc(vec![AdfNode::panel(
650            "info",
651            vec![AdfNode::expand(None, vec![])],
652        )]);
653        let err = ValidatedAdfDocument::try_new(d).unwrap_err();
654        let msg = err.to_string();
655        assert!(msg.contains("invalid ADF nesting"));
656        assert!(msg.contains("`expand` cannot be a child of `panel`"));
657        // adf_schema's path is index-only from the document root; the
658        // panel sits at /0 and its expand child at /0/0.
659        assert!(msg.contains("at /0/0"));
660        assert!(msg.contains("hint: invert the nesting"));
661    }
662
663    #[test]
664    fn error_display_falls_back_to_generic_hint_for_unknown_pair() {
665        // `paragraph → table` is forbidden by the schema but is not in our
666        // hand-written hint table; the generic fallback should still give
667        // the user something actionable.
668        let d = doc(vec![AdfNode::paragraph(vec![AdfNode::table(vec![])])]);
669        let err = ValidatedAdfDocument::try_new(d).unwrap_err();
670        let msg = err.to_string();
671        assert!(msg.contains("invalid ADF nesting"));
672        assert!(msg.contains("`table` cannot be a child of `paragraph`"));
673        assert!(msg.contains("hint: restructure the document"));
674    }
675
676    #[test]
677    fn error_display_separates_multiple_violations() {
678        let d = doc(vec![
679            AdfNode::panel("info", vec![AdfNode::expand(None, vec![])]),
680            AdfNode::blockquote(vec![AdfNode::panel("note", vec![])]),
681        ]);
682        let err = ValidatedAdfDocument::try_new(d).unwrap_err();
683        assert!(err.violations.len() >= 2);
684        let msg = err.to_string();
685        // Two violations imply a blank-line separator (two consecutive
686        // newlines) between them.
687        assert!(msg.contains("\n\n"));
688    }
689
690    // ── Display arms for non-nesting variant kinds ────────────────────
691    //
692    // Each variant kind in `AdfSchemaViolation` produces a different
693    // `AdfValidationError` Display section (nesting / arity / attr / mark).
694    // Cover the attr and mark sections directly by constructing the
695    // error rather than going through the validator.
696
697    #[test]
698    fn error_display_for_missing_attr_violation() {
699        let err = AdfValidationError::new(vec![AdfSchemaViolation::MissingAttr {
700            node_type: "panel".to_string(),
701            attr_name: "panelType".to_string(),
702            path: vec![0],
703        }]);
704        let msg = err.to_string();
705        assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
706        assert!(msg.contains("'panelType'"), "got: {msg}");
707        assert!(msg.contains("hint:"), "got: {msg}");
708    }
709
710    #[test]
711    fn error_display_for_invalid_attr_violation() {
712        use crate::atlassian::adf_attr_schema::AttrProblem;
713        let err = AdfValidationError::new(vec![AdfSchemaViolation::InvalidAttr {
714            node_type: "heading".to_string(),
715            attr_name: "level".to_string(),
716            problem: AttrProblem::OutOfRange {
717                lo: 1,
718                hi: 6,
719                actual: 7,
720            },
721            path: vec![0],
722        }]);
723        let msg = err.to_string();
724        assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
725        assert!(msg.contains("'heading.level'"), "got: {msg}");
726    }
727
728    #[test]
729    fn error_display_for_disallowed_mark_violation() {
730        let err = AdfValidationError::new(vec![AdfSchemaViolation::DisallowedMark {
731            mark_type: "code".to_string(),
732            parent_type: "heading".to_string(),
733            inline_index: Some(0),
734            path: vec![0],
735        }]);
736        let msg = err.to_string();
737        assert!(msg.contains("invalid ADF mark"), "got: {msg}");
738        assert!(msg.contains("'code' mark"), "got: {msg}");
739        assert!(msg.contains("hint: remove or correct"), "got: {msg}");
740    }
741
742    #[test]
743    fn error_display_for_forbidden_mark_combination() {
744        let err = AdfValidationError::new(vec![AdfSchemaViolation::ForbiddenMarkCombination {
745            mark_type: "strong".to_string(),
746            conflicts_with: "code".to_string(),
747            parent_type: "paragraph".to_string(),
748            inline_index: Some(0),
749            path: vec![0, 0],
750        }]);
751        let msg = err.to_string();
752        assert!(msg.contains("invalid ADF mark combination"), "got: {msg}");
753        assert!(
754            msg.contains("'strong' mark cannot be combined with 'code' mark"),
755            "got: {msg}"
756        );
757        assert!(msg.contains("hint:"), "got: {msg}");
758    }
759
760    #[test]
761    fn try_new_rejects_strong_plus_code_text() {
762        // Issue #1047 reproducer at the validator boundary: a paragraph whose
763        // text carries both `strong` and `code` is rejected before send.
764        let text = AdfNode {
765            node_type: "text".to_string(),
766            attrs: None,
767            content: None,
768            text: Some("x".to_string()),
769            marks: Some(vec![
770                crate::atlassian::adf::AdfMark::strong(),
771                crate::atlassian::adf::AdfMark::code(),
772            ]),
773            local_id: None,
774            parameters: None,
775        };
776        let d = doc(vec![AdfNode::paragraph(vec![text])]);
777        let err = ValidatedAdfDocument::try_new(d).unwrap_err();
778        assert!(err.violations.iter().any(|v| matches!(
779            v,
780            AdfSchemaViolation::ForbiddenMarkCombination { mark_type, conflicts_with, .. }
781                if mark_type == "strong" && conflicts_with == "code"
782        )));
783    }
784
785    #[test]
786    fn error_display_for_invalid_mark_attr_violation() {
787        use crate::atlassian::adf_attr_schema::AttrProblem;
788        let err = AdfValidationError::new(vec![AdfSchemaViolation::InvalidMarkAttr {
789            mark_type: "link".to_string(),
790            attr_name: "href".to_string(),
791            problem: AttrProblem::BadFormat {
792                reason: "not a valid URL",
793            },
794            inline_index: Some(0),
795            path: vec![0],
796        }]);
797        let msg = err.to_string();
798        assert!(msg.contains("invalid ADF mark"), "got: {msg}");
799        assert!(msg.contains("'link' mark"), "got: {msg}");
800    }
801
802    // ── Source-location enrichment (issue #1087) ──────────────────────
803
804    #[test]
805    fn offset_to_line_col_counts_chars_not_bytes() {
806        // "éx" sits on line 3; `é` is two bytes but one column, so `x` must be
807        // reported at column 2, not 3.
808        let src = "ab\ncd\néx";
809        assert_eq!(
810            offset_to_line_col(src, 0),
811            SourceLocation { line: 1, column: 1 }
812        );
813        assert_eq!(
814            offset_to_line_col(src, 6),
815            SourceLocation { line: 3, column: 1 } // é
816        );
817        assert_eq!(
818            offset_to_line_col(src, 8),
819            SourceLocation { line: 3, column: 2 } // x
820        );
821    }
822
823    #[test]
824    fn locate_in_source_reports_first_occurrence() {
825        let loc = locate_in_source("hello\nworld foo", "foo").unwrap();
826        assert_eq!(loc, SourceLocation { line: 2, column: 7 });
827        assert!(locate_in_source("no match here", "absent").is_none());
828        // A needle that is only whitespace trims to empty and locates nothing.
829        assert!(locate_in_source("some text", "   ").is_none());
830    }
831
832    #[test]
833    fn collect_text_stops_at_the_excerpt_budget() {
834        // A block node whose *own* text already fills the budget, plus a child:
835        // gathering the child enters `gather_text` with a full buffer (the
836        // entry guard), and the post-child loop guard also fires — so the
837        // trailing child text is never appended.
838        let long = "a".repeat(EXCERPT_MAX_CHARS + 5);
839        let mut node = AdfNode::paragraph(vec![AdfNode::text("SHOULD_NOT_APPEAR")]);
840        node.text = Some(long);
841        let gathered = collect_text(&node);
842        assert!(gathered.chars().count() >= EXCERPT_MAX_CHARS);
843        assert!(!gathered.contains("SHOULD_NOT_APPEAR"), "got: {gathered}");
844    }
845
846    #[test]
847    fn resolve_context_returns_empty_for_off_tree_path() {
848        // A violation whose path runs off the tree resolves to no node, so no
849        // excerpt or location is attached.
850        let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hi")])]);
851        let violation = AdfSchemaViolation::DisallowedChild {
852            child_type: "x".to_string(),
853            parent_type: "y".to_string(),
854            path: vec![9, 9],
855        };
856        assert_eq!(
857            resolve_context(&violation, &d, Some("hi")),
858            ViolationContext::default()
859        );
860    }
861
862    #[test]
863    fn node_at_path_resolves_nested_and_rejects_off_tree() {
864        let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hi")])]);
865        assert_eq!(node_at_path(&d, &[0]).unwrap().node_type, "paragraph");
866        assert_eq!(
867            node_at_path(&d, &[0, 0]).unwrap().text.as_deref(),
868            Some("hi")
869        );
870        assert!(node_at_path(&d, &[5]).is_none());
871        assert!(node_at_path(&d, &[0, 9]).is_none());
872    }
873
874    #[test]
875    fn truncate_excerpt_shortens_long_runs() {
876        assert_eq!(truncate_excerpt("abc"), "abc");
877        let long = "x".repeat(EXCERPT_MAX_CHARS + 5);
878        let t = truncate_excerpt(&long);
879        assert!(t.ends_with('…'), "got: {t}");
880        assert_eq!(t.chars().count(), EXCERPT_MAX_CHARS + 1);
881    }
882
883    #[test]
884    fn try_new_error_names_offending_text_without_source() {
885        // A strong+code text node. `try_new` has no JFM source, so the message
886        // carries the offending run's text but no line:column.
887        let text = AdfNode {
888            node_type: "text".to_string(),
889            attrs: None,
890            content: None,
891            text: Some("/api/v1/example".to_string()),
892            marks: Some(vec![
893                crate::atlassian::adf::AdfMark::strong(),
894                crate::atlassian::adf::AdfMark::code(),
895            ]),
896            local_id: None,
897            parameters: None,
898        };
899        let d = doc(vec![AdfNode::paragraph(vec![text])]);
900        let err = ValidatedAdfDocument::try_new(d).unwrap_err();
901        let msg = err.to_string();
902        assert!(msg.contains("in text: \"/api/v1/example\""), "got: {msg}");
903        assert!(
904            !msg.contains("at line"),
905            "no source ⇒ no line:col, got: {msg}"
906        );
907    }
908
909    #[test]
910    fn markdown_to_validated_adf_reports_line_column_and_excerpt() {
911        // Issue #1087: bold-wrapping-inline-code yields a text node carrying
912        // both `strong` and `code`, rejected by the validator. The offending
913        // run sits on line 5 of the source.
914        let src = "# Heading\n\nIntro paragraph.\n\nHere is **`/api/v1/example`** in a sentence.\n";
915        let err = markdown_to_validated_adf(src).unwrap_err();
916        let msg = err.to_string();
917        assert!(msg.contains("/api/v1/example"), "excerpt, got: {msg}");
918        assert!(msg.contains("at line 5"), "line, got: {msg}");
919        assert!(msg.contains("column"), "column, got: {msg}");
920    }
921
922    #[test]
923    fn markdown_to_validated_adf_accepts_clean_document() {
924        let v = markdown_to_validated_adf("# Title\n\nA clean paragraph.\n").unwrap();
925        assert!(!v.content.is_empty());
926    }
927}