Skip to main content

aster/
commands.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::env;
3use std::fs::{self, File, OpenOptions};
4use std::io::{Read, Seek, SeekFrom, Write};
5use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
6use std::os::unix::process::CommandExt;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
11use std::sync::{Arc, Mutex};
12use std::thread;
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15use serde::{Deserialize, Serialize};
16
17const CACHE_VERSION: u32 = 3;
18const CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024;
19const OUTPUT_MAX_BYTES: u64 = 64 * 1024;
20const MAX_OPTION_COUNT: usize = 256;
21const MAX_OPTION_SPELLING_BYTES: usize = 128;
22const QUEUE_CAPACITY: usize = 64;
23const WORKER_COUNT: usize = 2;
24const SUCCESS_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
25const MISS_TTL: Duration = Duration::from_secs(24 * 60 * 60);
26const MAN_TIMEOUT: Duration = Duration::from_millis(1_500);
27#[cfg(target_os = "macos")]
28const HELP_TIMEOUT: Duration = Duration::from_millis(1_000);
29
30static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct CommandEntry {
34    pub name: String,
35    pub description: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct CommandMatch {
40    pub name: String,
41    pub description: String,
42    pub description_pending: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct OptionMatch {
47    pub spelling: String,
48    pub description: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct OptionMatches {
53    pub entries: Vec<OptionMatch>,
54    pub pending: bool,
55}
56
57#[derive(Debug)]
58pub struct CommandCatalog {
59    entries: Vec<CommandEntry>,
60    jobs: HashMap<String, DescriptionJob>,
61    state: Arc<Mutex<EnrichmentState>>,
62    queue: Option<SyncSender<DescriptionJob>>,
63}
64
65#[derive(Debug, Default)]
66struct EnrichmentState {
67    descriptions: HashMap<String, String>,
68    options: HashMap<String, Vec<OptionMatch>>,
69    settled: HashSet<String>,
70    pending: HashSet<String>,
71}
72
73#[derive(Debug, Clone)]
74struct DescriptionJob {
75    name: String,
76    path: PathBuf,
77    fingerprint: ExecutableFingerprint,
78    authored_description: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82struct ExecutableFingerprint {
83    path: PathBuf,
84    size: u64,
85    device: u64,
86    inode: u64,
87    mode: u32,
88    modified_secs: i64,
89    modified_nanos: i64,
90    changed_secs: i64,
91    changed_nanos: i64,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95struct CachedDescription {
96    fingerprint: ExecutableFingerprint,
97    checked_at_secs: u64,
98    description: Option<String>,
99    options: Vec<OptionMatch>,
100}
101
102#[derive(Debug, Default)]
103struct Enrichment {
104    description: Option<String>,
105    options: Vec<OptionMatch>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109struct DescriptionCache {
110    version: u32,
111    entries: BTreeMap<String, CachedDescription>,
112}
113
114impl Default for DescriptionCache {
115    fn default() -> Self {
116        Self {
117            version: CACHE_VERSION,
118            entries: BTreeMap::new(),
119        }
120    }
121}
122
123impl Default for CommandCatalog {
124    fn default() -> Self {
125        Self {
126            entries: Vec::new(),
127            jobs: HashMap::new(),
128            state: Arc::new(Mutex::new(EnrichmentState::default())),
129            queue: None,
130        }
131    }
132}
133
134impl CommandCatalog {
135    pub fn discover(cache_file: PathBuf) -> Self {
136        let cache = load_cache(&cache_file);
137        let now = now_secs();
138        let mut seen = HashSet::new();
139        let mut entries = Vec::new();
140        let mut jobs = HashMap::new();
141        let mut state = EnrichmentState::default();
142
143        for (name, description) in SHELL_BUILTINS {
144            seen.insert((*name).to_owned());
145            entries.push(CommandEntry {
146                name: (*name).to_owned(),
147                description: (*description).to_owned(),
148            });
149        }
150
151        if let Some(path) = env::var_os("PATH") {
152            for directory in env::split_paths(&path) {
153                let Ok(children) = fs::read_dir(&directory) else {
154                    continue;
155                };
156                for child in children.flatten() {
157                    let Some(name) = child.file_name().to_str().map(str::to_owned) else {
158                        continue;
159                    };
160                    if !valid_name(&name) || jobs.contains_key(&name) {
161                        continue;
162                    }
163                    let path = child.path();
164                    let Ok(metadata) = fs::metadata(&path) else {
165                        continue;
166                    };
167                    if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
168                        continue;
169                    }
170                    let authored = known_description(&name);
171                    let authored_description = !seen.insert(name.clone()) || authored.is_some();
172                    if !authored_description || authored.is_some() {
173                        entries.push(CommandEntry {
174                            description: authored
175                                .map(str::to_owned)
176                                .unwrap_or_else(|| fallback_description(&path)),
177                            name: name.clone(),
178                        });
179                    }
180
181                    let fingerprint = fingerprint(&path, &metadata);
182                    let job = DescriptionJob {
183                        name: name.clone(),
184                        path,
185                        fingerprint,
186                        authored_description,
187                    };
188                    if let Some(cached) = cache.entries.get(&name)
189                        && cached.fingerprint == job.fingerprint
190                        && cache_is_fresh(cached, now)
191                    {
192                        state.settled.insert(name.clone());
193                        if !authored_description && let Some(description) = &cached.description {
194                            state.descriptions.insert(name.clone(), description.clone());
195                        }
196                        if !cached.options.is_empty() {
197                            state.options.insert(name.clone(), cached.options.clone());
198                        }
199                    }
200                    jobs.insert(name, job);
201                }
202            }
203        }
204
205        entries.sort_unstable_by(|left, right| left.name.cmp(&right.name));
206        let state = Arc::new(Mutex::new(state));
207        let queue = start_workers(Arc::clone(&state), cache_file, cache);
208        Self {
209            entries,
210            jobs,
211            state,
212            queue,
213        }
214    }
215
216    pub fn matching(&self, prefix: &str, limit: usize) -> Vec<CommandMatch> {
217        let mut state = self.state.lock().expect("command state lock poisoned");
218        self.entries
219            .iter()
220            .filter(|entry| entry.name.starts_with(prefix))
221            .take(limit)
222            .map(|entry| {
223                let job = self.jobs.get(&entry.name);
224                let description = if job.is_some_and(|job| job.authored_description) {
225                    entry.description.clone()
226                } else {
227                    state
228                        .descriptions
229                        .get(&entry.name)
230                        .cloned()
231                        .unwrap_or_else(|| entry.description.clone())
232                };
233                let metadata_pending = enqueue_if_unsettled(&mut state, job, self.queue.as_ref());
234                let description_pending =
235                    metadata_pending && !job.is_some_and(|job| job.authored_description);
236
237                CommandMatch {
238                    name: entry.name.clone(),
239                    description,
240                    description_pending,
241                }
242            })
243            .collect()
244    }
245
246    pub fn matching_options(&self, command: &str, prefix: &str, limit: usize) -> OptionMatches {
247        let Some(job) = self.jobs.get(command) else {
248            return OptionMatches {
249                entries: Vec::new(),
250                pending: false,
251            };
252        };
253        let mut state = self.state.lock().expect("command state lock poisoned");
254        let pending = enqueue_if_unsettled(&mut state, Some(job), self.queue.as_ref());
255        let entries = state
256            .options
257            .get(command)
258            .into_iter()
259            .flatten()
260            .filter(|option| option.spelling.starts_with(prefix))
261            .take(limit)
262            .cloned()
263            .collect();
264        OptionMatches { entries, pending }
265    }
266
267    pub fn inventory(&self) -> Vec<CommandMatch> {
268        let state = self.state.lock().expect("command state lock poisoned");
269        self.entries
270            .iter()
271            .map(|entry| CommandMatch {
272                name: entry.name.clone(),
273                description: if self
274                    .jobs
275                    .get(&entry.name)
276                    .is_some_and(|job| job.authored_description)
277                {
278                    entry.description.clone()
279                } else {
280                    state
281                        .descriptions
282                        .get(&entry.name)
283                        .cloned()
284                        .unwrap_or_else(|| entry.description.clone())
285                },
286                description_pending: false,
287            })
288            .collect()
289    }
290
291    #[cfg(test)]
292    pub fn from_entries(entries: impl IntoIterator<Item = CommandEntry>) -> Self {
293        let mut entries: Vec<_> = entries.into_iter().collect();
294        entries.sort_unstable_by(|left, right| left.name.cmp(&right.name));
295        Self {
296            entries,
297            ..Self::default()
298        }
299    }
300
301    #[cfg(test)]
302    pub fn from_options(command: &str, options: Vec<OptionMatch>) -> Self {
303        let entry = CommandEntry {
304            name: command.to_owned(),
305            description: "Test command".to_owned(),
306        };
307        let job = DescriptionJob {
308            name: command.to_owned(),
309            path: PathBuf::from(command),
310            fingerprint: ExecutableFingerprint {
311                path: PathBuf::from(command),
312                size: 0,
313                device: 0,
314                inode: 0,
315                mode: 0,
316                modified_secs: 0,
317                modified_nanos: 0,
318                changed_secs: 0,
319                changed_nanos: 0,
320            },
321            authored_description: true,
322        };
323        let mut state = EnrichmentState::default();
324        state.options.insert(command.to_owned(), options);
325        state.settled.insert(command.to_owned());
326        Self {
327            entries: vec![entry],
328            jobs: HashMap::from([(command.to_owned(), job)]),
329            state: Arc::new(Mutex::new(state)),
330            queue: None,
331        }
332    }
333}
334
335fn enqueue_if_unsettled(
336    state: &mut EnrichmentState,
337    job: Option<&DescriptionJob>,
338    queue: Option<&SyncSender<DescriptionJob>>,
339) -> bool {
340    let (Some(job), Some(queue)) = (job, queue) else {
341        return false;
342    };
343    if state.settled.contains(&job.name) {
344        return false;
345    }
346    if state.pending.insert(job.name.clone()) {
347        match queue.try_send(job.clone()) {
348            Ok(()) => {}
349            Err(TrySendError::Full(_)) => {
350                state.pending.remove(&job.name);
351            }
352            Err(TrySendError::Disconnected(_)) => {
353                state.pending.remove(&job.name);
354                state.settled.insert(job.name.clone());
355                return false;
356            }
357        }
358    }
359    true
360}
361
362fn start_workers(
363    state: Arc<Mutex<EnrichmentState>>,
364    cache_file: PathBuf,
365    cache: DescriptionCache,
366) -> Option<SyncSender<DescriptionJob>> {
367    let (sender, receiver) = sync_channel(QUEUE_CAPACITY);
368    let receiver = Arc::new(Mutex::new(receiver));
369    let cache = Arc::new(Mutex::new(cache));
370    let mut started = 0;
371
372    for index in 0..WORKER_COUNT {
373        let state = Arc::clone(&state);
374        let receiver = Arc::clone(&receiver);
375        let cache = Arc::clone(&cache);
376        let cache_file = cache_file.clone();
377        let worker = thread::Builder::new()
378            .name(format!("aster-description-{index}"))
379            .spawn(move || description_worker(&receiver, &state, &cache_file, &cache));
380        if worker.is_ok() {
381            started += 1;
382        }
383    }
384
385    (started > 0).then_some(sender)
386}
387
388fn description_worker(
389    receiver: &Mutex<Receiver<DescriptionJob>>,
390    state: &Mutex<EnrichmentState>,
391    cache_file: &Path,
392    cache: &Mutex<DescriptionCache>,
393) {
394    loop {
395        let job = {
396            let receiver = receiver.lock().expect("description queue lock poisoned");
397            receiver.recv()
398        };
399        let Ok(job) = job else {
400            return;
401        };
402
403        let unchanged_before = fingerprint_matches(&job);
404        let enrichment = if unchanged_before {
405            discover_enrichment(&job, cache_file.parent().unwrap_or(Path::new("/tmp")))
406        } else {
407            Enrichment::default()
408        };
409        let cacheable = unchanged_before && fingerprint_matches(&job);
410        {
411            let mut state = state.lock().expect("command state lock poisoned");
412            state.pending.remove(&job.name);
413            state.settled.insert(job.name.clone());
414            if cacheable {
415                if !job.authored_description
416                    && let Some(description) = &enrichment.description
417                {
418                    state
419                        .descriptions
420                        .insert(job.name.clone(), description.clone());
421                }
422                if !enrichment.options.is_empty() {
423                    state
424                        .options
425                        .insert(job.name.clone(), enrichment.options.clone());
426                }
427            }
428        }
429
430        if cacheable {
431            let mut cache = cache.lock().expect("description cache lock poisoned");
432            cache.entries.insert(
433                job.name,
434                CachedDescription {
435                    fingerprint: job.fingerprint,
436                    checked_at_secs: now_secs(),
437                    description: enrichment.description,
438                    options: enrichment.options,
439                },
440            );
441            let _ = save_cache(cache_file, &cache);
442        }
443    }
444}
445
446fn discover_enrichment(job: &DescriptionJob, output_dir: &Path) -> Enrichment {
447    let mut enrichment = man_enrichment(&job.name, output_dir).unwrap_or_default();
448    if (enrichment.description.is_none() || enrichment.options.is_empty())
449        && let Some(help) = help_enrichment(job, output_dir)
450    {
451        if enrichment.description.is_none() {
452            enrichment.description = help.description;
453        }
454        if enrichment.options.is_empty() {
455            enrichment.options = help.options;
456        }
457    }
458    enrichment
459}
460
461fn man_enrichment(name: &str, output_dir: &Path) -> Option<Enrichment> {
462    let man = Path::new("/usr/bin/man");
463    if !man.is_file() {
464        return None;
465    }
466    let mut command = Command::new(man);
467    command
468        .arg("--")
469        .arg(name)
470        .env_clear()
471        .env("HOME", "/nonexistent")
472        .env("LC_ALL", "C")
473        .env("MANPAGER", "cat")
474        .env("PAGER", "cat");
475    let output = run_bounded(command, output_dir, MAN_TIMEOUT)?;
476    Some(Enrichment {
477        description: parse_man_description(name, &output),
478        options: parse_options(&output),
479    })
480}
481
482#[cfg(target_os = "macos")]
483fn help_enrichment(job: &DescriptionJob, output_dir: &Path) -> Option<Enrichment> {
484    let sandbox = Path::new("/usr/bin/sandbox-exec");
485    if !sandbox.is_file() {
486        return None;
487    }
488    let mut command = Command::new(sandbox);
489    command
490        .arg("-p")
491        .arg(
492            "(version 1) (deny default) (allow process-exec) (allow file-read*) \
493             (allow sysctl-read) (allow mach-lookup)",
494        )
495        .arg(&job.path)
496        .arg("--help")
497        .env_clear()
498        .env("HOME", "/nonexistent")
499        .env("LC_ALL", "C")
500        .env("NO_COLOR", "1")
501        .env("PAGER", "cat")
502        .env("MANPAGER", "cat")
503        .env("TERM", "dumb");
504    let output = run_bounded(command, output_dir, HELP_TIMEOUT)?;
505    Some(Enrichment {
506        description: parse_help_description(&job.name, &output),
507        options: parse_options(&output),
508    })
509}
510
511#[cfg(not(target_os = "macos"))]
512fn help_enrichment(_job: &DescriptionJob, _output_dir: &Path) -> Option<Enrichment> {
513    None
514}
515
516fn run_bounded(mut command: Command, output_dir: &Path, timeout: Duration) -> Option<String> {
517    let (path, mut output) = temporary_output(output_dir)?;
518    let stdout = output.try_clone().ok()?;
519    let stderr = output.try_clone().ok()?;
520    command
521        .current_dir("/")
522        .stdin(Stdio::null())
523        .stdout(Stdio::from(stdout))
524        .stderr(Stdio::from(stderr))
525        .process_group(0);
526    unsafe {
527        command.pre_exec(|| {
528            let limit = libc::rlimit {
529                rlim_cur: OUTPUT_MAX_BYTES,
530                rlim_max: OUTPUT_MAX_BYTES,
531            };
532            if libc::setrlimit(libc::RLIMIT_FSIZE, &limit) == -1 {
533                return Err(std::io::Error::last_os_error());
534            }
535            Ok(())
536        });
537    }
538
539    let mut child = match command.spawn() {
540        Ok(child) => child,
541        Err(_) => {
542            let _ = fs::remove_file(path);
543            return None;
544        }
545    };
546    let _ = fs::remove_file(&path);
547    let deadline = Instant::now() + timeout;
548    loop {
549        match child.try_wait() {
550            Ok(Some(_)) => break,
551            Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
552            Ok(None) | Err(_) => {
553                unsafe {
554                    libc::kill(-(child.id() as i32), libc::SIGKILL);
555                }
556                let _ = child.kill();
557                let _ = child.wait();
558                break;
559            }
560        }
561    }
562    unsafe {
563        libc::kill(-(child.id() as i32), libc::SIGKILL);
564    }
565    let _ = child.wait();
566
567    output.seek(SeekFrom::Start(0)).ok()?;
568    let mut bytes = Vec::new();
569    output.take(OUTPUT_MAX_BYTES).read_to_end(&mut bytes).ok()?;
570    String::from_utf8(bytes).ok()
571}
572
573fn temporary_output(directory: &Path) -> Option<(PathBuf, File)> {
574    for _ in 0..10 {
575        let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
576        let path = directory.join(format!(
577            ".description-output-{}-{sequence}",
578            std::process::id()
579        ));
580        let file = OpenOptions::new()
581            .create_new(true)
582            .read(true)
583            .write(true)
584            .mode(0o600)
585            .open(&path);
586        match file {
587            Ok(file) => return Some((path, file)),
588            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
589            Err(_) => return None,
590        }
591    }
592    None
593}
594
595fn parse_man_description(name: &str, output: &str) -> Option<String> {
596    let mut name_section = false;
597    for line in clean_lines(output) {
598        if line == "NAME" {
599            name_section = true;
600            continue;
601        }
602        if name_section {
603            name_section = false;
604            if let Some(description) = line.strip_prefix(name)
605                && description.chars().next().is_some_and(char::is_whitespace)
606                && let Some(description) =
607                    sanitize_description(description.trim_start().trim_start_matches('-'))
608            {
609                return Some(description);
610            }
611        }
612        let Some((names, description)) = line.split_once(" - ") else {
613            continue;
614        };
615        let exact = names.split(',').any(|candidate| {
616            candidate
617                .trim()
618                .strip_prefix(name)
619                .is_some_and(|rest| rest.is_empty() || rest.starts_with('('))
620        });
621        if exact && let Some(description) = sanitize_description(description) {
622            return Some(description);
623        }
624    }
625    None
626}
627
628#[cfg(any(target_os = "macos", test))]
629fn parse_help_description(name: &str, output: &str) -> Option<String> {
630    for line in clean_lines(output) {
631        let lower = line.to_ascii_lowercase();
632        let structural = [
633            "usage",
634            "options",
635            "commands",
636            "arguments",
637            "available commands",
638            "flags",
639            "examples",
640        ]
641        .iter()
642        .any(|heading| lower == *heading || lower.starts_with(&format!("{heading}:")));
643        let command_usage =
644            lower.starts_with(&format!("{name} [")) || lower.starts_with(&format!("{name} <"));
645        let diagnostic = [
646            "error:",
647            "warning:",
648            "failed",
649            "couldn't",
650            "unrecognized option",
651            "unknown option",
652        ]
653        .iter()
654        .any(|text| lower.contains(text));
655        if structural || command_usage || diagnostic || line.starts_with(['-', '[']) {
656            continue;
657        }
658        if let Some(description) = sanitize_description(&line)
659            && description.split_whitespace().count() >= 2
660        {
661            return Some(description);
662        }
663    }
664    None
665}
666
667fn parse_options(output: &str) -> Vec<OptionMatch> {
668    let mut entries: Vec<OptionMatch> = Vec::new();
669    let mut in_options = false;
670    let mut section_indent = 0;
671    let mut continuation_indexes = Vec::new();
672    let mut declaration_indent = 0;
673
674    for raw_line in output.lines() {
675        let Some(line) = clean_line(raw_line) else {
676            continuation_indexes.clear();
677            continue;
678        };
679        let text = line.trim();
680        let indent = line.len() - line.trim_start_matches(' ').len();
681
682        if is_options_heading(text) {
683            if in_options {
684                break;
685            }
686            in_options = true;
687            section_indent = indent;
688            continuation_indexes.clear();
689            continue;
690        }
691        if !in_options {
692            continue;
693        }
694        if indent <= section_indent && is_section_heading(text) {
695            break;
696        }
697
698        if !option_line_has_unsafe_data(raw_line)
699            && let Some(declaration) = parse_option_declaration(text)
700        {
701            continuation_indexes.clear();
702            declaration_indent = indent;
703            for spelling in declaration.spellings {
704                if let Some(index) = entries
705                    .iter()
706                    .position(|option| option.spelling == spelling)
707                {
708                    if entries[index].description.is_empty()
709                        && let Some(description) = &declaration.description
710                    {
711                        entries[index].description = description.clone();
712                    }
713                    continuation_indexes.push(index);
714                } else if entries.len() < MAX_OPTION_COUNT {
715                    entries.push(OptionMatch {
716                        spelling,
717                        description: declaration.description.clone().unwrap_or_default(),
718                    });
719                    continuation_indexes.push(entries.len() - 1);
720                }
721            }
722            continue;
723        }
724
725        if !continuation_indexes.is_empty()
726            && indent > declaration_indent
727            && !text.starts_with('-')
728            && !is_section_heading(text)
729            && let Some(continuation) = sanitize_description(text)
730        {
731            for &index in &continuation_indexes {
732                let combined = if entries[index].description.is_empty() {
733                    continuation.clone()
734                } else {
735                    format!("{} {continuation}", entries[index].description)
736                };
737                if let Some(description) = sanitize_description(&combined) {
738                    entries[index].description = description;
739                }
740            }
741        } else {
742            continuation_indexes.clear();
743        }
744    }
745
746    entries
747}
748
749struct ParsedOptionDeclaration {
750    spellings: Vec<String>,
751    description: Option<String>,
752}
753
754fn parse_option_declaration(line: &str) -> Option<ParsedOptionDeclaration> {
755    if !line.starts_with('-') {
756        return None;
757    }
758    let bytes = line.as_bytes();
759    let mut position = 0;
760    let mut spellings = Vec::new();
761
762    loop {
763        let (spelling, end) = option_spelling_at(line, position)?;
764        spellings.push(spelling.to_owned());
765        position = end;
766
767        if bytes.get(position) == Some(&b'=') {
768            position += 1;
769            let argument_start = position;
770            while bytes
771                .get(position)
772                .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b',')
773            {
774                position += 1;
775            }
776            if position == argument_start {
777                return None;
778            }
779        } else if bytes.get(position) == Some(&b'[') {
780            let argument_end = bytes[position..].iter().position(|byte| *byte == b']')? + position;
781            let argument = &line[position + 1..argument_end];
782            if !argument.strip_prefix('=').is_some_and(is_option_argument) {
783                return None;
784            }
785            position = argument_end + 1;
786        }
787
788        if bytes.get(position) == Some(&b',') {
789            position += 1;
790            skip_ascii_spaces(bytes, &mut position);
791            if bytes.get(position) == Some(&b'-') {
792                continue;
793            }
794            return None;
795        }
796
797        if position == bytes.len() {
798            break;
799        }
800        if !bytes[position].is_ascii_whitespace() {
801            return None;
802        }
803        skip_ascii_spaces(bytes, &mut position);
804        if position == bytes.len() {
805            break;
806        }
807        if bytes[position] == b'/' {
808            position += 1;
809            skip_ascii_spaces(bytes, &mut position);
810            continue;
811        }
812        if bytes[position] == b'-' {
813            continue;
814        }
815
816        let token_end = bytes[position..]
817            .iter()
818            .position(u8::is_ascii_whitespace)
819            .map_or(bytes.len(), |offset| position + offset);
820        let argument = line[position..token_end].trim_end_matches(',');
821        if is_option_argument(argument) {
822            position = token_end;
823            skip_ascii_spaces(bytes, &mut position);
824            if line[..token_end].ends_with(',') {
825                continue;
826            }
827        }
828        let description = (position < bytes.len())
829            .then(|| sanitize_description(&line[position..]))
830            .flatten();
831        return Some(ParsedOptionDeclaration {
832            spellings,
833            description,
834        });
835    }
836
837    Some(ParsedOptionDeclaration {
838        spellings,
839        description: None,
840    })
841}
842
843fn option_spelling_at(line: &str, position: usize) -> Option<(&str, usize)> {
844    let bytes = line.as_bytes();
845    if bytes.get(position) != Some(&b'-') {
846        return None;
847    }
848    let mut end = position + 1;
849    if bytes.get(end) == Some(&b'-') {
850        end += 1;
851        let name_start = end;
852        while bytes
853            .get(end)
854            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
855        {
856            end += 1;
857        }
858        if end == name_start {
859            return None;
860        }
861    } else {
862        if !bytes.get(end).is_some_and(u8::is_ascii_alphanumeric) {
863            return None;
864        }
865        end += 1;
866    }
867
868    let spelling = &line[position..end];
869    let delimiter = bytes.get(end);
870    if !valid_option_spelling(spelling)
871        || delimiter
872            .is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b',' | b'=' | b'['))
873    {
874        return None;
875    }
876    Some((spelling, end))
877}
878
879fn valid_option_spelling(spelling: &str) -> bool {
880    if !spelling.is_ascii()
881        || spelling.len() > MAX_OPTION_SPELLING_BYTES
882        || !spelling.starts_with('-')
883    {
884        return false;
885    }
886    let bytes = spelling.as_bytes();
887    if bytes.get(1) == Some(&b'-') {
888        bytes.len() >= 3
889            && bytes[2].is_ascii_alphanumeric()
890            && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
891            && bytes[2..]
892                .iter()
893                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
894    } else {
895        bytes.len() == 2 && bytes[1].is_ascii_alphanumeric()
896    }
897}
898
899fn option_line_has_unsafe_data(line: &str) -> bool {
900    let characters: Vec<_> = line.chars().collect();
901    characters.iter().enumerate().any(|(index, &character)| {
902        if is_directional_format(character) {
903            return true;
904        }
905        if character == '\u{8}' {
906            return index == 0
907                || index + 1 == characters.len()
908                || (characters[index - 1] != characters[index + 1]
909                    && characters[index - 1] != '_');
910        }
911        character.is_control() && character != '\t'
912    })
913}
914
915fn is_option_argument(token: &str) -> bool {
916    let token = token
917        .strip_prefix('<')
918        .and_then(|token| token.strip_suffix('>'))
919        .or_else(|| {
920            token
921                .strip_prefix('[')
922                .and_then(|token| token.strip_suffix(']'))
923        })
924        .unwrap_or(token)
925        .trim_end_matches("...");
926    !token.is_empty()
927        && token.is_ascii()
928        && token
929            .bytes()
930            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
931}
932
933fn skip_ascii_spaces(bytes: &[u8], position: &mut usize) {
934    while bytes.get(*position).is_some_and(u8::is_ascii_whitespace) {
935        *position += 1;
936    }
937}
938
939fn is_options_heading(line: &str) -> bool {
940    matches!(
941        line.trim_end_matches(':').to_ascii_lowercase().as_str(),
942        "option"
943            | "options"
944            | "flags"
945            | "global options"
946            | "general options"
947            | "optional arguments"
948            | "the following options are available"
949    )
950}
951
952fn is_section_heading(line: &str) -> bool {
953    let heading = line.trim_end_matches(':');
954    let lower = heading.to_ascii_lowercase();
955    if matches!(
956        lower.as_str(),
957        "usage"
958            | "arguments"
959            | "commands"
960            | "available commands"
961            | "examples"
962            | "description"
963            | "synopsis"
964            | "operands"
965            | "environment"
966            | "exit status"
967            | "files"
968            | "authors"
969            | "bugs"
970            | "see also"
971    ) {
972        return true;
973    }
974    heading
975        .chars()
976        .any(|character| character.is_ascii_alphabetic())
977        && heading
978            .chars()
979            .all(|character| !character.is_ascii_alphabetic() || character.is_ascii_uppercase())
980}
981
982fn clean_lines(output: &str) -> impl Iterator<Item = String> + '_ {
983    output
984        .lines()
985        .filter_map(clean_line)
986        .map(|line| line.trim().to_owned())
987}
988
989fn clean_line(line: &str) -> Option<String> {
990    let mut clean = String::with_capacity(line.len());
991    let mut escape = 0;
992    for character in line.chars() {
993        if escape == 1 {
994            escape = match character {
995                '[' => 2,
996                ']' => 3,
997                _ => 0,
998            };
999            continue;
1000        }
1001        if escape == 2 {
1002            if ('@'..='~').contains(&character) {
1003                escape = 0;
1004            }
1005            continue;
1006        }
1007        if escape == 3 {
1008            continue;
1009        }
1010        if character == '\u{1b}' {
1011            escape = 1;
1012        } else if character == '\u{8}' {
1013            clean.pop();
1014        } else if character == '\t' {
1015            clean.push(' ');
1016        } else if !character.is_control() && !is_directional_format(character) {
1017            clean.push(character);
1018        }
1019    }
1020    let clean = clean.trim_end().to_owned();
1021    (!clean.trim().is_empty()).then_some(clean)
1022}
1023
1024fn sanitize_description(description: &str) -> Option<String> {
1025    let mut clean = description.split_whitespace().collect::<Vec<_>>().join(" ");
1026    clean.retain(|character| !character.is_control() && !is_directional_format(character));
1027    if clean.is_empty() || !clean.chars().any(char::is_alphabetic) {
1028        return None;
1029    }
1030    if clean.chars().count() > 200 {
1031        clean = clean.chars().take(199).collect();
1032        clean.push('…');
1033    }
1034    Some(clean)
1035}
1036
1037fn is_directional_format(character: char) -> bool {
1038    matches!(character, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
1039}
1040
1041fn load_cache(path: &Path) -> DescriptionCache {
1042    let Ok(metadata) = fs::metadata(path) else {
1043        return DescriptionCache::default();
1044    };
1045    if metadata.len() > CACHE_MAX_BYTES {
1046        return DescriptionCache::default();
1047    }
1048    let Ok(bytes) = fs::read(path) else {
1049        return DescriptionCache::default();
1050    };
1051    let Ok(mut cache) = serde_json::from_slice::<DescriptionCache>(&bytes) else {
1052        return DescriptionCache::default();
1053    };
1054    if cache.version != CACHE_VERSION {
1055        return DescriptionCache::default();
1056    }
1057    for cached in cache.entries.values_mut() {
1058        let mut seen = HashSet::new();
1059        cached.options.retain_mut(|option| {
1060            if !valid_option_spelling(&option.spelling) || !seen.insert(option.spelling.clone()) {
1061                return false;
1062            }
1063            option.description = sanitize_description(&option.description).unwrap_or_default();
1064            true
1065        });
1066        cached.options.truncate(MAX_OPTION_COUNT);
1067    }
1068    cache
1069}
1070
1071fn save_cache(path: &Path, cache: &DescriptionCache) -> std::io::Result<()> {
1072    let mut bounded = cache.clone();
1073    let bytes = loop {
1074        let bytes = serde_json::to_vec(&bounded)?;
1075        if bytes.len() as u64 <= CACHE_MAX_BYTES || bounded.entries.is_empty() {
1076            break bytes;
1077        }
1078        let Some(oldest) = bounded
1079            .entries
1080            .iter()
1081            .min_by_key(|(_, entry)| entry.checked_at_secs)
1082            .map(|(name, _)| name.clone())
1083        else {
1084            break bytes;
1085        };
1086        bounded.entries.remove(&oldest);
1087    };
1088    let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1089    let temporary = path.with_extension(format!("tmp-{}-{sequence}", std::process::id()));
1090    let result = (|| {
1091        let mut file = OpenOptions::new()
1092            .create_new(true)
1093            .write(true)
1094            .mode(0o600)
1095            .open(&temporary)?;
1096        file.write_all(&bytes)?;
1097        file.write_all(b"\n")?;
1098        file.sync_all()?;
1099        fs::rename(&temporary, path)
1100    })();
1101    if result.is_err() {
1102        let _ = fs::remove_file(temporary);
1103    }
1104    result
1105}
1106
1107fn cache_is_fresh(cached: &CachedDescription, now: u64) -> bool {
1108    let ttl = if !cached.options.is_empty() {
1109        SUCCESS_TTL
1110    } else {
1111        MISS_TTL
1112    };
1113    now >= cached.checked_at_secs && now - cached.checked_at_secs <= ttl.as_secs()
1114}
1115
1116fn fingerprint(path: &Path, metadata: &fs::Metadata) -> ExecutableFingerprint {
1117    ExecutableFingerprint {
1118        path: path.to_path_buf(),
1119        size: metadata.len(),
1120        device: metadata.dev(),
1121        inode: metadata.ino(),
1122        mode: metadata.mode(),
1123        modified_secs: metadata.mtime(),
1124        modified_nanos: metadata.mtime_nsec(),
1125        changed_secs: metadata.ctime(),
1126        changed_nanos: metadata.ctime_nsec(),
1127    }
1128}
1129
1130fn fingerprint_matches(job: &DescriptionJob) -> bool {
1131    fs::metadata(&job.path)
1132        .map(|metadata| fingerprint(&job.path, &metadata) == job.fingerprint)
1133        .unwrap_or(false)
1134}
1135
1136fn now_secs() -> u64 {
1137    SystemTime::now()
1138        .duration_since(UNIX_EPOCH)
1139        .unwrap_or_default()
1140        .as_secs()
1141}
1142
1143fn valid_name(name: &str) -> bool {
1144    !name.is_empty()
1145        && !name.chars().any(char::is_control)
1146        && name
1147            .bytes()
1148            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+'))
1149}
1150
1151fn fallback_description(path: &Path) -> String {
1152    let path = path.to_string_lossy();
1153    if path.contains("/.cargo/bin/") {
1154        "Executable installed by Cargo".to_owned()
1155    } else if path.contains("/homebrew/") || path.contains("/Cellar/") {
1156        "Homebrew command".to_owned()
1157    } else if path.contains("/.local/bin/") || path.contains("/bin/") && path.contains("/Users/") {
1158        "User-installed command".to_owned()
1159    } else {
1160        "System command".to_owned()
1161    }
1162}
1163
1164fn known_description(name: &str) -> Option<&'static str> {
1165    Some(match name {
1166        "ansible" => "Define and run automation tasks",
1167        "appwrite" => "Manage Appwrite projects and services",
1168        "arch" => "Print architecture type or run a universal binary",
1169        "asr" => "Apple Software Restore; copy volumes and disk images",
1170        "atlas" => "CLI tool to manage MongoDB Atlas",
1171        "aws" => "Official command line interface for Amazon Web Services",
1172        "aws-vault" => "Securely store and access AWS credentials",
1173        "bash" => "GNU Bourne Again shell",
1174        "brew" => "The missing package manager for macOS",
1175        "cargo" => "Rust package manager and build tool",
1176        "cmake" => "Configure, build, and test software projects",
1177        "code" => "Open Visual Studio Code",
1178        "curl" => "Transfer data from or to a server",
1179        "docker" => "Build and run applications in containers",
1180        "fd" => "Fast and user-friendly file finder",
1181        "fzf" => "Command-line fuzzy finder",
1182        "gh" => "GitHub command line interface",
1183        "git" => "Distributed version control system",
1184        "go" => "Build and manage Go source code",
1185        "iris" => "Interactive shell assistant",
1186        "jq" => "Process and transform JSON",
1187        "kubectl" => "Control Kubernetes clusters",
1188        "make" => "Maintain and build groups of programs",
1189        "node" => "Run JavaScript with Node.js",
1190        "npm" => "JavaScript package manager",
1191        "nvim" => "Edit text with Neovim",
1192        "pnpm" => "Fast, disk-efficient JavaScript package manager",
1193        "python" | "python3" => "Run the Python interpreter",
1194        "rg" => "Recursively search files with ripgrep",
1195        "rustc" => "Compile Rust source code",
1196        "ssh" => "OpenSSH remote login client",
1197        "tmux" => "Terminal multiplexer",
1198        "yarn" => "JavaScript package manager",
1199        "zsh" => "Z shell command interpreter",
1200        _ => return None,
1201    })
1202}
1203
1204const SHELL_BUILTINS: &[(&str, &str)] = &[
1205    ("alias", "Define or display shell aliases"),
1206    ("autoload", "Mark shell functions for automatic loading"),
1207    ("bg", "Resume jobs in the background"),
1208    ("cd", "Change the current working directory"),
1209    ("command", "Execute a command without shell function lookup"),
1210    ("export", "Set environment variables for child processes"),
1211    ("fg", "Bring jobs into the foreground"),
1212    ("jobs", "Display active shell jobs"),
1213    ("setopt", "Enable Zsh options"),
1214    (
1215        "source",
1216        "Execute commands from a file in the current shell",
1217    ),
1218    ("typeset", "Declare shell variables and attributes"),
1219    ("unalias", "Remove shell alias definitions"),
1220    ("unset", "Remove shell variables or functions"),
1221    ("unsetopt", "Disable Zsh options"),
1222];
1223
1224#[cfg(test)]
1225mod tests {
1226    use super::*;
1227    use tempfile::tempdir;
1228
1229    #[test]
1230    fn matches_sorted_prefixes() {
1231        let catalog = CommandCatalog::from_entries([
1232            CommandEntry {
1233                name: "atlas".to_owned(),
1234                description: "MongoDB Atlas".to_owned(),
1235            },
1236            CommandEntry {
1237                name: "arch".to_owned(),
1238                description: "Architecture".to_owned(),
1239            },
1240        ]);
1241
1242        let names: Vec<_> = catalog
1243            .matching("a", 10)
1244            .into_iter()
1245            .map(|entry| entry.name)
1246            .collect();
1247        assert_eq!(names, ["arch", "atlas"]);
1248    }
1249
1250    #[test]
1251    fn rejects_shell_metacharacters_in_names() {
1252        assert!(valid_name("aws-vault"));
1253        assert!(!valid_name("bad command"));
1254        assert!(!valid_name("bad;command"));
1255    }
1256
1257    #[test]
1258    fn parses_exact_man_description() {
1259        let output = "assetutil(1) - process asset catalog.car files\n\
1260                      other(1) - unrelated\n";
1261        assert_eq!(
1262            parse_man_description("assetutil", output).as_deref(),
1263            Some("process asset catalog.car files")
1264        );
1265        assert_eq!(parse_man_description("asset", output), None);
1266    }
1267
1268    #[test]
1269    fn parses_overstruck_man_name_section() {
1270        let output = "N\u{8}NA\u{8}AM\u{8}ME\u{8}E\n       a\u{8}as\u{8}s - assembler\n";
1271        assert_eq!(
1272            parse_man_description("as", output).as_deref(),
1273            Some("assembler")
1274        );
1275    }
1276
1277    #[test]
1278    fn parses_man_name_section_without_separator() {
1279        let output = "NAME\n     assetutil process asset catalog files\n\nSYNOPSIS\n";
1280        assert_eq!(
1281            parse_man_description("assetutil", output).as_deref(),
1282            Some("process asset catalog files")
1283        );
1284    }
1285
1286    #[test]
1287    fn parses_prose_from_help_output() {
1288        let output =
1289            "Usage: tool [OPTIONS]\n\nInspect a project without changing it.\n\nOptions:\n";
1290        assert_eq!(
1291            parse_help_description("tool", output).as_deref(),
1292            Some("Inspect a project without changing it.")
1293        );
1294    }
1295
1296    #[test]
1297    fn rejects_help_diagnostics() {
1298        let output = "tool: error: couldn't create cache file\nUsage: tool [OPTIONS]\n";
1299        assert_eq!(parse_help_description("tool", output), None);
1300    }
1301
1302    #[test]
1303    fn parses_rendered_man_option_declarations_and_continuations() {
1304        let output = "NAME\n    tool - inspect things\nThe following options are available:\n\
1305                      \x20   -a, --all\n\
1306                      \x20       Include hidden entries.\n\
1307                      \x20   -o, --output FILE  Write to FILE.\n\
1308                      \x20       --color=WHEN    Control colored output.\nARGUMENTS\n\
1309                      \x20   FILE  Input file.\n";
1310
1311        assert_eq!(
1312            parse_options(output),
1313            [
1314                OptionMatch {
1315                    spelling: "-a".to_owned(),
1316                    description: "Include hidden entries.".to_owned(),
1317                },
1318                OptionMatch {
1319                    spelling: "--all".to_owned(),
1320                    description: "Include hidden entries.".to_owned(),
1321                },
1322                OptionMatch {
1323                    spelling: "-o".to_owned(),
1324                    description: "Write to FILE.".to_owned(),
1325                },
1326                OptionMatch {
1327                    spelling: "--output".to_owned(),
1328                    description: "Write to FILE.".to_owned(),
1329                },
1330                OptionMatch {
1331                    spelling: "--color".to_owned(),
1332                    description: "Control colored output.".to_owned(),
1333                },
1334            ]
1335        );
1336    }
1337
1338    #[test]
1339    fn parses_common_help_option_sections() {
1340        let clap = "Usage: tool [OPTIONS]\n\nOptions:\n  -q, --quiet       Suppress output\n\
1341                    \x20     --format <FORMAT>  Select a format\n";
1342        let cobra = "Flags:\n  -h, --help   help for tool\nCommands:\n  child\n";
1343        let click = "Options:\n  --color / --no-color  Toggle color.\n  --help                  Show this message.\n";
1344        let argparse = "optional arguments:\n  -v, --verbose    increase verbosity\n  -o OUTPUT, --output OUTPUT  destination\n  --color[=WHEN]  color mode\n";
1345
1346        assert_eq!(
1347            parse_options(clap)
1348                .into_iter()
1349                .map(|option| option.spelling)
1350                .collect::<Vec<_>>(),
1351            ["-q", "--quiet", "--format"]
1352        );
1353        assert_eq!(
1354            parse_options(cobra),
1355            [
1356                OptionMatch {
1357                    spelling: "-h".to_owned(),
1358                    description: "help for tool".to_owned(),
1359                },
1360                OptionMatch {
1361                    spelling: "--help".to_owned(),
1362                    description: "help for tool".to_owned(),
1363                },
1364            ]
1365        );
1366        assert_eq!(parse_options(click).len(), 3);
1367        assert_eq!(
1368            parse_options(argparse)
1369                .into_iter()
1370                .map(|option| option.spelling)
1371                .collect::<Vec<_>>(),
1372            ["-v", "--verbose", "-o", "--output", "--color"]
1373        );
1374    }
1375
1376    #[test]
1377    fn rejects_usage_prose_subcommands_and_malicious_option_spellings() {
1378        let output = "Usage: tool --usage-only\n\
1379                      \x20Prose mentions --prose-only but is not an option.\n\
1380                      \x20Options:\n\
1381                      \x20  --safe        Safe option.\n\
1382                      \x20  --bad;touch   Not safe.\n\
1383                      \x20  --also$(evil) Not safe.\n\
1384                      \x20  --con\u{7}trol Control data.\n\
1385                      \x20  -abc          Combined spelling.\n\
1386                      \x20  —lookalike    Unicode dash.\n\
1387                      \x20Commands:\n\
1388                      \x20  child\n\
1389                      \x20Options:\n\
1390                      \x20  --child-only  Child option.\n";
1391
1392        assert_eq!(
1393            parse_options(output),
1394            [OptionMatch {
1395                spelling: "--safe".to_owned(),
1396                description: "Safe option.".to_owned(),
1397            }]
1398        );
1399        assert!(!option_line_has_unsafe_data("-\u{8}-, h\u{8}h"));
1400    }
1401
1402    #[test]
1403    fn strips_terminal_controls_from_descriptions() {
1404        let output = "tool(1) - \u{1b}[31mred\u{1b}[0m\u{202e} text\n";
1405        assert_eq!(
1406            parse_man_description("tool", output).as_deref(),
1407            Some("red text")
1408        );
1409    }
1410
1411    #[test]
1412    fn description_cache_round_trips() {
1413        let directory = tempdir().unwrap();
1414        let path = directory.path().join("descriptions.json");
1415        let mut cache = DescriptionCache::default();
1416        cache.entries.insert(
1417            "tool".to_owned(),
1418            CachedDescription {
1419                fingerprint: ExecutableFingerprint {
1420                    path: PathBuf::from("/usr/bin/tool"),
1421                    size: 42,
1422                    device: 1,
1423                    inode: 2,
1424                    mode: 0o100755,
1425                    modified_secs: 3,
1426                    modified_nanos: 4,
1427                    changed_secs: 5,
1428                    changed_nanos: 6,
1429                },
1430                checked_at_secs: 7,
1431                description: Some("Inspect a tool".to_owned()),
1432                options: vec![OptionMatch {
1433                    spelling: "--verbose".to_owned(),
1434                    description: "Show more detail".to_owned(),
1435                }],
1436            },
1437        );
1438
1439        save_cache(&path, &cache).unwrap();
1440        let loaded = load_cache(&path);
1441        assert_eq!(
1442            loaded.entries["tool"].description.as_deref(),
1443            Some("Inspect a tool")
1444        );
1445        assert_eq!(loaded.entries["tool"].fingerprint.size, 42);
1446        assert_eq!(
1447            loaded.entries["tool"].options,
1448            [OptionMatch {
1449                spelling: "--verbose".to_owned(),
1450                description: "Show more detail".to_owned(),
1451            }]
1452        );
1453    }
1454
1455    #[test]
1456    fn authored_descriptions_remain_preferred_during_enrichment() {
1457        let job = test_job("cargo", true);
1458        let mut state = EnrichmentState::default();
1459        state
1460            .descriptions
1461            .insert("cargo".to_owned(), "Parsed description".to_owned());
1462        let catalog = CommandCatalog {
1463            entries: vec![CommandEntry {
1464                name: "cargo".to_owned(),
1465                description: "Rust package manager and build tool".to_owned(),
1466            }],
1467            jobs: HashMap::from([("cargo".to_owned(), job)]),
1468            state: Arc::new(Mutex::new(state)),
1469            queue: None,
1470        };
1471
1472        assert_eq!(
1473            catalog.matching("cargo", 1)[0].description,
1474            "Rust package manager and build tool"
1475        );
1476    }
1477
1478    #[test]
1479    fn matches_cached_options_by_prefix_for_an_exact_command() {
1480        let mut state = EnrichmentState::default();
1481        state.settled.insert("tool".to_owned());
1482        state.options.insert(
1483            "tool".to_owned(),
1484            vec![
1485                OptionMatch {
1486                    spelling: "--all".to_owned(),
1487                    description: "Include all".to_owned(),
1488                },
1489                OptionMatch {
1490                    spelling: "--color".to_owned(),
1491                    description: "Control color".to_owned(),
1492                },
1493                OptionMatch {
1494                    spelling: "-v".to_owned(),
1495                    description: "Verbose".to_owned(),
1496                },
1497            ],
1498        );
1499        let catalog = CommandCatalog {
1500            jobs: HashMap::from([("tool".to_owned(), test_job("tool", false))]),
1501            state: Arc::new(Mutex::new(state)),
1502            ..CommandCatalog::default()
1503        };
1504
1505        let matches = catalog.matching_options("tool", "--", 1);
1506        assert_eq!(matches.entries[0].spelling, "--all");
1507        assert!(!matches.pending);
1508        assert!(
1509            catalog
1510                .matching_options("unknown", "-", 10)
1511                .entries
1512                .is_empty()
1513        );
1514        assert!(!catalog.matching_options("unknown", "-", 10).pending);
1515    }
1516
1517    #[test]
1518    fn unsettled_options_report_pending_without_blocking_on_a_full_queue() {
1519        let (sender, _receiver) = sync_channel(1);
1520        sender.try_send(test_job("queued", false)).unwrap();
1521        let catalog = CommandCatalog {
1522            jobs: HashMap::from([("tool".to_owned(), test_job("tool", false))]),
1523            queue: Some(sender),
1524            ..CommandCatalog::default()
1525        };
1526        let started = Instant::now();
1527
1528        let matches = catalog.matching_options("tool", "--", 10);
1529
1530        assert!(matches.entries.is_empty());
1531        assert!(matches.pending);
1532        assert!(started.elapsed() < Duration::from_millis(100));
1533    }
1534
1535    #[test]
1536    fn description_process_has_a_hard_timeout() {
1537        let directory = tempdir().unwrap();
1538        let mut command = Command::new("/bin/sleep");
1539        command.arg("2");
1540        let started = Instant::now();
1541
1542        assert_eq!(
1543            run_bounded(command, directory.path(), Duration::from_millis(30)).as_deref(),
1544            Some("")
1545        );
1546        assert!(started.elapsed() < Duration::from_secs(1));
1547    }
1548
1549    fn test_job(name: &str, authored_description: bool) -> DescriptionJob {
1550        DescriptionJob {
1551            name: name.to_owned(),
1552            path: PathBuf::from(format!("/usr/bin/{name}")),
1553            fingerprint: ExecutableFingerprint {
1554                path: PathBuf::from(format!("/usr/bin/{name}")),
1555                size: 1,
1556                device: 1,
1557                inode: 1,
1558                mode: 0o100755,
1559                modified_secs: 1,
1560                modified_nanos: 1,
1561                changed_secs: 1,
1562                changed_nanos: 1,
1563            },
1564            authored_description,
1565        }
1566    }
1567}