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