Skip to main content

mant_core/
source.rs

1//! Locates the original source file selected by the host's `man` database.
2
3use std::{
4    ffi::{OsStr, OsString},
5    fmt, io,
6    os::unix::ffi::OsStringExt,
7    path::PathBuf,
8    process::Command,
9};
10
11/// One validated manual lookup independent from CLI token syntax.
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct ManualRequest {
14    pub topic: String,
15    pub section: Option<String>,
16}
17
18impl ManualRequest {
19    #[must_use]
20    pub fn new(topic: impl Into<String>, section: Option<String>) -> Self {
21        Self {
22            topic: topic.into(),
23            section,
24        }
25    }
26}
27
28/// Minimal subprocess result used by deterministic source-locator tests.
29#[derive(Clone, Debug, Default, Eq, PartialEq)]
30pub struct CommandOutput {
31    pub stdout: Vec<u8>,
32    pub stderr: Vec<u8>,
33    pub exit_code: i32,
34}
35
36/// Injectable boundary around process execution.
37pub trait CommandRunner {
38    /// Run one executable with already-separated arguments.
39    ///
40    /// # Errors
41    ///
42    /// Returns an I/O error when the executable cannot be started or waited.
43    fn run(&self, program: &OsStr, arguments: &[OsString]) -> io::Result<CommandOutput>;
44}
45
46/// Production runner backed by [`std::process::Command`].
47///
48/// The child intentionally inherits the current environment. In particular,
49/// this lets the host `man -w` lookup honor `MANPATH`, `MANSECT`, and locale
50/// variables without `ManT` duplicating platform-specific manual-path rules.
51#[derive(Clone, Copy, Debug, Default)]
52pub struct SystemCommandRunner;
53
54impl CommandRunner for SystemCommandRunner {
55    fn run(&self, program: &OsStr, arguments: &[OsString]) -> io::Result<CommandOutput> {
56        let output = Command::new(program).args(arguments).output()?;
57        Ok(CommandOutput {
58            stdout: output.stdout,
59            stderr: output.stderr,
60            exit_code: output.status.code().unwrap_or(-1),
61        })
62    }
63}
64
65/// Expected source-discovery failures suitable for a user-facing CLI error.
66#[derive(Debug)]
67pub enum LocateError {
68    EmptyTopic,
69    InvalidSection,
70    CommandUnavailable(io::Error),
71    NotFound {
72        topic: String,
73        detail: Option<String>,
74    },
75    EmptyResult {
76        topic: String,
77    },
78}
79
80impl fmt::Display for LocateError {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::EmptyTopic => formatter.write_str("manual topic must not be empty"),
84            Self::InvalidSection => formatter.write_str("manual section must not be empty"),
85            Self::CommandUnavailable(error) if error.kind() == io::ErrorKind::NotFound => {
86                formatter.write_str("cannot locate manuals: the 'man' command is not installed")
87            }
88            Self::CommandUnavailable(error) => write!(formatter, "could not run 'man -w': {error}"),
89            Self::NotFound { topic, detail } => {
90                write!(formatter, "no local manual source was found for '{topic}'")?;
91                if let Some(detail) = detail {
92                    write!(formatter, ": {detail}")?;
93                }
94                Ok(())
95            }
96            Self::EmptyResult { topic } => {
97                write!(formatter, "man returned no source path for '{topic}'")
98            }
99        }
100    }
101}
102
103impl std::error::Error for LocateError {
104    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
105        match self {
106            Self::CommandUnavailable(error) => Some(error),
107            _ => None,
108        }
109    }
110}
111
112/// Locate a manual through the host's configured man database.
113///
114/// # Errors
115///
116/// Returns [`LocateError`] for invalid requests, unavailable host tooling,
117/// failed lookups, and successful commands that return no path.
118pub fn locate_manual_source(request: &ManualRequest) -> Result<PathBuf, LocateError> {
119    locate_manual_source_with(request, &SystemCommandRunner)
120}
121
122/// Injectable form of [`locate_manual_source`] used by native unit tests.
123///
124/// # Errors
125///
126/// Returns the same [`LocateError`] variants as [`locate_manual_source`].
127pub fn locate_manual_source_with(
128    request: &ManualRequest,
129    runner: &impl CommandRunner,
130) -> Result<PathBuf, LocateError> {
131    let topic = request.topic.trim();
132    if topic.is_empty() {
133        return Err(LocateError::EmptyTopic);
134    }
135
136    let mut arguments = vec![OsString::from("-w")];
137    if let Some(section) = request.section.as_deref() {
138        let section = section.trim();
139        if section.is_empty() {
140            return Err(LocateError::InvalidSection);
141        }
142        // Pass the section with an explicit `-S` flag. A bare section operand
143        // (`man -w 1 -- ls`) collides with the `--` option terminator on
144        // man-db, while lowercase `-s` is not available in BSD man on macOS.
145        // Uppercase `-S` labels the section on both implementations.
146        push_section_filter(&mut arguments, section);
147    }
148    // Terminate option parsing so a topic beginning with '-' is treated as a
149    // positional operand rather than an option by man.
150    arguments.push(OsString::from("--"));
151    arguments.push(OsString::from(topic));
152
153    let output = runner
154        .run(OsStr::new("man"), &arguments)
155        .map_err(LocateError::CommandUnavailable)?;
156    if output.exit_code != 0 {
157        return Err(LocateError::NotFound {
158            topic: topic.to_owned(),
159            detail: first_nonempty_line(&output.stderr),
160        });
161    }
162
163    let path = first_line_bytes(&output.stdout).ok_or_else(|| LocateError::EmptyResult {
164        topic: topic.to_owned(),
165    })?;
166    Ok(PathBuf::from(OsString::from_vec(path.to_vec())))
167}
168
169pub(crate) fn push_section_filter(arguments: &mut Vec<OsString>, section: &str) {
170    arguments.push(OsString::from("-S"));
171    arguments.push(OsString::from(section));
172}
173
174fn first_line_bytes(output: &[u8]) -> Option<&[u8]> {
175    let line = output.split(|byte| *byte == b'\n').next()?;
176    let line = trim_ascii(line);
177    (!line.is_empty()).then_some(line)
178}
179
180fn first_nonempty_line(output: &[u8]) -> Option<String> {
181    String::from_utf8_lossy(output)
182        .lines()
183        .map(str::trim)
184        .find(|line| !line.is_empty())
185        .map(ToOwned::to_owned)
186}
187
188fn trim_ascii(mut value: &[u8]) -> &[u8] {
189    while value.first().is_some_and(u8::is_ascii_whitespace) {
190        value = &value[1..];
191    }
192    while value.last().is_some_and(u8::is_ascii_whitespace) {
193        value = &value[..value.len() - 1];
194    }
195    value
196}
197
198#[cfg(test)]
199mod tests {
200    use std::{
201        ffi::{OsStr, OsString},
202        io,
203        sync::Mutex,
204    };
205
206    use super::{
207        CommandOutput, CommandRunner, LocateError, ManualRequest, locate_manual_source_with,
208    };
209
210    struct StubRunner {
211        output: CommandOutput,
212        calls: Mutex<Vec<(OsString, Vec<OsString>)>>,
213    }
214
215    impl StubRunner {
216        fn returning(output: CommandOutput) -> Self {
217            Self {
218                output,
219                calls: Mutex::new(Vec::new()),
220            }
221        }
222    }
223
224    impl CommandRunner for StubRunner {
225        fn run(&self, program: &OsStr, arguments: &[OsString]) -> io::Result<CommandOutput> {
226            self.calls
227                .lock()
228                .expect("recorded calls lock")
229                .push((program.to_owned(), arguments.to_vec()));
230            Ok(self.output.clone())
231        }
232    }
233
234    #[test]
235    fn locates_the_first_path_and_passes_an_optional_section() {
236        let runner = StubRunner::returning(CommandOutput {
237            stdout: b" /usr/share/man/man1/printf.1.gz\n/other/path\n".to_vec(),
238            stderr: Vec::new(),
239            exit_code: 0,
240        });
241        let request = ManualRequest::new("printf", Some("1p".to_owned()));
242
243        let path = locate_manual_source_with(&request, &runner).expect("locate source");
244
245        assert_eq!(
246            path,
247            std::path::Path::new("/usr/share/man/man1/printf.1.gz")
248        );
249        assert_eq!(
250            *runner.calls.lock().expect("recorded calls lock"),
251            vec![(
252                OsString::from("man"),
253                vec![
254                    OsString::from("-w"),
255                    OsString::from("-S"),
256                    OsString::from("1p"),
257                    OsString::from("--"),
258                    OsString::from("printf")
259                ]
260            )]
261        );
262    }
263
264    #[test]
265    fn passes_a_dash_prefixed_topic_after_an_option_terminator() {
266        let runner = StubRunner::returning(CommandOutput {
267            stdout: b"/usr/share/man/man1/-dash.1.gz\n".to_vec(),
268            stderr: Vec::new(),
269            exit_code: 0,
270        });
271
272        locate_manual_source_with(&ManualRequest::new("-x", None), &runner).expect("locate source");
273
274        assert_eq!(
275            *runner.calls.lock().expect("recorded calls lock"),
276            vec![(
277                OsString::from("man"),
278                vec![
279                    OsString::from("-w"),
280                    OsString::from("--"),
281                    OsString::from("-x")
282                ]
283            )]
284        );
285    }
286
287    #[test]
288    fn reports_man_diagnostics_without_runtime_debug_output() {
289        let runner = StubRunner::returning(CommandOutput {
290            stdout: Vec::new(),
291            stderr: b"No manual entry for definitely-missing\ntrace noise\n".to_vec(),
292            exit_code: 16,
293        });
294
295        let error =
296            locate_manual_source_with(&ManualRequest::new("definitely-missing", None), &runner)
297                .expect_err("lookup must fail");
298
299        assert!(matches!(error, LocateError::NotFound { .. }));
300        assert_eq!(
301            error.to_string(),
302            "no local manual source was found for 'definitely-missing': No manual entry for definitely-missing"
303        );
304    }
305
306    #[test]
307    fn validates_the_request_before_starting_man() {
308        let runner = StubRunner::returning(CommandOutput::default());
309
310        assert!(matches!(
311            locate_manual_source_with(&ManualRequest::new("  ", None), &runner),
312            Err(LocateError::EmptyTopic)
313        ));
314        assert!(matches!(
315            locate_manual_source_with(&ManualRequest::new("git", Some(" ".to_owned())), &runner),
316            Err(LocateError::InvalidSection)
317        ));
318        assert!(runner.calls.lock().expect("recorded calls lock").is_empty());
319    }
320}