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