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