Skip to main content

mant_loader/loading/
spec.rs

1//! View-independent local acquisition specifications and host seam.
2use super::LoadError;
3use crate::{ManualPage, ManualRequest};
4use mant_ir::{Document, DocumentAddress, TldrDocument};
5use mant_protocol::{
6    InputFormat, MAX_DOCUMENT_SELECTOR_CHARS, MAX_SOURCE_SELECTOR_CHARS, ScopeTextError,
7    validate_scope_text,
8};
9use std::{
10    io::{self, Read},
11    path::{Path, PathBuf},
12};
13
14/// Borrowed loading input without a protocol schema or a query view.
15#[derive(Debug, Clone, Copy)]
16pub enum LoadSpec<'a> {
17    /// Resolve an explicitly qualified logical name.
18    Document {
19        /// Requested logical selector.
20        selector: &'a str,
21        /// Optional registered source namespace.
22        source: Option<&'a str>,
23        /// Optional native manual category.
24        manual_section: Option<&'a str>,
25    },
26    /// Read an explicitly authorized local file.
27    File {
28        /// Caller-supplied source path.
29        path: &'a str,
30        /// Explicit or inferred source format.
31        format: InputFormat,
32    },
33}
34
35/// Upper bound on a single Markdown source, shared by every input path.
36///
37/// File and stdin readers both enforce this so an unbounded source (a pipe, a
38/// character device such as `/dev/zero`, or a pathologically large file) cannot
39/// exhaust memory. A file's reported length is not trusted: some sources report
40/// zero yet stream without end, so readers cap the byte count directly.
41pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
42
43/// Closed content-resolution policy kept outside the serialized request contract.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub enum LoadPolicy {
46    /// Resolve a full document and attach a compatible quick reference.
47    #[default]
48    Combined,
49    /// Bypass registered Markdown and tldr content.
50    ManualOnly,
51    /// Resolve only embedded or cached tldr content through source precedence.
52    TldrOnly,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub(super) enum FullDocumentMode {
57    Priority,
58    NativeManual,
59    None,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(super) enum QuickReferenceMode {
64    AttachToCommandManual,
65    Exclude,
66    Only,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub(super) struct NamedResolutionPlan {
71    pub(super) document: FullDocumentMode,
72    pub(super) quick_reference: QuickReferenceMode,
73}
74
75impl LoadPolicy {
76    pub(super) fn named_resolution_plan(self, has_manual_section: bool) -> NamedResolutionPlan {
77        match self {
78            Self::Combined => NamedResolutionPlan {
79                document: if has_manual_section {
80                    FullDocumentMode::NativeManual
81                } else {
82                    FullDocumentMode::Priority
83                },
84                quick_reference: QuickReferenceMode::AttachToCommandManual,
85            },
86            Self::ManualOnly => NamedResolutionPlan {
87                document: FullDocumentMode::NativeManual,
88                quick_reference: QuickReferenceMode::Exclude,
89            },
90            Self::TldrOnly => NamedResolutionPlan {
91                document: FullDocumentMode::None,
92                quick_reference: QuickReferenceMode::Only,
93            },
94        }
95    }
96}
97
98pub(super) trait LoadHost {
99    fn native_available(&self) -> bool {
100        true
101    }
102    fn name_candidates(&self, name: &str) -> Vec<String>;
103    fn locate_registered_document(
104        &self,
105        candidates: &[String],
106        source: Option<&str>,
107        phase: RegisteredLookupPhase,
108    ) -> Result<Option<RegisteredSelection>, String>;
109    fn locate_registered_document_groups(
110        &self,
111        candidates: &[String],
112        source: Option<&str>,
113        phase: RegisteredLookupPhase,
114    ) -> Result<Vec<RegisteredSelectionGroup>, String>;
115    fn locate_registered_address(
116        &self,
117        address: &DocumentAddress,
118    ) -> Result<Option<RegisteredSelection>, String>;
119    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String>;
120    fn parse_manual(&self, page: &ManualPage) -> Result<Document, String>;
121    fn parse_manual_input(&self, path: &Path) -> Result<Document, String>;
122    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String>;
123    fn read_markdown(&self, path: &Path) -> Result<String, String>;
124}
125
126#[derive(Clone, Copy)]
127pub(super) enum RegisteredLookupPhase {
128    BeforeBuiltin,
129    AfterBuiltin,
130}
131
132#[derive(Clone)]
133pub(super) struct RegisteredSelection {
134    pub(super) path: PathBuf,
135    pub(super) address: DocumentAddress,
136}
137
138pub(super) struct RegisteredSelectionGroup {
139    pub(super) documents: Vec<RegisteredSelection>,
140}
141
142pub(super) struct LoadedManual {
143    pub(super) document: Document,
144    pub(super) address: DocumentAddress,
145}
146
147/// Read at most `limit` bytes of UTF-8, rejecting anything larger.
148///
149/// The reader is bounded directly instead of trusting a reported length: a pipe
150/// or character device such as `/dev/zero` reports no size yet streams without
151/// end, so only capping the byte count keeps the read finite.
152pub(super) fn read_capped_utf8(reader: impl Read, limit: u64) -> Result<String, String> {
153    read_capped_utf8_io(reader, limit).map_err(|error| error.to_string())
154}
155
156/// Read bounded UTF-8 while preserving failures from the underlying reader.
157pub(crate) fn read_capped_utf8_io(reader: impl Read, limit: u64) -> io::Result<String> {
158    crate::bounded::read_utf8(reader, limit, "Markdown document")
159}
160
161/// Validate source-selection constraints before any host access.
162///
163/// # Errors
164/// Returns the precise loading input violation; no query view is inspected.
165pub fn validate_load_spec(spec: LoadSpec<'_>, policy: LoadPolicy) -> Result<(), LoadError> {
166    match spec {
167        LoadSpec::Document {
168            selector,
169            source,
170            manual_section,
171        } => {
172            validate_scope_text(selector, MAX_DOCUMENT_SELECTOR_CHARS).map_err(|error| {
173                if error == ScopeTextError::Empty {
174                    LoadError::EmptyName
175                } else {
176                    LoadError::InvalidSelector {
177                        field: "document selector",
178                        error,
179                    }
180                }
181            })?;
182            if let Some(source) = source {
183                validate_scope_text(source, MAX_SOURCE_SELECTOR_CHARS).map_err(|error| {
184                    if error == ScopeTextError::Empty {
185                        LoadError::InvalidSource
186                    } else {
187                        LoadError::InvalidSelector {
188                            field: "document source",
189                            error,
190                        }
191                    }
192                })?;
193            }
194            if manual_section.is_some_and(|value| !crate::is_manual_section(value.trim())) {
195                return Err(LoadError::InvalidManualSection);
196            }
197            if policy == LoadPolicy::TldrOnly
198                && let Some(section) = manual_section
199                && !crate::is_command_manual_section(section.trim())
200            {
201                return Err(LoadError::TldrManualSection {
202                    section: section.trim().to_owned(),
203                });
204            }
205            if source.is_some() && (manual_section.is_some() || policy == LoadPolicy::ManualOnly) {
206                return Err(LoadError::ConflictingSourceSelectors);
207            }
208        }
209        LoadSpec::File { path, .. } => {
210            if path.trim().is_empty() {
211                return Err(LoadError::EmptyMarkdownPath);
212            }
213            if policy != LoadPolicy::Combined {
214                return Err(LoadError::Markdown {
215                    path: path.trim().to_owned(),
216                    detail: "content-only policies do not apply to direct input".to_owned(),
217                });
218            }
219        }
220    }
221    Ok(())
222}