Skip to main content

vole_document/field/
capabilities.rs

1//! Machine-readable capability discovery for the common observation layer
2//! (Phase 12.7, ADR-0031, plan §DEC-5).
3//!
4//! Formats do not share coordinates, so the set of *common* observations a
5//! format can serve is explicit and auditable rather than silently approximated.
6//! [`capabilities_for_format`] returns the supported common selectors and their
7//! admissible representations, the versioned extraction profiles, and the native
8//! selectors; [`common_supported`] is the single predicate the planner and the
9//! evaluator both use, so a capability error is never a surprise.
10
11use crate::field::document_format::DocumentFormat;
12use crate::field::observe::{Representation, Selector};
13
14/// One common selector and the representations it admits for a format.
15#[derive(Debug, Clone, Copy)]
16pub struct SelectorCapability {
17    /// Canonical common selector name.
18    pub selector: &'static str,
19    /// Admissible representation names.
20    pub representations: &'static [&'static str],
21}
22
23/// The capability set for one detected format.
24#[derive(Debug, Clone)]
25pub struct Capabilities {
26    /// The detected format.
27    pub format: DocumentFormat,
28    /// Whether the adapter for this format is compiled into this build.
29    pub compiled: bool,
30    /// Supported common selectors (empty when the adapter is not compiled).
31    pub selectors: Vec<SelectorCapability>,
32    /// Versioned extraction-profile fingerprints (DOCX/EPUB).
33    pub profiles: Vec<String>,
34    /// Native selectors retained as first-class peers of the common vocabulary.
35    pub native_selectors: Vec<&'static str>,
36}
37
38impl Capabilities {
39    /// A deterministic, flat JSON object (machine-readable capability discovery).
40    pub fn to_json(&self) -> String {
41        let selectors = self
42            .selectors
43            .iter()
44            .map(|s| {
45                let reps = s
46                    .representations
47                    .iter()
48                    .map(|r| format!("\"{r}\""))
49                    .collect::<Vec<_>>()
50                    .join(",");
51                format!(
52                    "{{\"selector\":\"{}\",\"representations\":[{reps}]}}",
53                    s.selector
54                )
55            })
56            .collect::<Vec<_>>()
57            .join(",");
58        let profiles = self
59            .profiles
60            .iter()
61            .map(|p| format!("\"{}\"", crate::field::provenance::json_escape(p)))
62            .collect::<Vec<_>>()
63            .join(",");
64        let native = self
65            .native_selectors
66            .iter()
67            .map(|s| format!("\"{s}\""))
68            .collect::<Vec<_>>()
69            .join(",");
70        format!(
71            concat!(
72                "{{",
73                "\"root\":\"document\",",
74                "\"format\":\"{}\",",
75                "\"adapter\":\"{}\",",
76                "\"compiled\":{},",
77                "\"selectors\":[{}],",
78                "\"profiles\":[{}],",
79                "\"native_selectors\":[{}]",
80                "}}"
81            ),
82            self.format.name(),
83            self.format.adapter(),
84            self.compiled,
85            selectors,
86            profiles,
87            native
88        )
89    }
90}
91
92const DOCX_NATIVE: &[&str] = &[
93    "docx-story",
94    "docx-paragraph",
95    "docx-table",
96    "docx-cell",
97    "docx-find",
98    "package-part",
99    "relationship",
100    "member",
101];
102const EPUB_NATIVE: &[&str] = &[
103    "epub-package",
104    "epub-manifest-item",
105    "epub-spine-item",
106    "epub-nav",
107    "epub-nav-node",
108    "epub-resource",
109    "epub-block",
110    "epub-cell",
111    "epub-link",
112    "epub-find",
113    "member",
114];
115const ODT_NATIVE: &[&str] = &[
116    "odt-part",
117    "odt-paragraph",
118    "odt-heading",
119    "odt-table",
120    "odt-cell",
121    "odt-list",
122    "odt-find",
123    "package-part",
124    "member",
125];
126const PDF_NATIVE: &[&str] = &[
127    "document",
128    "object",
129    "stream",
130    "revision",
131    "page",
132    "byte-range",
133    "text-match",
134];
135
136const COMMON_METADATA: &[&str] = &["metadata"];
137const COMMON_TEXT: &[&str] = &["text"];
138const COMMON_TEXT_META: &[&str] = &["text", "metadata"];
139const COMMON_RESOURCE: &[&str] = &["metadata"];
140const COMMON_RESOURCE_BYTES: &[&str] = &["metadata", "exact", "decoded"];
141const SEARCH: &[&str] = &["text"];
142
143fn caps(selector: &'static str, representations: &'static [&'static str]) -> SelectorCapability {
144    SelectorCapability {
145        selector,
146        representations,
147    }
148}
149
150/// The capability set for a detected format, filtered by what this build compiles.
151pub fn capabilities_for_format(format: DocumentFormat) -> Capabilities {
152    let compiled = format.compiled();
153    if !compiled {
154        return Capabilities {
155            format,
156            compiled,
157            selectors: Vec::new(),
158            profiles: Vec::new(),
159            native_selectors: Vec::new(),
160        };
161    }
162    let (selectors, profiles, native_selectors): (Vec<SelectorCapability>, Vec<String>, Vec<&str>) =
163        match format {
164            DocumentFormat::Pdf => (
165                vec![
166                    caps("metadata", COMMON_METADATA),
167                    caps("text", COMMON_TEXT),
168                    caps("search-match", SEARCH),
169                ],
170                Vec::new(),
171                PDF_NATIVE.to_vec(),
172            ),
173            DocumentFormat::Docx => (
174                vec![
175                    caps("metadata", COMMON_METADATA),
176                    caps("text", COMMON_TEXT),
177                    caps("heading", COMMON_TEXT_META),
178                    caps("block", COMMON_TEXT_META),
179                    caps("table", COMMON_TEXT_META),
180                    caps("cell", COMMON_TEXT_META),
181                    caps("resource", COMMON_RESOURCE),
182                    caps("link", COMMON_METADATA),
183                    caps("search-match", SEARCH),
184                ],
185                docx_profiles(),
186                DOCX_NATIVE.to_vec(),
187            ),
188            DocumentFormat::Epub => (
189                vec![
190                    caps("metadata", COMMON_METADATA),
191                    caps("text", COMMON_TEXT),
192                    caps("heading", COMMON_TEXT_META),
193                    caps("block", COMMON_TEXT_META),
194                    caps("table", COMMON_TEXT_META),
195                    caps("cell", COMMON_TEXT_META),
196                    caps("resource", COMMON_RESOURCE_BYTES),
197                    caps("link", COMMON_METADATA),
198                    caps("search-match", SEARCH),
199                ],
200                epub_profiles(),
201                EPUB_NATIVE.to_vec(),
202            ),
203            DocumentFormat::Odt => (
204                vec![
205                    caps("metadata", COMMON_METADATA),
206                    caps("text", COMMON_TEXT),
207                    caps("heading", COMMON_TEXT_META),
208                    caps("block", COMMON_TEXT_META),
209                    caps("table", COMMON_TEXT_META),
210                    caps("cell", COMMON_TEXT_META),
211                    caps("resource", COMMON_RESOURCE),
212                    caps("link", COMMON_METADATA),
213                    caps("search-match", SEARCH),
214                ],
215                odt_profiles(),
216                ODT_NATIVE.to_vec(),
217            ),
218            DocumentFormat::Opaque => (Vec::new(), Vec::new(), Vec::new()),
219        };
220    Capabilities {
221        format,
222        compiled,
223        selectors,
224        profiles,
225        native_selectors,
226    }
227}
228
229#[cfg(feature = "docx")]
230fn docx_profiles() -> Vec<String> {
231    vec![crate::adapter::docx::DocxExtractProfile::DEFAULT.fingerprint()]
232}
233#[cfg(not(feature = "docx"))]
234fn docx_profiles() -> Vec<String> {
235    Vec::new()
236}
237
238#[cfg(feature = "epub")]
239fn epub_profiles() -> Vec<String> {
240    vec![crate::adapter::epub::EpubExtractProfile::DEFAULT.fingerprint()]
241}
242#[cfg(not(feature = "epub"))]
243fn epub_profiles() -> Vec<String> {
244    Vec::new()
245}
246
247#[cfg(feature = "odt")]
248fn odt_profiles() -> Vec<String> {
249    vec![crate::adapter::odt::OdtExtractProfile::DEFAULT.fingerprint()]
250}
251#[cfg(not(feature = "odt"))]
252fn odt_profiles() -> Vec<String> {
253    Vec::new()
254}
255
256/// The canonical name of a common selector, or `None` for a native selector.
257pub fn common_selector_name(selector: &Selector) -> Option<&'static str> {
258    match selector {
259        Selector::Metadata => Some("metadata"),
260        Selector::Text => Some("text"),
261        Selector::Heading(_) => Some("heading"),
262        Selector::Block(_) => Some("block"),
263        Selector::Table(_) => Some("table"),
264        Selector::Cell { .. } => Some("cell"),
265        Selector::Resource(_) => Some("resource"),
266        Selector::Link(_) => Some("link"),
267        Selector::SearchMatch(_) => Some("search-match"),
268        _ => None,
269    }
270}
271
272/// The representations admissible for `selector` under `format`, if the pair is
273/// structurally applicable.
274pub fn common_representations(
275    format: DocumentFormat,
276    selector: &Selector,
277) -> Option<&'static [&'static str]> {
278    if !format.compiled() {
279        return None;
280    }
281    let name = common_selector_name(selector)?;
282    let caps = capabilities_for_format(format);
283    caps.selectors
284        .iter()
285        .find(|c| c.selector == name)
286        .map(|c| c.representations)
287}
288
289/// Whether `format` can serve `selector` with `representation`.
290pub fn common_supported(
291    format: DocumentFormat,
292    selector: &Selector,
293    representation: Representation,
294) -> bool {
295    common_representations(format, selector)
296        .is_some_and(|reps| reps.contains(&representation.name()))
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn pdf_supports_text_and_search_but_not_tables() {
305        assert!(common_supported(
306            DocumentFormat::Pdf,
307            &Selector::Text,
308            Representation::Text
309        ));
310        assert!(common_supported(
311            DocumentFormat::Pdf,
312            &Selector::SearchMatch("x".into()),
313            Representation::Text
314        ));
315        assert!(!common_supported(
316            DocumentFormat::Pdf,
317            &Selector::Table(0),
318            Representation::Text
319        ));
320        assert!(!common_supported(
321            DocumentFormat::Pdf,
322            &Selector::Text,
323            Representation::Metadata
324        ));
325    }
326
327    #[test]
328    fn opaque_and_unknown_selectors_are_unsupported() {
329        assert!(!common_supported(
330            DocumentFormat::Opaque,
331            &Selector::Text,
332            Representation::Text
333        ));
334        assert!(!common_supported(
335            DocumentFormat::Pdf,
336            &Selector::Document,
337            Representation::Metadata
338        ));
339    }
340}