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, ¤t_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, ¤t_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, ¤t_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}