Skip to main content

mant_engine/query/
input.rs

1//! Resolves bounded direct files and in-memory document inputs.
2
3use super::{
4    InputFormat, ManualLoadError, OsStr, Path, QueryError, QueryHost, QueryInput, QueryPolicy,
5    QueryRequest, ResolvedContent, parse_manual_bytes, parse_markdown, query_named_document,
6};
7
8pub(super) fn query_with(
9    request: &QueryRequest,
10    policy: QueryPolicy,
11    host: &dyn QueryHost,
12) -> Result<ResolvedContent, QueryError> {
13    match &request.input {
14        QueryInput::Document {
15            selector,
16            source,
17            manual_section,
18        } => query_named_document(
19            selector,
20            source.as_deref(),
21            manual_section.as_deref(),
22            policy,
23            host,
24        ),
25        QueryInput::File { path, format } => query_input_file(path, *format, policy, host),
26    }
27}
28
29fn query_input_file(
30    requested_path: &str,
31    format: InputFormat,
32    policy: QueryPolicy,
33    host: &dyn QueryHost,
34) -> Result<ResolvedContent, QueryError> {
35    let path = requested_path.trim();
36    if path.is_empty() {
37        return Err(QueryError::EmptyMarkdownPath);
38    }
39    let format = match format {
40        InputFormat::Auto => {
41            detect_input_format(path).ok_or_else(|| QueryError::UnsupportedInputFormat {
42                path: path.to_owned(),
43            })?
44        }
45        format => format,
46    };
47    match format {
48        InputFormat::Markdown => query_markdown_file(path, policy, host),
49        InputFormat::Roff => {
50            if policy != QueryPolicy::Combined {
51                return Err(QueryError::ConflictingSourceSelectors);
52            }
53            let document = host.parse_manual_input(Path::new(path)).map_err(|detail| {
54                QueryError::Manual(ManualLoadError::Parse {
55                    name: path.to_owned(),
56                    detail,
57                })
58            })?;
59            if document.sections.is_empty() && document.blocks.is_empty() {
60                return Err(QueryError::NoReadableContent {
61                    name: path.to_owned(),
62                });
63            }
64            let label = document
65                .meta
66                .names
67                .first()
68                .cloned()
69                .or_else(|| document.meta.title.clone())
70                .unwrap_or_else(|| input_file_label(path));
71            Ok(ResolvedContent {
72                label,
73                address: None,
74                document: Some(document),
75                tldr: None,
76            })
77        }
78        InputFormat::Auto => unreachable!("auto input was resolved above"),
79    }
80}
81
82fn detect_input_format(path: &str) -> Option<InputFormat> {
83    let mut name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
84    let mut compressed = false;
85    if Path::new(&name)
86        .extension()
87        .and_then(OsStr::to_str)
88        .is_some_and(|extension| matches!(extension, "gz" | "zst"))
89    {
90        name = Path::new(&name).file_stem()?.to_str()?.to_owned();
91        compressed = true;
92    }
93    let extension = Path::new(&name).extension()?.to_str()?;
94    if matches!(extension, "md" | "markdown") {
95        return (!compressed).then_some(InputFormat::Markdown);
96    }
97    if matches!(extension, "roff" | "man" | "mdoc") {
98        return Some(InputFormat::Roff);
99    }
100    crate::is_manual_section(extension).then_some(InputFormat::Roff)
101}
102
103fn input_file_label(path: &str) -> String {
104    Path::new(path)
105        .file_name()
106        .and_then(OsStr::to_str)
107        .unwrap_or(path)
108        .to_owned()
109}
110
111fn query_markdown_file(
112    requested_path: &str,
113    policy: QueryPolicy,
114    host: &dyn QueryHost,
115) -> Result<ResolvedContent, QueryError> {
116    let path = requested_path.trim();
117    if path.is_empty() {
118        return Err(QueryError::EmptyMarkdownPath);
119    }
120    if policy != QueryPolicy::Combined {
121        return Err(QueryError::Markdown {
122            path: path.to_owned(),
123            detail: "content-only policies do not apply to direct input".to_owned(),
124        });
125    }
126    let source = host
127        .read_markdown(Path::new(path))
128        .map_err(|detail| QueryError::Markdown {
129            path: path.to_owned(),
130            detail,
131        })?;
132    query_markdown_text(&source, Some(path.to_owned()))
133}
134
135/// Parse in-memory Markdown for the direct `mant -` command.
136///
137/// This helper intentionally sits outside [`QueryRequest`]: public protocol
138/// requests reference local files and never embed arbitrary document content.
139///
140/// # Errors
141///
142/// Returns [`QueryError::EmptyMarkdown`] when parsing yields no visible blocks
143/// or sections.
144pub fn query_markdown_text(
145    source: &str,
146    source_path: Option<String>,
147) -> Result<ResolvedContent, QueryError> {
148    let label = source_path.as_deref().map_or_else(
149        || "stdin".to_owned(),
150        |path| {
151            Path::new(path)
152                .file_name()
153                .and_then(OsStr::to_str)
154                .unwrap_or(path)
155                .to_owned()
156        },
157    );
158    let error_path = source_path.clone().unwrap_or_else(|| "stdin".to_owned());
159    let parsed = parse_markdown(source, source_path).map_err(|error| QueryError::Markdown {
160        path: error_path,
161        detail: error.to_string(),
162    })?;
163    let document_is_empty =
164        parsed.document.blocks.is_empty() && parsed.document.sections.is_empty();
165    if document_is_empty && parsed.tldr.is_none() {
166        return Err(QueryError::EmptyMarkdown {
167            label: label.clone(),
168        });
169    }
170    Ok(ResolvedContent {
171        address: None,
172        label,
173        document: (!document_is_empty).then_some(parsed.document),
174        tldr: parsed.tldr,
175    })
176}
177
178/// Parse one bounded roff stream without consulting MANPATH or following `.so`.
179///
180/// # Errors
181///
182/// Returns a native parse error or an empty-document error.
183pub fn query_roff_bytes(source: &[u8]) -> Result<ResolvedContent, QueryError> {
184    if u64::try_from(source.len()).unwrap_or(u64::MAX) > crate::MAX_MANUAL_BYTES {
185        return Err(QueryError::Manual(ManualLoadError::Parse {
186            name: "stdin".to_owned(),
187            detail: format!(
188                "roff input exceeds the {}-byte limit",
189                crate::MAX_MANUAL_BYTES
190            ),
191        }));
192    }
193    let document = parse_manual_bytes(Path::new("stdin"), source).map_err(|error| {
194        QueryError::Manual(ManualLoadError::Parse {
195            name: "stdin".to_owned(),
196            detail: error.to_string(),
197        })
198    })?;
199    if document.sections.is_empty() && document.blocks.is_empty() {
200        return Err(QueryError::NoReadableContent {
201            name: "stdin".to_owned(),
202        });
203    }
204    let label = document
205        .meta
206        .names
207        .first()
208        .cloned()
209        .or_else(|| document.meta.title.clone())
210        .unwrap_or_else(|| "stdin".to_owned());
211    Ok(ResolvedContent {
212        address: None,
213        label,
214        document: Some(document),
215        tldr: None,
216    })
217}