1use std::{
4 ffi::{OsStr, OsString},
5 fmt, io,
6 os::unix::ffi::OsStringExt,
7 path::PathBuf,
8 process::Command,
9};
10
11#[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#[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
36pub trait CommandRunner {
38 fn run(&self, program: &OsStr, arguments: &[OsString]) -> io::Result<CommandOutput>;
44}
45
46#[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#[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
112pub fn locate_manual_source(request: &ManualRequest) -> Result<PathBuf, LocateError> {
119 locate_manual_source_with(request, &SystemCommandRunner)
120}
121
122pub 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 push_section_filter(&mut arguments, section);
147 }
148 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}