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 = 4;
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 MAX_SUBCOMMAND_COUNT: usize = 256;
23const MAX_SUBCOMMAND_NAME_BYTES: usize = 128;
24const MAX_VALUE_COUNT: usize = 128;
25const MAX_VALUE_BYTES: usize = 128;
26const QUEUE_CAPACITY: usize = 64;
27const WORKER_COUNT: usize = 2;
28const SUCCESS_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
29const MISS_TTL: Duration = Duration::from_secs(24 * 60 * 60);
30const MAN_TIMEOUT: Duration = Duration::from_millis(1_500);
31#[cfg(target_os = "macos")]
32const HELP_TIMEOUT: Duration = Duration::from_millis(1_000);
33
34static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CommandEntry {
38    pub name: String,
39    pub description: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CommandMatch {
44    pub name: String,
45    pub description: String,
46    pub description_pending: bool,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct OptionMatch {
51    pub spelling: String,
52    pub description: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct OptionMatches {
57    pub entries: Vec<OptionMatch>,
58    pub pending: bool,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct SubcommandMatch {
63    pub name: String,
64    pub description: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct SubcommandMatches {
69    pub entries: Vec<SubcommandMatch>,
70    pub pending: bool,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ValueMatch {
75    pub value: String,
76    pub description: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ValueMatches {
81    pub entries: Vec<ValueMatch>,
82    pub pending: bool,
83}
84
85#[derive(Debug)]
86pub struct CommandCatalog {
87    entries: Vec<CommandEntry>,
88    jobs: HashMap<String, DescriptionJob>,
89    state: Arc<Mutex<EnrichmentState>>,
90    queue: Option<SyncSender<DescriptionJob>>,
91}
92
93#[derive(Debug, Default)]
94struct EnrichmentState {
95    descriptions: HashMap<String, String>,
96    options: HashMap<String, Vec<OptionMatch>>,
97    subcommands: HashMap<String, Vec<SubcommandMatch>>,
98    option_values: HashMap<String, Vec<OptionValues>>,
99    settled: HashSet<String>,
100    pending: HashSet<String>,
101}
102
103#[derive(Debug, Clone)]
104struct DescriptionJob {
105    name: String,
106    path: PathBuf,
107    fingerprint: ExecutableFingerprint,
108    authored_description: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112struct ExecutableFingerprint {
113    path: PathBuf,
114    size: u64,
115    device: u64,
116    inode: u64,
117    mode: u32,
118    modified_secs: i64,
119    modified_nanos: i64,
120    changed_secs: i64,
121    changed_nanos: i64,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125struct CachedDescription {
126    fingerprint: ExecutableFingerprint,
127    checked_at_secs: u64,
128    description: Option<String>,
129    options: Vec<OptionMatch>,
130    subcommands: Vec<SubcommandMatch>,
131    option_values: Vec<OptionValues>,
132}
133
134#[derive(Debug, Default)]
135struct Enrichment {
136    description: Option<String>,
137    options: Vec<OptionMatch>,
138    subcommands: Vec<SubcommandMatch>,
139    option_values: Vec<OptionValues>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143struct OptionValues {
144    option: String,
145    values: Vec<ValueMatch>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149struct DescriptionCache {
150    version: u32,
151    entries: BTreeMap<String, CachedDescription>,
152}
153
154impl Default for DescriptionCache {
155    fn default() -> Self {
156        Self {
157            version: CACHE_VERSION,
158            entries: BTreeMap::new(),
159        }
160    }
161}
162
163impl Default for CommandCatalog {
164    fn default() -> Self {
165        Self {
166            entries: Vec::new(),
167            jobs: HashMap::new(),
168            state: Arc::new(Mutex::new(EnrichmentState::default())),
169            queue: None,
170        }
171    }
172}
173
174impl CommandCatalog {
175    pub fn discover(cache_file: PathBuf) -> Self {
176        let cache = load_cache(&cache_file);
177        let now = now_secs();
178        let mut seen = HashSet::new();
179        let mut entries = Vec::new();
180        let mut jobs = HashMap::new();
181        let mut state = EnrichmentState::default();
182
183        for (name, description) in SHELL_BUILTINS {
184            seen.insert((*name).to_owned());
185            entries.push(CommandEntry {
186                name: (*name).to_owned(),
187                description: (*description).to_owned(),
188            });
189        }
190
191        if let Some(path) = env::var_os("PATH") {
192            for directory in env::split_paths(&path) {
193                let Ok(children) = fs::read_dir(&directory) else {
194                    continue;
195                };
196                for child in children.flatten() {
197                    let Some(name) = child.file_name().to_str().map(str::to_owned) else {
198                        continue;
199                    };
200                    if !valid_name(&name) || jobs.contains_key(&name) {
201                        continue;
202                    }
203                    let path = child.path();
204                    let Ok(metadata) = fs::metadata(&path) else {
205                        continue;
206                    };
207                    if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
208                        continue;
209                    }
210                    let authored = known_description(&name);
211                    let authored_description = !seen.insert(name.clone()) || authored.is_some();
212                    if !authored_description || authored.is_some() {
213                        entries.push(CommandEntry {
214                            description: authored
215                                .map(str::to_owned)
216                                .unwrap_or_else(|| fallback_description(&path)),
217                            name: name.clone(),
218                        });
219                    }
220
221                    let fingerprint = fingerprint(&path, &metadata);
222                    let job = DescriptionJob {
223                        name: name.clone(),
224                        path,
225                        fingerprint,
226                        authored_description,
227                    };
228                    if let Some(cached) = cache.entries.get(&name)
229                        && cached.fingerprint == job.fingerprint
230                        && cache_is_fresh(cached, now)
231                    {
232                        state.settled.insert(name.clone());
233                        if !authored_description && let Some(description) = &cached.description {
234                            state.descriptions.insert(name.clone(), description.clone());
235                        }
236                        if !cached.options.is_empty() {
237                            state.options.insert(name.clone(), cached.options.clone());
238                        }
239                        if !cached.subcommands.is_empty() {
240                            state
241                                .subcommands
242                                .insert(name.clone(), cached.subcommands.clone());
243                        }
244                        if !cached.option_values.is_empty() {
245                            state
246                                .option_values
247                                .insert(name.clone(), cached.option_values.clone());
248                        }
249                    }
250                    jobs.insert(name, job);
251                }
252            }
253        }
254
255        entries.sort_unstable_by(|left, right| left.name.cmp(&right.name));
256        let state = Arc::new(Mutex::new(state));
257        let queue = start_workers(Arc::clone(&state), cache_file, cache);
258        Self {
259            entries,
260            jobs,
261            state,
262            queue,
263        }
264    }
265
266    pub fn matching(&self, prefix: &str, limit: usize) -> Vec<CommandMatch> {
267        let mut state = self.state.lock().expect("command state lock poisoned");
268        self.entries
269            .iter()
270            .filter(|entry| entry.name.starts_with(prefix))
271            .take(limit)
272            .map(|entry| {
273                let job = self.jobs.get(&entry.name);
274                let description = if job.is_some_and(|job| job.authored_description) {
275                    entry.description.clone()
276                } else {
277                    state
278                        .descriptions
279                        .get(&entry.name)
280                        .cloned()
281                        .unwrap_or_else(|| entry.description.clone())
282                };
283                let metadata_pending = enqueue_if_unsettled(&mut state, job, self.queue.as_ref());
284                let description_pending =
285                    metadata_pending && !job.is_some_and(|job| job.authored_description);
286
287                CommandMatch {
288                    name: entry.name.clone(),
289                    description,
290                    description_pending,
291                }
292            })
293            .collect()
294    }
295
296    pub fn matching_options(&self, command: &str, prefix: &str, limit: usize) -> OptionMatches {
297        let Some(job) = self.jobs.get(command) else {
298            return OptionMatches {
299                entries: Vec::new(),
300                pending: false,
301            };
302        };
303        let mut state = self.state.lock().expect("command state lock poisoned");
304        let pending = enqueue_if_unsettled(&mut state, Some(job), self.queue.as_ref());
305        let entries = state
306            .options
307            .get(command)
308            .into_iter()
309            .flatten()
310            .filter(|option| option.spelling.starts_with(prefix))
311            .take(limit)
312            .cloned()
313            .collect();
314        OptionMatches { entries, pending }
315    }
316
317    pub fn matching_subcommands(
318        &self,
319        command: &str,
320        prefix: &str,
321        limit: usize,
322    ) -> SubcommandMatches {
323        let Some(job) = self.jobs.get(command) else {
324            return SubcommandMatches {
325                entries: Vec::new(),
326                pending: false,
327            };
328        };
329        let mut state = self.state.lock().expect("command state lock poisoned");
330        let pending = enqueue_if_unsettled(&mut state, Some(job), self.queue.as_ref());
331        let entries = state
332            .subcommands
333            .get(command)
334            .into_iter()
335            .flatten()
336            .filter(|subcommand| subcommand.name.starts_with(prefix))
337            .take(limit)
338            .cloned()
339            .collect();
340        SubcommandMatches { entries, pending }
341    }
342
343    pub fn matching_values(
344        &self,
345        command: &str,
346        option: &str,
347        prefix: &str,
348        limit: usize,
349    ) -> ValueMatches {
350        let Some(job) = self.jobs.get(command) else {
351            return ValueMatches {
352                entries: Vec::new(),
353                pending: false,
354            };
355        };
356        let mut state = self.state.lock().expect("command state lock poisoned");
357        let pending = enqueue_if_unsettled(&mut state, Some(job), self.queue.as_ref());
358        let entries = state
359            .option_values
360            .get(command)
361            .into_iter()
362            .flatten()
363            .find(|values| values.option == option)
364            .into_iter()
365            .flat_map(|values| &values.values)
366            .filter(|value| value.value.starts_with(prefix))
367            .take(limit)
368            .cloned()
369            .collect();
370        ValueMatches { entries, pending }
371    }
372
373    pub fn inventory(&self) -> Vec<CommandMatch> {
374        let state = self.state.lock().expect("command state lock poisoned");
375        self.entries
376            .iter()
377            .map(|entry| CommandMatch {
378                name: entry.name.clone(),
379                description: if self
380                    .jobs
381                    .get(&entry.name)
382                    .is_some_and(|job| job.authored_description)
383                {
384                    entry.description.clone()
385                } else {
386                    state
387                        .descriptions
388                        .get(&entry.name)
389                        .cloned()
390                        .unwrap_or_else(|| entry.description.clone())
391                },
392                description_pending: false,
393            })
394            .collect()
395    }
396
397    #[cfg(test)]
398    pub fn from_entries(entries: impl IntoIterator<Item = CommandEntry>) -> Self {
399        let mut entries: Vec<_> = entries.into_iter().collect();
400        entries.sort_unstable_by(|left, right| left.name.cmp(&right.name));
401        Self {
402            entries,
403            ..Self::default()
404        }
405    }
406
407    #[cfg(test)]
408    pub fn from_options(command: &str, options: Vec<OptionMatch>) -> Self {
409        Self::from_structured(command, options, Vec::new(), Vec::new())
410    }
411
412    #[cfg(test)]
413    pub fn from_structured(
414        command: &str,
415        options: Vec<OptionMatch>,
416        subcommands: Vec<SubcommandMatch>,
417        option_values: Vec<(String, Vec<ValueMatch>)>,
418    ) -> Self {
419        let entry = CommandEntry {
420            name: command.to_owned(),
421            description: "Test command".to_owned(),
422        };
423        let job = DescriptionJob {
424            name: command.to_owned(),
425            path: PathBuf::from(command),
426            fingerprint: ExecutableFingerprint {
427                path: PathBuf::from(command),
428                size: 0,
429                device: 0,
430                inode: 0,
431                mode: 0,
432                modified_secs: 0,
433                modified_nanos: 0,
434                changed_secs: 0,
435                changed_nanos: 0,
436            },
437            authored_description: true,
438        };
439        let mut state = EnrichmentState::default();
440        state.options.insert(command.to_owned(), options);
441        state.subcommands.insert(command.to_owned(), subcommands);
442        state.option_values.insert(
443            command.to_owned(),
444            option_values
445                .into_iter()
446                .map(|(option, values)| OptionValues { option, values })
447                .collect(),
448        );
449        state.settled.insert(command.to_owned());
450        Self {
451            entries: vec![entry],
452            jobs: HashMap::from([(command.to_owned(), job)]),
453            state: Arc::new(Mutex::new(state)),
454            queue: None,
455        }
456    }
457}
458
459fn enqueue_if_unsettled(
460    state: &mut EnrichmentState,
461    job: Option<&DescriptionJob>,
462    queue: Option<&SyncSender<DescriptionJob>>,
463) -> bool {
464    let (Some(job), Some(queue)) = (job, queue) else {
465        return false;
466    };
467    if state.settled.contains(&job.name) {
468        return false;
469    }
470    if state.pending.insert(job.name.clone()) {
471        match queue.try_send(job.clone()) {
472            Ok(()) => {}
473            Err(TrySendError::Full(_)) => {
474                state.pending.remove(&job.name);
475            }
476            Err(TrySendError::Disconnected(_)) => {
477                state.pending.remove(&job.name);
478                state.settled.insert(job.name.clone());
479                return false;
480            }
481        }
482    }
483    true
484}
485
486fn start_workers(
487    state: Arc<Mutex<EnrichmentState>>,
488    cache_file: PathBuf,
489    cache: DescriptionCache,
490) -> Option<SyncSender<DescriptionJob>> {
491    let (sender, receiver) = sync_channel(QUEUE_CAPACITY);
492    let receiver = Arc::new(Mutex::new(receiver));
493    let cache = Arc::new(Mutex::new(cache));
494    let mut started = 0;
495
496    for index in 0..WORKER_COUNT {
497        let state = Arc::clone(&state);
498        let receiver = Arc::clone(&receiver);
499        let cache = Arc::clone(&cache);
500        let cache_file = cache_file.clone();
501        let worker = thread::Builder::new()
502            .name(format!("aster-description-{index}"))
503            .spawn(move || description_worker(&receiver, &state, &cache_file, &cache));
504        if worker.is_ok() {
505            started += 1;
506        }
507    }
508
509    (started > 0).then_some(sender)
510}
511
512fn description_worker(
513    receiver: &Mutex<Receiver<DescriptionJob>>,
514    state: &Mutex<EnrichmentState>,
515    cache_file: &Path,
516    cache: &Mutex<DescriptionCache>,
517) {
518    loop {
519        let job = {
520            let receiver = receiver.lock().expect("description queue lock poisoned");
521            receiver.recv()
522        };
523        let Ok(job) = job else {
524            return;
525        };
526
527        let unchanged_before = fingerprint_matches(&job);
528        let enrichment = if unchanged_before {
529            discover_enrichment(&job, cache_file.parent().unwrap_or(Path::new("/tmp")))
530        } else {
531            Enrichment::default()
532        };
533        let cacheable = unchanged_before && fingerprint_matches(&job);
534        {
535            let mut state = state.lock().expect("command state lock poisoned");
536            state.pending.remove(&job.name);
537            state.settled.insert(job.name.clone());
538            if cacheable {
539                if !job.authored_description
540                    && let Some(description) = &enrichment.description
541                {
542                    state
543                        .descriptions
544                        .insert(job.name.clone(), description.clone());
545                }
546                if !enrichment.options.is_empty() {
547                    state
548                        .options
549                        .insert(job.name.clone(), enrichment.options.clone());
550                }
551                if !enrichment.subcommands.is_empty() {
552                    state
553                        .subcommands
554                        .insert(job.name.clone(), enrichment.subcommands.clone());
555                }
556                if !enrichment.option_values.is_empty() {
557                    state
558                        .option_values
559                        .insert(job.name.clone(), enrichment.option_values.clone());
560                }
561            }
562        }
563
564        if cacheable {
565            let mut cache = cache.lock().expect("description cache lock poisoned");
566            cache.entries.insert(
567                job.name,
568                CachedDescription {
569                    fingerprint: job.fingerprint,
570                    checked_at_secs: now_secs(),
571                    description: enrichment.description,
572                    options: enrichment.options,
573                    subcommands: enrichment.subcommands,
574                    option_values: enrichment.option_values,
575                },
576            );
577            let _ = save_cache(cache_file, &cache);
578        }
579    }
580}
581
582fn discover_enrichment(job: &DescriptionJob, output_dir: &Path) -> Enrichment {
583    let mut enrichment = man_enrichment(&job.name, output_dir).unwrap_or_default();
584    if (enrichment.description.is_none()
585        || enrichment.options.is_empty()
586        || enrichment.subcommands.is_empty()
587        || enrichment.option_values.is_empty())
588        && let Some(help) = help_enrichment(job, output_dir)
589    {
590        if enrichment.description.is_none() {
591            enrichment.description = help.description;
592        }
593        if enrichment.options.is_empty() {
594            enrichment.options = help.options;
595        }
596        if enrichment.subcommands.is_empty() {
597            enrichment.subcommands = help.subcommands;
598        }
599        if enrichment.option_values.is_empty() {
600            enrichment.option_values = help.option_values;
601        }
602    }
603    enrichment
604}
605
606fn man_enrichment(name: &str, output_dir: &Path) -> Option<Enrichment> {
607    let man = Path::new("/usr/bin/man");
608    if !man.is_file() {
609        return None;
610    }
611    let mut command = Command::new(man);
612    command
613        .arg("--")
614        .arg(name)
615        .env_clear()
616        .env("HOME", "/nonexistent")
617        .env("LC_ALL", "C")
618        .env("MANPAGER", "cat")
619        .env("PAGER", "cat");
620    let output = run_bounded(command, output_dir, MAN_TIMEOUT)?;
621    Some(Enrichment {
622        description: parse_man_description(name, &output),
623        options: parse_options(&output),
624        subcommands: parse_subcommands(&output),
625        option_values: parse_option_values(&output),
626    })
627}
628
629#[cfg(target_os = "macos")]
630fn help_enrichment(job: &DescriptionJob, output_dir: &Path) -> Option<Enrichment> {
631    let sandbox = Path::new("/usr/bin/sandbox-exec");
632    if !sandbox.is_file() {
633        return None;
634    }
635    let mut command = Command::new(sandbox);
636    command
637        .arg("-p")
638        .arg(
639            "(version 1) (deny default) (allow process-exec) (allow file-read*) \
640             (allow sysctl-read) (allow mach-lookup)",
641        )
642        .arg(&job.path)
643        .arg("--help")
644        .env_clear()
645        .env("HOME", "/nonexistent")
646        .env("LC_ALL", "C")
647        .env("NO_COLOR", "1")
648        .env("PAGER", "cat")
649        .env("MANPAGER", "cat")
650        .env("TERM", "dumb");
651    let output = run_bounded(command, output_dir, HELP_TIMEOUT)?;
652    Some(Enrichment {
653        description: parse_help_description(&job.name, &output),
654        options: parse_options(&output),
655        subcommands: parse_subcommands(&output),
656        option_values: parse_option_values(&output),
657    })
658}
659
660#[cfg(not(target_os = "macos"))]
661fn help_enrichment(_job: &DescriptionJob, _output_dir: &Path) -> Option<Enrichment> {
662    None
663}
664
665fn run_bounded(mut command: Command, output_dir: &Path, timeout: Duration) -> Option<String> {
666    let (path, mut output) = temporary_output(output_dir)?;
667    let stdout = output.try_clone().ok()?;
668    let stderr = output.try_clone().ok()?;
669    command
670        .current_dir("/")
671        .stdin(Stdio::null())
672        .stdout(Stdio::from(stdout))
673        .stderr(Stdio::from(stderr))
674        .process_group(0);
675    unsafe {
676        command.pre_exec(|| {
677            let limit = libc::rlimit {
678                rlim_cur: OUTPUT_MAX_BYTES,
679                rlim_max: OUTPUT_MAX_BYTES,
680            };
681            if libc::setrlimit(libc::RLIMIT_FSIZE, &limit) == -1 {
682                return Err(std::io::Error::last_os_error());
683            }
684            Ok(())
685        });
686    }
687
688    let mut child = match command.spawn() {
689        Ok(child) => child,
690        Err(_) => {
691            let _ = fs::remove_file(path);
692            return None;
693        }
694    };
695    let _ = fs::remove_file(&path);
696    let deadline = Instant::now() + timeout;
697    loop {
698        match child.try_wait() {
699            Ok(Some(_)) => break,
700            Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
701            Ok(None) | Err(_) => {
702                unsafe {
703                    libc::kill(-(child.id() as i32), libc::SIGKILL);
704                }
705                let _ = child.kill();
706                let _ = child.wait();
707                break;
708            }
709        }
710    }
711    unsafe {
712        libc::kill(-(child.id() as i32), libc::SIGKILL);
713    }
714    let _ = child.wait();
715
716    output.seek(SeekFrom::Start(0)).ok()?;
717    let mut bytes = Vec::new();
718    output.take(OUTPUT_MAX_BYTES).read_to_end(&mut bytes).ok()?;
719    String::from_utf8(bytes).ok()
720}
721
722fn temporary_output(directory: &Path) -> Option<(PathBuf, File)> {
723    for _ in 0..10 {
724        let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
725        let path = directory.join(format!(
726            ".description-output-{}-{sequence}",
727            std::process::id()
728        ));
729        let file = OpenOptions::new()
730            .create_new(true)
731            .read(true)
732            .write(true)
733            .mode(0o600)
734            .open(&path);
735        match file {
736            Ok(file) => return Some((path, file)),
737            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
738            Err(_) => return None,
739        }
740    }
741    None
742}
743
744fn parse_man_description(name: &str, output: &str) -> Option<String> {
745    let mut name_section = false;
746    for line in clean_lines(output) {
747        if line == "NAME" {
748            name_section = true;
749            continue;
750        }
751        if name_section {
752            name_section = false;
753            if let Some(description) = line.strip_prefix(name)
754                && description.chars().next().is_some_and(char::is_whitespace)
755                && let Some(description) =
756                    sanitize_description(description.trim_start().trim_start_matches('-'))
757            {
758                return Some(description);
759            }
760        }
761        let Some((names, description)) = line.split_once(" - ") else {
762            continue;
763        };
764        let exact = names.split(',').any(|candidate| {
765            candidate
766                .trim()
767                .strip_prefix(name)
768                .is_some_and(|rest| rest.is_empty() || rest.starts_with('('))
769        });
770        if exact && let Some(description) = sanitize_description(description) {
771            return Some(description);
772        }
773    }
774    None
775}
776
777#[cfg(any(target_os = "macos", test))]
778fn parse_help_description(name: &str, output: &str) -> Option<String> {
779    for line in clean_lines(output) {
780        let lower = line.to_ascii_lowercase();
781        let structural = [
782            "usage",
783            "options",
784            "commands",
785            "arguments",
786            "available commands",
787            "flags",
788            "examples",
789        ]
790        .iter()
791        .any(|heading| lower == *heading || lower.starts_with(&format!("{heading}:")));
792        let command_usage =
793            lower.starts_with(&format!("{name} [")) || lower.starts_with(&format!("{name} <"));
794        let diagnostic = [
795            "error:",
796            "warning:",
797            "failed",
798            "couldn't",
799            "unrecognized option",
800            "unknown option",
801        ]
802        .iter()
803        .any(|text| lower.contains(text));
804        if structural || command_usage || diagnostic || line.starts_with(['-', '[']) {
805            continue;
806        }
807        if let Some(description) = sanitize_description(&line)
808            && description.split_whitespace().count() >= 2
809        {
810            return Some(description);
811        }
812    }
813    None
814}
815
816fn parse_options(output: &str) -> Vec<OptionMatch> {
817    let mut entries: Vec<OptionMatch> = Vec::new();
818    let mut in_options = false;
819    let mut section_indent = 0;
820    let mut continuation_indexes = Vec::new();
821    let mut declaration_indent = 0;
822
823    for raw_line in output.lines() {
824        let Some(line) = clean_line(raw_line) else {
825            continuation_indexes.clear();
826            continue;
827        };
828        let text = line.trim();
829        let indent = line.len() - line.trim_start_matches(' ').len();
830
831        if is_options_heading(text) {
832            if in_options {
833                break;
834            }
835            in_options = true;
836            section_indent = indent;
837            continuation_indexes.clear();
838            continue;
839        }
840        if !in_options {
841            continue;
842        }
843        if indent <= section_indent && is_section_heading(text) {
844            break;
845        }
846
847        if !option_line_has_unsafe_data(raw_line)
848            && let Some(declaration) = parse_option_declaration(text)
849        {
850            continuation_indexes.clear();
851            declaration_indent = indent;
852            for spelling in declaration.spellings {
853                if let Some(index) = entries
854                    .iter()
855                    .position(|option| option.spelling == spelling)
856                {
857                    if entries[index].description.is_empty()
858                        && let Some(description) = &declaration.description
859                    {
860                        entries[index].description = description.clone();
861                    }
862                    continuation_indexes.push(index);
863                } else if entries.len() < MAX_OPTION_COUNT {
864                    entries.push(OptionMatch {
865                        spelling,
866                        description: declaration.description.clone().unwrap_or_default(),
867                    });
868                    continuation_indexes.push(entries.len() - 1);
869                }
870            }
871            continue;
872        }
873
874        if !continuation_indexes.is_empty()
875            && indent > declaration_indent
876            && !text.starts_with('-')
877            && !is_section_heading(text)
878            && let Some(continuation) = sanitize_description(text)
879        {
880            for &index in &continuation_indexes {
881                let combined = if entries[index].description.is_empty() {
882                    continuation.clone()
883                } else {
884                    format!("{} {continuation}", entries[index].description)
885                };
886                if let Some(description) = sanitize_description(&combined) {
887                    entries[index].description = description;
888                }
889            }
890        } else {
891            continuation_indexes.clear();
892        }
893    }
894
895    entries
896}
897
898fn parse_subcommands(output: &str) -> Vec<SubcommandMatch> {
899    let mut entries = Vec::new();
900    let mut seen = HashSet::new();
901    let mut in_commands = false;
902    let mut section_indent = 0;
903    let mut declaration_indent = None;
904
905    for raw_line in output.lines() {
906        let Some(line) = clean_line(raw_line) else {
907            continue;
908        };
909        let text = line.trim();
910        let indent = line.len() - line.trim_start_matches(' ').len();
911
912        if is_commands_heading(text) {
913            in_commands = true;
914            section_indent = indent;
915            declaration_indent = None;
916            continue;
917        }
918        if !in_commands {
919            continue;
920        }
921        if indent <= section_indent && is_section_heading(text) {
922            in_commands = false;
923            declaration_indent = None;
924            continue;
925        }
926        if indent <= section_indent
927            || option_line_has_unsafe_data(raw_line)
928            || text.starts_with('-')
929        {
930            continue;
931        }
932
933        let Some((names, description)) = parse_subcommand_declaration(text) else {
934            continue;
935        };
936        let expected_indent = *declaration_indent.get_or_insert(indent);
937        if indent != expected_indent {
938            continue;
939        }
940        for name in names {
941            if seen.insert(name.clone()) {
942                entries.push(SubcommandMatch {
943                    name,
944                    description: description.clone(),
945                });
946                if entries.len() >= MAX_SUBCOMMAND_COUNT {
947                    return entries;
948                }
949            }
950        }
951    }
952
953    entries
954}
955
956fn parse_subcommand_declaration(line: &str) -> Option<(Vec<String>, String)> {
957    let bytes = line.as_bytes();
958    let column = bytes
959        .windows(2)
960        .position(|pair| pair[0].is_ascii_whitespace() && pair[1].is_ascii_whitespace());
961    let (declaration, description) = if let Some(column) = column {
962        (&line[..column], line[column..].trim())
963    } else if line.bytes().any(|byte| byte.is_ascii_whitespace()) {
964        return None;
965    } else {
966        (line, "")
967    };
968    let names: Vec<_> = declaration
969        .split(',')
970        .map(|name| name.trim().trim_end_matches([':', '*']))
971        .filter(|name| valid_subcommand_name(name))
972        .map(str::to_owned)
973        .collect();
974    if names.is_empty() {
975        return None;
976    }
977    Some((names, sanitize_description(description).unwrap_or_default()))
978}
979
980fn parse_option_values(output: &str) -> Vec<OptionValues> {
981    let mut entries = Vec::new();
982    let mut in_options = false;
983    let mut section_indent = 0;
984    let mut declaration_indent = 0;
985    let mut current_options = Vec::new();
986    let mut collecting_list = false;
987
988    for raw_line in output.lines() {
989        let Some(line) = clean_line(raw_line) else {
990            collecting_list = false;
991            continue;
992        };
993        let text = line.trim();
994        let indent = line.len() - line.trim_start_matches(' ').len();
995
996        if is_options_heading(text) {
997            if in_options {
998                break;
999            }
1000            in_options = true;
1001            section_indent = indent;
1002            current_options.clear();
1003            continue;
1004        }
1005        if !in_options {
1006            continue;
1007        }
1008        if indent <= section_indent && is_section_heading(text) {
1009            break;
1010        }
1011        if option_line_has_unsafe_data(raw_line) {
1012            current_options.clear();
1013            collecting_list = false;
1014            continue;
1015        }
1016
1017        if let Some(declaration) = parse_option_declaration(text) {
1018            current_options = declaration.spellings;
1019            declaration_indent = indent;
1020            let (values, list_follows) = documented_values(text);
1021            insert_option_values(&mut entries, &current_options, values);
1022            collecting_list = list_follows;
1023            continue;
1024        }
1025
1026        if current_options.is_empty() || indent <= declaration_indent {
1027            collecting_list = false;
1028            continue;
1029        }
1030        let (values, list_follows) = documented_values(text);
1031        if !values.is_empty() || list_follows {
1032            insert_option_values(&mut entries, &current_options, values);
1033            collecting_list = list_follows;
1034        } else if collecting_list {
1035            if let Some(value) = documented_value_bullet(text) {
1036                insert_option_values(&mut entries, &current_options, vec![value]);
1037            } else {
1038                collecting_list = false;
1039            }
1040        }
1041    }
1042
1043    entries
1044}
1045
1046fn insert_option_values(
1047    entries: &mut Vec<OptionValues>,
1048    options: &[String],
1049    values: Vec<ValueMatch>,
1050) {
1051    if values.is_empty() {
1052        return;
1053    }
1054    for option in options {
1055        let index = entries
1056            .iter()
1057            .position(|entry| entry.option == *option)
1058            .unwrap_or_else(|| {
1059                entries.push(OptionValues {
1060                    option: option.clone(),
1061                    values: Vec::new(),
1062                });
1063                entries.len() - 1
1064            });
1065        for value in &values {
1066            if entries[index].values.len() >= MAX_VALUE_COUNT {
1067                break;
1068            }
1069            if let Some(existing) = entries[index]
1070                .values
1071                .iter_mut()
1072                .find(|existing| existing.value == value.value)
1073            {
1074                if existing.description.is_empty() {
1075                    existing.description = value.description.clone();
1076                }
1077            } else {
1078                entries[index].values.push(value.clone());
1079            }
1080        }
1081    }
1082}
1083
1084fn documented_values(line: &str) -> (Vec<ValueMatch>, bool) {
1085    let lower = line.to_ascii_lowercase();
1086    for marker in ["possible values:", "possible value:"] {
1087        if let Some(index) = lower.find(marker) {
1088            let remainder = line[index + marker.len()..]
1089                .trim()
1090                .trim_matches(|character| matches!(character, '[' | ']' | '(' | ')'))
1091                .trim();
1092            if remainder.is_empty() {
1093                return (Vec::new(), true);
1094            }
1095            return (parse_value_list(remainder, ','), false);
1096        }
1097    }
1098
1099    let syntax_end = line
1100        .as_bytes()
1101        .windows(2)
1102        .position(|pair| pair[0].is_ascii_whitespace() && pair[1].is_ascii_whitespace())
1103        .unwrap_or(line.len());
1104    let syntax = &line[..syntax_end];
1105    for (open, close, separator) in [('{', '}', ','), ('[', ']', '|'), ('<', '>', '|')] {
1106        let Some(start) = syntax.find(open) else {
1107            continue;
1108        };
1109        let Some(end_offset) = syntax[start + 1..].find(close) else {
1110            continue;
1111        };
1112        let values = &syntax[start + 1..start + 1 + end_offset];
1113        if values.contains(separator) {
1114            return (parse_value_list(values, separator), false);
1115        }
1116    }
1117    (Vec::new(), false)
1118}
1119
1120fn parse_value_list(values: &str, separator: char) -> Vec<ValueMatch> {
1121    values
1122        .split(separator)
1123        .filter_map(|value| {
1124            let value = value
1125                .trim()
1126                .trim_matches(|character| matches!(character, '\'' | '"' | '`'));
1127            valid_value(value).then(|| ValueMatch {
1128                value: value.to_owned(),
1129                description: String::new(),
1130            })
1131        })
1132        .take(MAX_VALUE_COUNT)
1133        .collect()
1134}
1135
1136fn documented_value_bullet(line: &str) -> Option<ValueMatch> {
1137    let line = line.strip_prefix('-')?.trim_start();
1138    let (value, description) = line
1139        .split_once(':')
1140        .map_or((line, ""), |(value, description)| {
1141            (value.trim(), description.trim())
1142        });
1143    let value = value.trim_matches(|character| matches!(character, '\'' | '"' | '`'));
1144    valid_value(value).then(|| ValueMatch {
1145        value: value.to_owned(),
1146        description: sanitize_description(description).unwrap_or_default(),
1147    })
1148}
1149
1150struct ParsedOptionDeclaration {
1151    spellings: Vec<String>,
1152    description: Option<String>,
1153}
1154
1155fn parse_option_declaration(line: &str) -> Option<ParsedOptionDeclaration> {
1156    if !line.starts_with('-') {
1157        return None;
1158    }
1159    let bytes = line.as_bytes();
1160    let mut position = 0;
1161    let mut spellings = Vec::new();
1162
1163    loop {
1164        let (spelling, end) = option_spelling_at(line, position)?;
1165        spellings.push(spelling.to_owned());
1166        position = end;
1167
1168        if bytes.get(position) == Some(&b'=') {
1169            position += 1;
1170            let argument_start = position;
1171            while bytes
1172                .get(position)
1173                .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b',')
1174            {
1175                position += 1;
1176            }
1177            if position == argument_start {
1178                return None;
1179            }
1180        } else if bytes.get(position) == Some(&b'[') {
1181            let argument_end = bytes[position..].iter().position(|byte| *byte == b']')? + position;
1182            let argument = &line[position + 1..argument_end];
1183            if !argument.strip_prefix('=').is_some_and(is_option_argument) {
1184                return None;
1185            }
1186            position = argument_end + 1;
1187        }
1188
1189        if bytes.get(position) == Some(&b',') {
1190            position += 1;
1191            skip_ascii_spaces(bytes, &mut position);
1192            if bytes.get(position) == Some(&b'-') {
1193                continue;
1194            }
1195            return None;
1196        }
1197
1198        if position == bytes.len() {
1199            break;
1200        }
1201        if !bytes[position].is_ascii_whitespace() {
1202            return None;
1203        }
1204        skip_ascii_spaces(bytes, &mut position);
1205        if position == bytes.len() {
1206            break;
1207        }
1208        if bytes[position] == b'/' {
1209            position += 1;
1210            skip_ascii_spaces(bytes, &mut position);
1211            continue;
1212        }
1213        if bytes[position] == b'-' {
1214            continue;
1215        }
1216
1217        let token_end = bytes[position..]
1218            .iter()
1219            .position(u8::is_ascii_whitespace)
1220            .map_or(bytes.len(), |offset| position + offset);
1221        let argument = line[position..token_end].trim_end_matches(',');
1222        if is_option_argument(argument) {
1223            position = token_end;
1224            skip_ascii_spaces(bytes, &mut position);
1225            if line[..token_end].ends_with(',') {
1226                continue;
1227            }
1228        }
1229        let description = (position < bytes.len())
1230            .then(|| sanitize_description(&line[position..]))
1231            .flatten();
1232        return Some(ParsedOptionDeclaration {
1233            spellings,
1234            description,
1235        });
1236    }
1237
1238    Some(ParsedOptionDeclaration {
1239        spellings,
1240        description: None,
1241    })
1242}
1243
1244fn option_spelling_at(line: &str, position: usize) -> Option<(&str, usize)> {
1245    let bytes = line.as_bytes();
1246    if bytes.get(position) != Some(&b'-') {
1247        return None;
1248    }
1249    let mut end = position + 1;
1250    if bytes.get(end) == Some(&b'-') {
1251        end += 1;
1252        let name_start = end;
1253        while bytes
1254            .get(end)
1255            .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1256        {
1257            end += 1;
1258        }
1259        if end == name_start {
1260            return None;
1261        }
1262    } else {
1263        if !bytes.get(end).is_some_and(u8::is_ascii_alphanumeric) {
1264            return None;
1265        }
1266        end += 1;
1267    }
1268
1269    let spelling = &line[position..end];
1270    let delimiter = bytes.get(end);
1271    if !valid_option_spelling(spelling)
1272        || delimiter
1273            .is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b',' | b'=' | b'['))
1274    {
1275        return None;
1276    }
1277    Some((spelling, end))
1278}
1279
1280fn valid_option_spelling(spelling: &str) -> bool {
1281    if !spelling.is_ascii()
1282        || spelling.len() > MAX_OPTION_SPELLING_BYTES
1283        || !spelling.starts_with('-')
1284    {
1285        return false;
1286    }
1287    let bytes = spelling.as_bytes();
1288    if bytes.get(1) == Some(&b'-') {
1289        bytes.len() >= 3
1290            && bytes[2].is_ascii_alphanumeric()
1291            && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
1292            && bytes[2..]
1293                .iter()
1294                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1295    } else {
1296        bytes.len() == 2 && bytes[1].is_ascii_alphanumeric()
1297    }
1298}
1299
1300fn option_line_has_unsafe_data(line: &str) -> bool {
1301    let characters: Vec<_> = line.chars().collect();
1302    characters.iter().enumerate().any(|(index, &character)| {
1303        if is_directional_format(character) {
1304            return true;
1305        }
1306        if character == '\u{8}' {
1307            return index == 0
1308                || index + 1 == characters.len()
1309                || (characters[index - 1] != characters[index + 1]
1310                    && characters[index - 1] != '_');
1311        }
1312        character.is_control() && character != '\t'
1313    })
1314}
1315
1316fn is_option_argument(token: &str) -> bool {
1317    let token = token
1318        .strip_prefix('<')
1319        .and_then(|token| token.strip_suffix('>'))
1320        .or_else(|| {
1321            token
1322                .strip_prefix('[')
1323                .and_then(|token| token.strip_suffix(']'))
1324        })
1325        .unwrap_or(token)
1326        .trim_end_matches("...");
1327    !token.is_empty()
1328        && token.is_ascii()
1329        && token
1330            .bytes()
1331            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
1332}
1333
1334fn skip_ascii_spaces(bytes: &[u8], position: &mut usize) {
1335    while bytes.get(*position).is_some_and(u8::is_ascii_whitespace) {
1336        *position += 1;
1337    }
1338}
1339
1340fn is_options_heading(line: &str) -> bool {
1341    matches!(
1342        line.trim_end_matches(':').to_ascii_lowercase().as_str(),
1343        "option"
1344            | "options"
1345            | "flags"
1346            | "global options"
1347            | "general options"
1348            | "optional arguments"
1349            | "the following options are available"
1350    )
1351}
1352
1353fn is_commands_heading(line: &str) -> bool {
1354    let has_colon = line.ends_with(':');
1355    let heading = line.trim_end_matches(':');
1356    let lower = heading.to_ascii_lowercase();
1357    let heading_shape = has_colon
1358        || heading
1359            .chars()
1360            .filter(|character| character.is_ascii_alphabetic())
1361            .all(|character| character.is_ascii_uppercase());
1362    matches!(
1363        lower.as_str(),
1364        "command" | "commands" | "available commands" | "subcommands" | "the commands are"
1365    ) || heading_shape && lower.ends_with(" commands")
1366}
1367
1368fn is_section_heading(line: &str) -> bool {
1369    let heading = line.trim_end_matches(':');
1370    let lower = heading.to_ascii_lowercase();
1371    if matches!(
1372        lower.as_str(),
1373        "usage"
1374            | "arguments"
1375            | "commands"
1376            | "available commands"
1377            | "examples"
1378            | "description"
1379            | "synopsis"
1380            | "operands"
1381            | "environment"
1382            | "exit status"
1383            | "files"
1384            | "authors"
1385            | "bugs"
1386            | "see also"
1387    ) {
1388        return true;
1389    }
1390    heading
1391        .chars()
1392        .any(|character| character.is_ascii_alphabetic())
1393        && heading
1394            .chars()
1395            .all(|character| !character.is_ascii_alphabetic() || character.is_ascii_uppercase())
1396}
1397
1398fn clean_lines(output: &str) -> impl Iterator<Item = String> + '_ {
1399    output
1400        .lines()
1401        .filter_map(clean_line)
1402        .map(|line| line.trim().to_owned())
1403}
1404
1405fn clean_line(line: &str) -> Option<String> {
1406    let mut clean = String::with_capacity(line.len());
1407    let mut escape = 0;
1408    for character in line.chars() {
1409        if escape == 1 {
1410            escape = match character {
1411                '[' => 2,
1412                ']' => 3,
1413                _ => 0,
1414            };
1415            continue;
1416        }
1417        if escape == 2 {
1418            if ('@'..='~').contains(&character) {
1419                escape = 0;
1420            }
1421            continue;
1422        }
1423        if escape == 3 {
1424            continue;
1425        }
1426        if character == '\u{1b}' {
1427            escape = 1;
1428        } else if character == '\u{8}' {
1429            clean.pop();
1430        } else if character == '\t' {
1431            clean.push(' ');
1432        } else if !character.is_control() && !is_directional_format(character) {
1433            clean.push(character);
1434        }
1435    }
1436    let clean = clean.trim_end().to_owned();
1437    (!clean.trim().is_empty()).then_some(clean)
1438}
1439
1440fn sanitize_description(description: &str) -> Option<String> {
1441    let mut clean = description.split_whitespace().collect::<Vec<_>>().join(" ");
1442    clean.retain(|character| !character.is_control() && !is_directional_format(character));
1443    if clean.is_empty() || !clean.chars().any(char::is_alphabetic) {
1444        return None;
1445    }
1446    if clean.chars().count() > 200 {
1447        clean = clean.chars().take(199).collect();
1448        clean.push('…');
1449    }
1450    Some(clean)
1451}
1452
1453fn is_directional_format(character: char) -> bool {
1454    matches!(character, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
1455}
1456
1457fn load_cache(path: &Path) -> DescriptionCache {
1458    let Ok(metadata) = fs::metadata(path) else {
1459        return DescriptionCache::default();
1460    };
1461    if metadata.len() > CACHE_MAX_BYTES {
1462        return DescriptionCache::default();
1463    }
1464    let Ok(bytes) = fs::read(path) else {
1465        return DescriptionCache::default();
1466    };
1467    let Ok(mut cache) = serde_json::from_slice::<DescriptionCache>(&bytes) else {
1468        return DescriptionCache::default();
1469    };
1470    if cache.version != CACHE_VERSION {
1471        return DescriptionCache::default();
1472    }
1473    for cached in cache.entries.values_mut() {
1474        let mut seen = HashSet::new();
1475        cached.options.retain_mut(|option| {
1476            if !valid_option_spelling(&option.spelling) || !seen.insert(option.spelling.clone()) {
1477                return false;
1478            }
1479            option.description = sanitize_description(&option.description).unwrap_or_default();
1480            true
1481        });
1482        cached.options.truncate(MAX_OPTION_COUNT);
1483
1484        seen.clear();
1485        cached.subcommands.retain_mut(|subcommand| {
1486            if !valid_subcommand_name(&subcommand.name) || !seen.insert(subcommand.name.clone()) {
1487                return false;
1488            }
1489            subcommand.description =
1490                sanitize_description(&subcommand.description).unwrap_or_default();
1491            true
1492        });
1493        cached.subcommands.truncate(MAX_SUBCOMMAND_COUNT);
1494
1495        seen.clear();
1496        cached.option_values.retain_mut(|option_values| {
1497            if !valid_option_spelling(&option_values.option)
1498                || !seen.insert(option_values.option.clone())
1499            {
1500                return false;
1501            }
1502            let mut seen_values = HashSet::new();
1503            option_values.values.retain_mut(|value| {
1504                if !valid_value(&value.value) || !seen_values.insert(value.value.clone()) {
1505                    return false;
1506                }
1507                value.description = sanitize_description(&value.description).unwrap_or_default();
1508                true
1509            });
1510            option_values.values.truncate(MAX_VALUE_COUNT);
1511            !option_values.values.is_empty()
1512        });
1513    }
1514    cache
1515}
1516
1517fn save_cache(path: &Path, cache: &DescriptionCache) -> std::io::Result<()> {
1518    let mut bounded = cache.clone();
1519    let bytes = loop {
1520        let bytes = serde_json::to_vec(&bounded)?;
1521        if bytes.len() as u64 <= CACHE_MAX_BYTES || bounded.entries.is_empty() {
1522            break bytes;
1523        }
1524        let Some(oldest) = bounded
1525            .entries
1526            .iter()
1527            .min_by_key(|(_, entry)| entry.checked_at_secs)
1528            .map(|(name, _)| name.clone())
1529        else {
1530            break bytes;
1531        };
1532        bounded.entries.remove(&oldest);
1533    };
1534    let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1535    let temporary = path.with_extension(format!("tmp-{}-{sequence}", std::process::id()));
1536    let result = (|| {
1537        let mut file = OpenOptions::new()
1538            .create_new(true)
1539            .write(true)
1540            .mode(0o600)
1541            .open(&temporary)?;
1542        file.write_all(&bytes)?;
1543        file.write_all(b"\n")?;
1544        file.sync_all()?;
1545        fs::rename(&temporary, path)
1546    })();
1547    if result.is_err() {
1548        let _ = fs::remove_file(temporary);
1549    }
1550    result
1551}
1552
1553fn cache_is_fresh(cached: &CachedDescription, now: u64) -> bool {
1554    let ttl = if !cached.options.is_empty()
1555        || !cached.subcommands.is_empty()
1556        || !cached.option_values.is_empty()
1557    {
1558        SUCCESS_TTL
1559    } else {
1560        MISS_TTL
1561    };
1562    now >= cached.checked_at_secs && now - cached.checked_at_secs <= ttl.as_secs()
1563}
1564
1565fn fingerprint(path: &Path, metadata: &fs::Metadata) -> ExecutableFingerprint {
1566    ExecutableFingerprint {
1567        path: path.to_path_buf(),
1568        size: metadata.len(),
1569        device: metadata.dev(),
1570        inode: metadata.ino(),
1571        mode: metadata.mode(),
1572        modified_secs: metadata.mtime(),
1573        modified_nanos: metadata.mtime_nsec(),
1574        changed_secs: metadata.ctime(),
1575        changed_nanos: metadata.ctime_nsec(),
1576    }
1577}
1578
1579fn fingerprint_matches(job: &DescriptionJob) -> bool {
1580    fs::metadata(&job.path)
1581        .map(|metadata| fingerprint(&job.path, &metadata) == job.fingerprint)
1582        .unwrap_or(false)
1583}
1584
1585fn now_secs() -> u64 {
1586    SystemTime::now()
1587        .duration_since(UNIX_EPOCH)
1588        .unwrap_or_default()
1589        .as_secs()
1590}
1591
1592fn valid_name(name: &str) -> bool {
1593    !name.is_empty()
1594        && !name.chars().any(char::is_control)
1595        && name
1596            .bytes()
1597            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+'))
1598}
1599
1600fn valid_subcommand_name(name: &str) -> bool {
1601    !name.is_empty()
1602        && name.len() <= MAX_SUBCOMMAND_NAME_BYTES
1603        && name.is_ascii()
1604        && name.as_bytes()[0].is_ascii_alphanumeric()
1605        && name
1606            .bytes()
1607            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+'))
1608}
1609
1610fn valid_value(value: &str) -> bool {
1611    !value.is_empty()
1612        && value.len() <= MAX_VALUE_BYTES
1613        && value.is_ascii()
1614        && value.bytes().all(|byte| {
1615            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+' | b'/')
1616        })
1617}
1618
1619fn fallback_description(path: &Path) -> String {
1620    let path = path.to_string_lossy();
1621    if path.contains("/.cargo/bin/") {
1622        "Executable installed by Cargo".to_owned()
1623    } else if path.contains("/homebrew/") || path.contains("/Cellar/") {
1624        "Homebrew command".to_owned()
1625    } else if path.contains("/.local/bin/") || path.contains("/bin/") && path.contains("/Users/") {
1626        "User-installed command".to_owned()
1627    } else {
1628        "System command".to_owned()
1629    }
1630}
1631
1632fn known_description(name: &str) -> Option<&'static str> {
1633    Some(match name {
1634        "ansible" => "Define and run automation tasks",
1635        "appwrite" => "Manage Appwrite projects and services",
1636        "arch" => "Print architecture type or run a universal binary",
1637        "asr" => "Apple Software Restore; copy volumes and disk images",
1638        "atlas" => "CLI tool to manage MongoDB Atlas",
1639        "aws" => "Official command line interface for Amazon Web Services",
1640        "aws-vault" => "Securely store and access AWS credentials",
1641        "bash" => "GNU Bourne Again shell",
1642        "brew" => "The missing package manager for macOS",
1643        "cargo" => "Rust package manager and build tool",
1644        "cmake" => "Configure, build, and test software projects",
1645        "code" => "Open Visual Studio Code",
1646        "curl" => "Transfer data from or to a server",
1647        "docker" => "Build and run applications in containers",
1648        "fd" => "Fast and user-friendly file finder",
1649        "fzf" => "Command-line fuzzy finder",
1650        "gh" => "GitHub command line interface",
1651        "git" => "Distributed version control system",
1652        "go" => "Build and manage Go source code",
1653        "iris" => "Interactive shell assistant",
1654        "jq" => "Process and transform JSON",
1655        "kubectl" => "Control Kubernetes clusters",
1656        "make" => "Maintain and build groups of programs",
1657        "node" => "Run JavaScript with Node.js",
1658        "npm" => "JavaScript package manager",
1659        "nvim" => "Edit text with Neovim",
1660        "pnpm" => "Fast, disk-efficient JavaScript package manager",
1661        "python" | "python3" => "Run the Python interpreter",
1662        "rg" => "Recursively search files with ripgrep",
1663        "rustc" => "Compile Rust source code",
1664        "ssh" => "OpenSSH remote login client",
1665        "tmux" => "Terminal multiplexer",
1666        "yarn" => "JavaScript package manager",
1667        "zsh" => "Z shell command interpreter",
1668        _ => return None,
1669    })
1670}
1671
1672const SHELL_BUILTINS: &[(&str, &str)] = &[
1673    ("alias", "Define or display shell aliases"),
1674    ("autoload", "Mark shell functions for automatic loading"),
1675    ("bg", "Resume jobs in the background"),
1676    ("cd", "Change the current working directory"),
1677    ("command", "Execute a command without shell function lookup"),
1678    ("export", "Set environment variables for child processes"),
1679    ("fg", "Bring jobs into the foreground"),
1680    ("jobs", "Display active shell jobs"),
1681    ("setopt", "Enable Zsh options"),
1682    (
1683        "source",
1684        "Execute commands from a file in the current shell",
1685    ),
1686    ("typeset", "Declare shell variables and attributes"),
1687    ("unalias", "Remove shell alias definitions"),
1688    ("unset", "Remove shell variables or functions"),
1689    ("unsetopt", "Disable Zsh options"),
1690];
1691
1692#[cfg(test)]
1693mod tests {
1694    use super::*;
1695    use tempfile::tempdir;
1696
1697    #[test]
1698    fn matches_sorted_prefixes() {
1699        let catalog = CommandCatalog::from_entries([
1700            CommandEntry {
1701                name: "atlas".to_owned(),
1702                description: "MongoDB Atlas".to_owned(),
1703            },
1704            CommandEntry {
1705                name: "arch".to_owned(),
1706                description: "Architecture".to_owned(),
1707            },
1708        ]);
1709
1710        let names: Vec<_> = catalog
1711            .matching("a", 10)
1712            .into_iter()
1713            .map(|entry| entry.name)
1714            .collect();
1715        assert_eq!(names, ["arch", "atlas"]);
1716    }
1717
1718    #[test]
1719    fn rejects_shell_metacharacters_in_names() {
1720        assert!(valid_name("aws-vault"));
1721        assert!(!valid_name("bad command"));
1722        assert!(!valid_name("bad;command"));
1723    }
1724
1725    #[test]
1726    fn parses_exact_man_description() {
1727        let output = "assetutil(1) - process asset catalog.car files\n\
1728                      other(1) - unrelated\n";
1729        assert_eq!(
1730            parse_man_description("assetutil", output).as_deref(),
1731            Some("process asset catalog.car files")
1732        );
1733        assert_eq!(parse_man_description("asset", output), None);
1734    }
1735
1736    #[test]
1737    fn parses_overstruck_man_name_section() {
1738        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";
1739        assert_eq!(
1740            parse_man_description("as", output).as_deref(),
1741            Some("assembler")
1742        );
1743    }
1744
1745    #[test]
1746    fn parses_man_name_section_without_separator() {
1747        let output = "NAME\n     assetutil process asset catalog files\n\nSYNOPSIS\n";
1748        assert_eq!(
1749            parse_man_description("assetutil", output).as_deref(),
1750            Some("process asset catalog files")
1751        );
1752    }
1753
1754    #[test]
1755    fn parses_prose_from_help_output() {
1756        let output =
1757            "Usage: tool [OPTIONS]\n\nInspect a project without changing it.\n\nOptions:\n";
1758        assert_eq!(
1759            parse_help_description("tool", output).as_deref(),
1760            Some("Inspect a project without changing it.")
1761        );
1762    }
1763
1764    #[test]
1765    fn rejects_help_diagnostics() {
1766        let output = "tool: error: couldn't create cache file\nUsage: tool [OPTIONS]\n";
1767        assert_eq!(parse_help_description("tool", output), None);
1768    }
1769
1770    #[test]
1771    fn parses_rendered_man_option_declarations_and_continuations() {
1772        let output = "NAME\n    tool - inspect things\nThe following options are available:\n\
1773                      \x20   -a, --all\n\
1774                      \x20       Include hidden entries.\n\
1775                      \x20   -o, --output FILE  Write to FILE.\n\
1776                      \x20       --color=WHEN    Control colored output.\nARGUMENTS\n\
1777                      \x20   FILE  Input file.\n";
1778
1779        assert_eq!(
1780            parse_options(output),
1781            [
1782                OptionMatch {
1783                    spelling: "-a".to_owned(),
1784                    description: "Include hidden entries.".to_owned(),
1785                },
1786                OptionMatch {
1787                    spelling: "--all".to_owned(),
1788                    description: "Include hidden entries.".to_owned(),
1789                },
1790                OptionMatch {
1791                    spelling: "-o".to_owned(),
1792                    description: "Write to FILE.".to_owned(),
1793                },
1794                OptionMatch {
1795                    spelling: "--output".to_owned(),
1796                    description: "Write to FILE.".to_owned(),
1797                },
1798                OptionMatch {
1799                    spelling: "--color".to_owned(),
1800                    description: "Control colored output.".to_owned(),
1801                },
1802            ]
1803        );
1804    }
1805
1806    #[test]
1807    fn parses_common_help_option_sections() {
1808        let clap = "Usage: tool [OPTIONS]\n\nOptions:\n  -q, --quiet       Suppress output\n\
1809                    \x20     --format <FORMAT>  Select a format\n";
1810        let cobra = "Flags:\n  -h, --help   help for tool\nCommands:\n  child\n";
1811        let click = "Options:\n  --color / --no-color  Toggle color.\n  --help                  Show this message.\n";
1812        let argparse = "optional arguments:\n  -v, --verbose    increase verbosity\n  -o OUTPUT, --output OUTPUT  destination\n  --color[=WHEN]  color mode\n";
1813
1814        assert_eq!(
1815            parse_options(clap)
1816                .into_iter()
1817                .map(|option| option.spelling)
1818                .collect::<Vec<_>>(),
1819            ["-q", "--quiet", "--format"]
1820        );
1821        assert_eq!(
1822            parse_options(cobra),
1823            [
1824                OptionMatch {
1825                    spelling: "-h".to_owned(),
1826                    description: "help for tool".to_owned(),
1827                },
1828                OptionMatch {
1829                    spelling: "--help".to_owned(),
1830                    description: "help for tool".to_owned(),
1831                },
1832            ]
1833        );
1834        assert_eq!(parse_options(click).len(), 3);
1835        assert_eq!(
1836            parse_options(argparse)
1837                .into_iter()
1838                .map(|option| option.spelling)
1839                .collect::<Vec<_>>(),
1840            ["-v", "--verbose", "-o", "--output", "--color"]
1841        );
1842    }
1843
1844    #[test]
1845    fn parses_explicit_subcommand_sections() {
1846        let output = "Usage: tool [COMMAND]\n\nAvailable Commands:\n\
1847                      \x20  build, b  Build the project\n\
1848                      \x20  inspect   Inspect project state\n\
1849                      \x20    continuation text that is not a command\n\nOptions:\n\
1850                      \x20  --help    Print help\n\nAdditional Commands:\n\
1851                      \x20  deploy:   Deploy the project\n";
1852
1853        assert_eq!(
1854            parse_subcommands(output),
1855            [
1856                SubcommandMatch {
1857                    name: "build".to_owned(),
1858                    description: "Build the project".to_owned(),
1859                },
1860                SubcommandMatch {
1861                    name: "b".to_owned(),
1862                    description: "Build the project".to_owned(),
1863                },
1864                SubcommandMatch {
1865                    name: "inspect".to_owned(),
1866                    description: "Inspect project state".to_owned(),
1867                },
1868                SubcommandMatch {
1869                    name: "deploy".to_owned(),
1870                    description: "Deploy the project".to_owned(),
1871                },
1872            ]
1873        );
1874
1875        let prose = "Tool for running commands\n\
1876                     \x20  This paragraph is not a command declaration.\n\nCommands:\n\
1877                     \x20  valid  A real command\n\
1878                     \x20  Commands can be abbreviated\n";
1879        assert_eq!(
1880            parse_subcommands(prose),
1881            [SubcommandMatch {
1882                name: "valid".to_owned(),
1883                description: "A real command".to_owned(),
1884            }]
1885        );
1886    }
1887
1888    #[test]
1889    fn parses_documented_option_values_for_every_alias() {
1890        let output = "Options:\n\
1891                      \x20  -c, --color <WHEN>  Color output [possible values: auto, always, never]\n\
1892                      \x20  --format {json,yaml}  Output format\n\
1893                      \x20  --mode <fast|safe>    Execution mode\n\
1894                      \x20  --template TEMPLATE  Expand {name,id} placeholders\n\
1895                      \x20  --target <TARGET>\n\
1896                      \x20      Possible values:\n\
1897                      \x20      - local: Run locally\n\
1898                      \x20      - remote: Run remotely\n";
1899        let values = parse_option_values(output);
1900
1901        for option in ["-c", "--color"] {
1902            assert_eq!(
1903                values
1904                    .iter()
1905                    .find(|entry| entry.option == option)
1906                    .unwrap()
1907                    .values
1908                    .iter()
1909                    .map(|value| value.value.as_str())
1910                    .collect::<Vec<_>>(),
1911                ["auto", "always", "never"]
1912            );
1913        }
1914        assert_eq!(values_for(&values, "--format"), ["json", "yaml"]);
1915        assert_eq!(values_for(&values, "--mode"), ["fast", "safe"]);
1916        assert!(!values.iter().any(|entry| entry.option == "--template"));
1917        assert_eq!(values_for(&values, "--target"), ["local", "remote"]);
1918        assert_eq!(
1919            values
1920                .iter()
1921                .find(|entry| entry.option == "--target")
1922                .unwrap()
1923                .values[0]
1924                .description,
1925            "Run locally"
1926        );
1927    }
1928
1929    #[test]
1930    fn rejects_usage_prose_subcommands_and_malicious_option_spellings() {
1931        let output = "Usage: tool --usage-only\n\
1932                      \x20Prose mentions --prose-only but is not an option.\n\
1933                      \x20Options:\n\
1934                      \x20  --safe        Safe option.\n\
1935                      \x20  --bad;touch   Not safe.\n\
1936                      \x20  --also$(evil) Not safe.\n\
1937                      \x20  --con\u{7}trol Control data.\n\
1938                      \x20  -abc          Combined spelling.\n\
1939                      \x20  —lookalike    Unicode dash.\n\
1940                      \x20Commands:\n\
1941                      \x20  child\n\
1942                      \x20Options:\n\
1943                      \x20  --child-only  Child option.\n";
1944
1945        assert_eq!(
1946            parse_options(output),
1947            [OptionMatch {
1948                spelling: "--safe".to_owned(),
1949                description: "Safe option.".to_owned(),
1950            }]
1951        );
1952        assert!(!option_line_has_unsafe_data("-\u{8}-, h\u{8}h"));
1953    }
1954
1955    #[test]
1956    fn strips_terminal_controls_from_descriptions() {
1957        let output = "tool(1) - \u{1b}[31mred\u{1b}[0m\u{202e} text\n";
1958        assert_eq!(
1959            parse_man_description("tool", output).as_deref(),
1960            Some("red text")
1961        );
1962    }
1963
1964    #[test]
1965    fn description_cache_round_trips() {
1966        let directory = tempdir().unwrap();
1967        let path = directory.path().join("descriptions.json");
1968        let mut cache = DescriptionCache::default();
1969        cache.entries.insert(
1970            "tool".to_owned(),
1971            CachedDescription {
1972                fingerprint: ExecutableFingerprint {
1973                    path: PathBuf::from("/usr/bin/tool"),
1974                    size: 42,
1975                    device: 1,
1976                    inode: 2,
1977                    mode: 0o100755,
1978                    modified_secs: 3,
1979                    modified_nanos: 4,
1980                    changed_secs: 5,
1981                    changed_nanos: 6,
1982                },
1983                checked_at_secs: 7,
1984                description: Some("Inspect a tool".to_owned()),
1985                options: vec![OptionMatch {
1986                    spelling: "--verbose".to_owned(),
1987                    description: "Show more detail".to_owned(),
1988                }],
1989                subcommands: vec![SubcommandMatch {
1990                    name: "inspect".to_owned(),
1991                    description: "Inspect a project".to_owned(),
1992                }],
1993                option_values: vec![OptionValues {
1994                    option: "--color".to_owned(),
1995                    values: vec![ValueMatch {
1996                        value: "always".to_owned(),
1997                        description: String::new(),
1998                    }],
1999                }],
2000            },
2001        );
2002
2003        save_cache(&path, &cache).unwrap();
2004        let loaded = load_cache(&path);
2005        assert_eq!(
2006            loaded.entries["tool"].description.as_deref(),
2007            Some("Inspect a tool")
2008        );
2009        assert_eq!(loaded.entries["tool"].fingerprint.size, 42);
2010        assert_eq!(
2011            loaded.entries["tool"].options,
2012            [OptionMatch {
2013                spelling: "--verbose".to_owned(),
2014                description: "Show more detail".to_owned(),
2015            }]
2016        );
2017        assert_eq!(loaded.entries["tool"].subcommands[0].name, "inspect");
2018        assert_eq!(
2019            loaded.entries["tool"].option_values[0].values[0].value,
2020            "always"
2021        );
2022    }
2023
2024    #[test]
2025    fn authored_descriptions_remain_preferred_during_enrichment() {
2026        let job = test_job("cargo", true);
2027        let mut state = EnrichmentState::default();
2028        state
2029            .descriptions
2030            .insert("cargo".to_owned(), "Parsed description".to_owned());
2031        let catalog = CommandCatalog {
2032            entries: vec![CommandEntry {
2033                name: "cargo".to_owned(),
2034                description: "Rust package manager and build tool".to_owned(),
2035            }],
2036            jobs: HashMap::from([("cargo".to_owned(), job)]),
2037            state: Arc::new(Mutex::new(state)),
2038            queue: None,
2039        };
2040
2041        assert_eq!(
2042            catalog.matching("cargo", 1)[0].description,
2043            "Rust package manager and build tool"
2044        );
2045    }
2046
2047    #[test]
2048    fn matches_cached_options_by_prefix_for_an_exact_command() {
2049        let mut state = EnrichmentState::default();
2050        state.settled.insert("tool".to_owned());
2051        state.options.insert(
2052            "tool".to_owned(),
2053            vec![
2054                OptionMatch {
2055                    spelling: "--all".to_owned(),
2056                    description: "Include all".to_owned(),
2057                },
2058                OptionMatch {
2059                    spelling: "--color".to_owned(),
2060                    description: "Control color".to_owned(),
2061                },
2062                OptionMatch {
2063                    spelling: "-v".to_owned(),
2064                    description: "Verbose".to_owned(),
2065                },
2066            ],
2067        );
2068        let catalog = CommandCatalog {
2069            jobs: HashMap::from([("tool".to_owned(), test_job("tool", false))]),
2070            state: Arc::new(Mutex::new(state)),
2071            ..CommandCatalog::default()
2072        };
2073
2074        let matches = catalog.matching_options("tool", "--", 1);
2075        assert_eq!(matches.entries[0].spelling, "--all");
2076        assert!(!matches.pending);
2077        assert!(
2078            catalog
2079                .matching_options("unknown", "-", 10)
2080                .entries
2081                .is_empty()
2082        );
2083        assert!(!catalog.matching_options("unknown", "-", 10).pending);
2084    }
2085
2086    #[test]
2087    fn unsettled_options_report_pending_without_blocking_on_a_full_queue() {
2088        let (sender, _receiver) = sync_channel(1);
2089        sender.try_send(test_job("queued", false)).unwrap();
2090        let catalog = CommandCatalog {
2091            jobs: HashMap::from([("tool".to_owned(), test_job("tool", false))]),
2092            queue: Some(sender),
2093            ..CommandCatalog::default()
2094        };
2095        let started = Instant::now();
2096
2097        let matches = catalog.matching_options("tool", "--", 10);
2098
2099        assert!(matches.entries.is_empty());
2100        assert!(matches.pending);
2101        assert!(started.elapsed() < Duration::from_millis(100));
2102    }
2103
2104    #[test]
2105    fn description_process_has_a_hard_timeout() {
2106        let directory = tempdir().unwrap();
2107        let mut command = Command::new("/bin/sleep");
2108        command.arg("2");
2109        let started = Instant::now();
2110
2111        assert_eq!(
2112            run_bounded(command, directory.path(), Duration::from_millis(30)).as_deref(),
2113            Some("")
2114        );
2115        assert!(started.elapsed() < Duration::from_secs(1));
2116    }
2117
2118    fn test_job(name: &str, authored_description: bool) -> DescriptionJob {
2119        DescriptionJob {
2120            name: name.to_owned(),
2121            path: PathBuf::from(format!("/usr/bin/{name}")),
2122            fingerprint: ExecutableFingerprint {
2123                path: PathBuf::from(format!("/usr/bin/{name}")),
2124                size: 1,
2125                device: 1,
2126                inode: 1,
2127                mode: 0o100755,
2128                modified_secs: 1,
2129                modified_nanos: 1,
2130                changed_secs: 1,
2131                changed_nanos: 1,
2132            },
2133            authored_description,
2134        }
2135    }
2136
2137    fn values_for<'a>(values: &'a [OptionValues], option: &str) -> Vec<&'a str> {
2138        values
2139            .iter()
2140            .find(|entry| entry.option == option)
2141            .unwrap()
2142            .values
2143            .iter()
2144            .map(|value| value.value.as_str())
2145            .collect()
2146    }
2147}