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 = 1;
18const CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024;
19const OUTPUT_MAX_BYTES: u64 = 64 * 1024;
20const QUEUE_CAPACITY: usize = 64;
21const WORKER_COUNT: usize = 2;
22const SUCCESS_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
23const MISS_TTL: Duration = Duration::from_secs(24 * 60 * 60);
24const MAN_TIMEOUT: Duration = Duration::from_millis(1_500);
25const HELP_TIMEOUT: Duration = Duration::from_millis(1_000);
26
27static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CommandEntry {
31 pub name: String,
32 pub description: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct CommandMatch {
37 pub name: String,
38 pub description: String,
39 pub description_pending: bool,
40}
41
42#[derive(Debug)]
43pub struct CommandCatalog {
44 entries: Vec<CommandEntry>,
45 jobs: HashMap<String, DescriptionJob>,
46 state: Arc<Mutex<EnrichmentState>>,
47 queue: Option<SyncSender<DescriptionJob>>,
48}
49
50#[derive(Debug, Default)]
51struct EnrichmentState {
52 descriptions: HashMap<String, String>,
53 settled: HashSet<String>,
54 pending: HashSet<String>,
55}
56
57#[derive(Debug, Clone)]
58struct DescriptionJob {
59 name: String,
60 path: PathBuf,
61 fingerprint: ExecutableFingerprint,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65struct ExecutableFingerprint {
66 path: PathBuf,
67 size: u64,
68 device: u64,
69 inode: u64,
70 mode: u32,
71 modified_secs: i64,
72 modified_nanos: i64,
73 changed_secs: i64,
74 changed_nanos: i64,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78struct CachedDescription {
79 fingerprint: ExecutableFingerprint,
80 checked_at_secs: u64,
81 description: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85struct DescriptionCache {
86 version: u32,
87 entries: BTreeMap<String, CachedDescription>,
88}
89
90impl Default for DescriptionCache {
91 fn default() -> Self {
92 Self {
93 version: CACHE_VERSION,
94 entries: BTreeMap::new(),
95 }
96 }
97}
98
99impl Default for CommandCatalog {
100 fn default() -> Self {
101 Self {
102 entries: Vec::new(),
103 jobs: HashMap::new(),
104 state: Arc::new(Mutex::new(EnrichmentState::default())),
105 queue: None,
106 }
107 }
108}
109
110impl CommandCatalog {
111 pub fn discover(cache_file: PathBuf) -> Self {
112 let cache = load_cache(&cache_file);
113 let now = now_secs();
114 let mut seen = HashSet::new();
115 let mut entries = Vec::new();
116 let mut jobs = HashMap::new();
117 let mut state = EnrichmentState::default();
118
119 for (name, description) in SHELL_BUILTINS {
120 seen.insert((*name).to_owned());
121 entries.push(CommandEntry {
122 name: (*name).to_owned(),
123 description: (*description).to_owned(),
124 });
125 }
126
127 if let Some(path) = env::var_os("PATH") {
128 for directory in env::split_paths(&path) {
129 let Ok(children) = fs::read_dir(&directory) else {
130 continue;
131 };
132 for child in children.flatten() {
133 let Some(name) = child.file_name().to_str().map(str::to_owned) else {
134 continue;
135 };
136 if !valid_name(&name) || seen.contains(&name) {
137 continue;
138 }
139 let path = child.path();
140 let Ok(metadata) = fs::metadata(&path) else {
141 continue;
142 };
143 if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
144 continue;
145 }
146 seen.insert(name.clone());
147 let authored = known_description(&name);
148 entries.push(CommandEntry {
149 description: authored
150 .map(str::to_owned)
151 .unwrap_or_else(|| fallback_description(&path)),
152 name: name.clone(),
153 });
154
155 if authored.is_none() && !name.starts_with('-') {
156 let fingerprint = fingerprint(&path, &metadata);
157 let job = DescriptionJob {
158 name: name.clone(),
159 path,
160 fingerprint,
161 };
162 if let Some(cached) = cache.entries.get(&name)
163 && cached.fingerprint == job.fingerprint
164 && cache_is_fresh(cached, now)
165 {
166 state.settled.insert(name.clone());
167 if let Some(description) = &cached.description {
168 state.descriptions.insert(name.clone(), description.clone());
169 }
170 }
171 jobs.insert(name, job);
172 }
173 }
174 }
175 }
176
177 entries.sort_unstable_by(|left, right| left.name.cmp(&right.name));
178 let state = Arc::new(Mutex::new(state));
179 let queue = start_workers(Arc::clone(&state), cache_file, cache);
180 Self {
181 entries,
182 jobs,
183 state,
184 queue,
185 }
186 }
187
188 pub fn matching(&self, prefix: &str, limit: usize) -> Vec<CommandMatch> {
189 let mut state = self.state.lock().expect("command state lock poisoned");
190 self.entries
191 .iter()
192 .filter(|entry| entry.name.starts_with(prefix))
193 .take(limit)
194 .map(|entry| {
195 let description = state
196 .descriptions
197 .get(&entry.name)
198 .cloned()
199 .unwrap_or_else(|| entry.description.clone());
200 let mut description_pending = false;
201
202 if let (Some(job), Some(queue)) = (self.jobs.get(&entry.name), self.queue.as_ref())
203 && !state.settled.contains(&entry.name)
204 {
205 description_pending = true;
206 if state.pending.insert(entry.name.clone()) {
207 match queue.try_send(job.clone()) {
208 Ok(()) => {}
209 Err(TrySendError::Full(_)) => {
210 state.pending.remove(&entry.name);
211 }
212 Err(TrySendError::Disconnected(_)) => {
213 state.pending.remove(&entry.name);
214 state.settled.insert(entry.name.clone());
215 description_pending = false;
216 }
217 }
218 }
219 }
220
221 CommandMatch {
222 name: entry.name.clone(),
223 description,
224 description_pending,
225 }
226 })
227 .collect()
228 }
229
230 pub fn inventory(&self) -> Vec<CommandMatch> {
231 let state = self.state.lock().expect("command state lock poisoned");
232 self.entries
233 .iter()
234 .map(|entry| CommandMatch {
235 name: entry.name.clone(),
236 description: state
237 .descriptions
238 .get(&entry.name)
239 .cloned()
240 .unwrap_or_else(|| entry.description.clone()),
241 description_pending: false,
242 })
243 .collect()
244 }
245
246 #[cfg(test)]
247 pub fn from_entries(entries: impl IntoIterator<Item = CommandEntry>) -> Self {
248 let mut entries: Vec<_> = entries.into_iter().collect();
249 entries.sort_unstable_by(|left, right| left.name.cmp(&right.name));
250 Self {
251 entries,
252 ..Self::default()
253 }
254 }
255}
256
257fn start_workers(
258 state: Arc<Mutex<EnrichmentState>>,
259 cache_file: PathBuf,
260 cache: DescriptionCache,
261) -> Option<SyncSender<DescriptionJob>> {
262 let (sender, receiver) = sync_channel(QUEUE_CAPACITY);
263 let receiver = Arc::new(Mutex::new(receiver));
264 let cache = Arc::new(Mutex::new(cache));
265 let mut started = 0;
266
267 for index in 0..WORKER_COUNT {
268 let state = Arc::clone(&state);
269 let receiver = Arc::clone(&receiver);
270 let cache = Arc::clone(&cache);
271 let cache_file = cache_file.clone();
272 let worker = thread::Builder::new()
273 .name(format!("aster-description-{index}"))
274 .spawn(move || description_worker(&receiver, &state, &cache_file, &cache));
275 if worker.is_ok() {
276 started += 1;
277 }
278 }
279
280 (started > 0).then_some(sender)
281}
282
283fn description_worker(
284 receiver: &Mutex<Receiver<DescriptionJob>>,
285 state: &Mutex<EnrichmentState>,
286 cache_file: &Path,
287 cache: &Mutex<DescriptionCache>,
288) {
289 loop {
290 let job = {
291 let receiver = receiver.lock().expect("description queue lock poisoned");
292 receiver.recv()
293 };
294 let Ok(job) = job else {
295 return;
296 };
297
298 let unchanged_before = fingerprint_matches(&job);
299 let description = unchanged_before
300 .then(|| discover_description(&job, cache_file.parent().unwrap_or(Path::new("/tmp"))))
301 .flatten();
302 let cacheable = unchanged_before && fingerprint_matches(&job);
303 {
304 let mut state = state.lock().expect("command state lock poisoned");
305 state.pending.remove(&job.name);
306 state.settled.insert(job.name.clone());
307 if cacheable && let Some(description) = &description {
308 state
309 .descriptions
310 .insert(job.name.clone(), description.clone());
311 }
312 }
313
314 if cacheable {
315 let mut cache = cache.lock().expect("description cache lock poisoned");
316 cache.entries.insert(
317 job.name,
318 CachedDescription {
319 fingerprint: job.fingerprint,
320 checked_at_secs: now_secs(),
321 description,
322 },
323 );
324 let _ = save_cache(cache_file, &cache);
325 }
326 }
327}
328
329fn discover_description(job: &DescriptionJob, output_dir: &Path) -> Option<String> {
330 man_description(&job.name, output_dir).or_else(|| help_description(job, output_dir))
331}
332
333fn man_description(name: &str, output_dir: &Path) -> Option<String> {
334 let man = Path::new("/usr/bin/man");
335 if !man.is_file() {
336 return None;
337 }
338 let mut command = Command::new(man);
339 command
340 .arg(name)
341 .env_clear()
342 .env("HOME", "/nonexistent")
343 .env("LC_ALL", "C")
344 .env("MANPAGER", "cat")
345 .env("PAGER", "cat");
346 let output = run_bounded(command, output_dir, MAN_TIMEOUT)?;
347 parse_man_description(name, &output)
348}
349
350#[cfg(target_os = "macos")]
351fn help_description(job: &DescriptionJob, output_dir: &Path) -> Option<String> {
352 let sandbox = Path::new("/usr/bin/sandbox-exec");
353 if !sandbox.is_file() {
354 return None;
355 }
356 let mut command = Command::new(sandbox);
357 command
358 .arg("-p")
359 .arg(
360 "(version 1) (deny default) (allow process-exec) (allow file-read*) \
361 (allow sysctl-read) (allow mach-lookup)",
362 )
363 .arg(&job.path)
364 .arg("--help")
365 .env_clear()
366 .env("HOME", "/nonexistent")
367 .env("LC_ALL", "C")
368 .env("NO_COLOR", "1")
369 .env("PAGER", "cat")
370 .env("MANPAGER", "cat")
371 .env("TERM", "dumb");
372 let output = run_bounded(command, output_dir, HELP_TIMEOUT)?;
373 parse_help_description(&job.name, &output)
374}
375
376#[cfg(not(target_os = "macos"))]
377fn help_description(_job: &DescriptionJob, _output_dir: &Path) -> Option<String> {
378 None
379}
380
381fn run_bounded(mut command: Command, output_dir: &Path, timeout: Duration) -> Option<String> {
382 let (path, mut output) = temporary_output(output_dir)?;
383 let stdout = output.try_clone().ok()?;
384 let stderr = output.try_clone().ok()?;
385 command
386 .current_dir("/")
387 .stdin(Stdio::null())
388 .stdout(Stdio::from(stdout))
389 .stderr(Stdio::from(stderr))
390 .process_group(0);
391 unsafe {
392 command.pre_exec(|| {
393 let limit = libc::rlimit {
394 rlim_cur: OUTPUT_MAX_BYTES,
395 rlim_max: OUTPUT_MAX_BYTES,
396 };
397 if libc::setrlimit(libc::RLIMIT_FSIZE, &limit) == -1 {
398 return Err(std::io::Error::last_os_error());
399 }
400 Ok(())
401 });
402 }
403
404 let mut child = match command.spawn() {
405 Ok(child) => child,
406 Err(_) => {
407 let _ = fs::remove_file(path);
408 return None;
409 }
410 };
411 let _ = fs::remove_file(&path);
412 let deadline = Instant::now() + timeout;
413 loop {
414 match child.try_wait() {
415 Ok(Some(_)) => break,
416 Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
417 Ok(None) | Err(_) => {
418 unsafe {
419 libc::kill(-(child.id() as i32), libc::SIGKILL);
420 }
421 let _ = child.kill();
422 let _ = child.wait();
423 break;
424 }
425 }
426 }
427 unsafe {
428 libc::kill(-(child.id() as i32), libc::SIGKILL);
429 }
430 let _ = child.wait();
431
432 output.seek(SeekFrom::Start(0)).ok()?;
433 let mut bytes = Vec::new();
434 output.take(OUTPUT_MAX_BYTES).read_to_end(&mut bytes).ok()?;
435 String::from_utf8(bytes).ok()
436}
437
438fn temporary_output(directory: &Path) -> Option<(PathBuf, File)> {
439 for _ in 0..10 {
440 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
441 let path = directory.join(format!(
442 ".description-output-{}-{sequence}",
443 std::process::id()
444 ));
445 let file = OpenOptions::new()
446 .create_new(true)
447 .read(true)
448 .write(true)
449 .mode(0o600)
450 .open(&path);
451 match file {
452 Ok(file) => return Some((path, file)),
453 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
454 Err(_) => return None,
455 }
456 }
457 None
458}
459
460fn parse_man_description(name: &str, output: &str) -> Option<String> {
461 let mut name_section = false;
462 for line in clean_lines(output) {
463 if line == "NAME" {
464 name_section = true;
465 continue;
466 }
467 if name_section {
468 name_section = false;
469 if let Some(description) = line.strip_prefix(name)
470 && description.chars().next().is_some_and(char::is_whitespace)
471 && let Some(description) =
472 sanitize_description(description.trim_start().trim_start_matches('-'))
473 {
474 return Some(description);
475 }
476 }
477 let Some((names, description)) = line.split_once(" - ") else {
478 continue;
479 };
480 let exact = names.split(',').any(|candidate| {
481 candidate
482 .trim()
483 .strip_prefix(name)
484 .is_some_and(|rest| rest.is_empty() || rest.starts_with('('))
485 });
486 if exact && let Some(description) = sanitize_description(description) {
487 return Some(description);
488 }
489 }
490 None
491}
492
493fn parse_help_description(name: &str, output: &str) -> Option<String> {
494 for line in clean_lines(output) {
495 let lower = line.to_ascii_lowercase();
496 let structural = [
497 "usage",
498 "options",
499 "commands",
500 "arguments",
501 "available commands",
502 "flags",
503 "examples",
504 ]
505 .iter()
506 .any(|heading| lower == *heading || lower.starts_with(&format!("{heading}:")));
507 let command_usage =
508 lower.starts_with(&format!("{name} [")) || lower.starts_with(&format!("{name} <"));
509 let diagnostic = [
510 "error:",
511 "warning:",
512 "failed",
513 "couldn't",
514 "unrecognized option",
515 "unknown option",
516 ]
517 .iter()
518 .any(|text| lower.contains(text));
519 if structural || command_usage || diagnostic || line.starts_with(['-', '[']) {
520 continue;
521 }
522 if let Some(description) = sanitize_description(&line)
523 && description.split_whitespace().count() >= 2
524 {
525 return Some(description);
526 }
527 }
528 None
529}
530
531fn clean_lines(output: &str) -> impl Iterator<Item = String> + '_ {
532 output.lines().filter_map(|line| {
533 let mut clean = String::with_capacity(line.len());
534 let mut escape = 0;
535 for character in line.chars() {
536 if escape == 1 {
537 escape = match character {
538 '[' => 2,
539 ']' => 3,
540 _ => 0,
541 };
542 continue;
543 }
544 if escape == 2 {
545 if ('@'..='~').contains(&character) {
546 escape = 0;
547 }
548 continue;
549 }
550 if escape == 3 {
551 continue;
552 }
553 if character == '\u{1b}' {
554 escape = 1;
555 } else if character == '\u{8}' {
556 clean.pop();
557 } else if character == '\t' {
558 clean.push(' ');
559 } else if !character.is_control() && !is_directional_format(character) {
560 clean.push(character);
561 }
562 }
563 let clean = clean.trim().to_owned();
564 (!clean.is_empty()).then_some(clean)
565 })
566}
567
568fn sanitize_description(description: &str) -> Option<String> {
569 let mut clean = description.split_whitespace().collect::<Vec<_>>().join(" ");
570 clean.retain(|character| !character.is_control() && !is_directional_format(character));
571 if clean.is_empty() || !clean.chars().any(char::is_alphabetic) {
572 return None;
573 }
574 if clean.chars().count() > 200 {
575 clean = clean.chars().take(199).collect();
576 clean.push('…');
577 }
578 Some(clean)
579}
580
581fn is_directional_format(character: char) -> bool {
582 matches!(character, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
583}
584
585fn load_cache(path: &Path) -> DescriptionCache {
586 let Ok(metadata) = fs::metadata(path) else {
587 return DescriptionCache::default();
588 };
589 if metadata.len() > CACHE_MAX_BYTES {
590 return DescriptionCache::default();
591 }
592 let Ok(bytes) = fs::read(path) else {
593 return DescriptionCache::default();
594 };
595 let Ok(cache) = serde_json::from_slice::<DescriptionCache>(&bytes) else {
596 return DescriptionCache::default();
597 };
598 if cache.version != CACHE_VERSION {
599 return DescriptionCache::default();
600 }
601 cache
602}
603
604fn save_cache(path: &Path, cache: &DescriptionCache) -> std::io::Result<()> {
605 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
606 let temporary = path.with_extension(format!("tmp-{}-{sequence}", std::process::id()));
607 let result = (|| {
608 let mut file = OpenOptions::new()
609 .create_new(true)
610 .write(true)
611 .mode(0o600)
612 .open(&temporary)?;
613 serde_json::to_writer(&mut file, cache)?;
614 file.write_all(b"\n")?;
615 file.sync_all()?;
616 fs::rename(&temporary, path)
617 })();
618 if result.is_err() {
619 let _ = fs::remove_file(temporary);
620 }
621 result
622}
623
624fn cache_is_fresh(cached: &CachedDescription, now: u64) -> bool {
625 let ttl = if cached.description.is_some() {
626 SUCCESS_TTL
627 } else {
628 MISS_TTL
629 };
630 now >= cached.checked_at_secs && now - cached.checked_at_secs <= ttl.as_secs()
631}
632
633fn fingerprint(path: &Path, metadata: &fs::Metadata) -> ExecutableFingerprint {
634 ExecutableFingerprint {
635 path: path.to_path_buf(),
636 size: metadata.len(),
637 device: metadata.dev(),
638 inode: metadata.ino(),
639 mode: metadata.mode(),
640 modified_secs: metadata.mtime(),
641 modified_nanos: metadata.mtime_nsec(),
642 changed_secs: metadata.ctime(),
643 changed_nanos: metadata.ctime_nsec(),
644 }
645}
646
647fn fingerprint_matches(job: &DescriptionJob) -> bool {
648 fs::metadata(&job.path)
649 .map(|metadata| fingerprint(&job.path, &metadata) == job.fingerprint)
650 .unwrap_or(false)
651}
652
653fn now_secs() -> u64 {
654 SystemTime::now()
655 .duration_since(UNIX_EPOCH)
656 .unwrap_or_default()
657 .as_secs()
658}
659
660fn valid_name(name: &str) -> bool {
661 !name.is_empty()
662 && !name.chars().any(char::is_control)
663 && name
664 .bytes()
665 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+'))
666}
667
668fn fallback_description(path: &Path) -> String {
669 let path = path.to_string_lossy();
670 if path.contains("/.cargo/bin/") {
671 "Executable installed by Cargo".to_owned()
672 } else if path.contains("/homebrew/") || path.contains("/Cellar/") {
673 "Homebrew command".to_owned()
674 } else if path.contains("/.local/bin/") || path.contains("/bin/") && path.contains("/Users/") {
675 "User-installed command".to_owned()
676 } else {
677 "System command".to_owned()
678 }
679}
680
681fn known_description(name: &str) -> Option<&'static str> {
682 Some(match name {
683 "ansible" => "Define and run automation tasks",
684 "appwrite" => "Manage Appwrite projects and services",
685 "arch" => "Print architecture type or run a universal binary",
686 "asr" => "Apple Software Restore; copy volumes and disk images",
687 "atlas" => "CLI tool to manage MongoDB Atlas",
688 "aws" => "Official command line interface for Amazon Web Services",
689 "aws-vault" => "Securely store and access AWS credentials",
690 "bash" => "GNU Bourne Again shell",
691 "brew" => "The missing package manager for macOS",
692 "cargo" => "Rust package manager and build tool",
693 "cmake" => "Configure, build, and test software projects",
694 "code" => "Open Visual Studio Code",
695 "curl" => "Transfer data from or to a server",
696 "docker" => "Build and run applications in containers",
697 "fd" => "Fast and user-friendly file finder",
698 "fzf" => "Command-line fuzzy finder",
699 "gh" => "GitHub command line interface",
700 "git" => "Distributed version control system",
701 "go" => "Build and manage Go source code",
702 "iris" => "Interactive shell assistant",
703 "jq" => "Process and transform JSON",
704 "kubectl" => "Control Kubernetes clusters",
705 "make" => "Maintain and build groups of programs",
706 "node" => "Run JavaScript with Node.js",
707 "npm" => "JavaScript package manager",
708 "nvim" => "Edit text with Neovim",
709 "pnpm" => "Fast, disk-efficient JavaScript package manager",
710 "python" | "python3" => "Run the Python interpreter",
711 "rg" => "Recursively search files with ripgrep",
712 "rustc" => "Compile Rust source code",
713 "ssh" => "OpenSSH remote login client",
714 "tmux" => "Terminal multiplexer",
715 "yarn" => "JavaScript package manager",
716 "zsh" => "Z shell command interpreter",
717 _ => return None,
718 })
719}
720
721const SHELL_BUILTINS: &[(&str, &str)] = &[
722 ("alias", "Define or display shell aliases"),
723 ("autoload", "Mark shell functions for automatic loading"),
724 ("bg", "Resume jobs in the background"),
725 ("cd", "Change the current working directory"),
726 ("command", "Execute a command without shell function lookup"),
727 ("export", "Set environment variables for child processes"),
728 ("fg", "Bring jobs into the foreground"),
729 ("jobs", "Display active shell jobs"),
730 ("setopt", "Enable Zsh options"),
731 (
732 "source",
733 "Execute commands from a file in the current shell",
734 ),
735 ("typeset", "Declare shell variables and attributes"),
736 ("unalias", "Remove shell alias definitions"),
737 ("unset", "Remove shell variables or functions"),
738 ("unsetopt", "Disable Zsh options"),
739];
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744 use tempfile::tempdir;
745
746 #[test]
747 fn matches_sorted_prefixes() {
748 let catalog = CommandCatalog::from_entries([
749 CommandEntry {
750 name: "atlas".to_owned(),
751 description: "MongoDB Atlas".to_owned(),
752 },
753 CommandEntry {
754 name: "arch".to_owned(),
755 description: "Architecture".to_owned(),
756 },
757 ]);
758
759 let names: Vec<_> = catalog
760 .matching("a", 10)
761 .into_iter()
762 .map(|entry| entry.name)
763 .collect();
764 assert_eq!(names, ["arch", "atlas"]);
765 }
766
767 #[test]
768 fn rejects_shell_metacharacters_in_names() {
769 assert!(valid_name("aws-vault"));
770 assert!(!valid_name("bad command"));
771 assert!(!valid_name("bad;command"));
772 }
773
774 #[test]
775 fn parses_exact_man_description() {
776 let output = "assetutil(1) - process asset catalog.car files\n\
777 other(1) - unrelated\n";
778 assert_eq!(
779 parse_man_description("assetutil", output).as_deref(),
780 Some("process asset catalog.car files")
781 );
782 assert_eq!(parse_man_description("asset", output), None);
783 }
784
785 #[test]
786 fn parses_overstruck_man_name_section() {
787 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";
788 assert_eq!(
789 parse_man_description("as", output).as_deref(),
790 Some("assembler")
791 );
792 }
793
794 #[test]
795 fn parses_man_name_section_without_separator() {
796 let output = "NAME\n assetutil process asset catalog files\n\nSYNOPSIS\n";
797 assert_eq!(
798 parse_man_description("assetutil", output).as_deref(),
799 Some("process asset catalog files")
800 );
801 }
802
803 #[test]
804 fn parses_prose_from_help_output() {
805 let output =
806 "Usage: tool [OPTIONS]\n\nInspect a project without changing it.\n\nOptions:\n";
807 assert_eq!(
808 parse_help_description("tool", output).as_deref(),
809 Some("Inspect a project without changing it.")
810 );
811 }
812
813 #[test]
814 fn rejects_help_diagnostics() {
815 let output = "tool: error: couldn't create cache file\nUsage: tool [OPTIONS]\n";
816 assert_eq!(parse_help_description("tool", output), None);
817 }
818
819 #[test]
820 fn strips_terminal_controls_from_descriptions() {
821 let output = "tool(1) - \u{1b}[31mred\u{1b}[0m\u{202e} text\n";
822 assert_eq!(
823 parse_man_description("tool", output).as_deref(),
824 Some("red text")
825 );
826 }
827
828 #[test]
829 fn description_cache_round_trips() {
830 let directory = tempdir().unwrap();
831 let path = directory.path().join("descriptions.json");
832 let mut cache = DescriptionCache::default();
833 cache.entries.insert(
834 "tool".to_owned(),
835 CachedDescription {
836 fingerprint: ExecutableFingerprint {
837 path: PathBuf::from("/usr/bin/tool"),
838 size: 42,
839 device: 1,
840 inode: 2,
841 mode: 0o100755,
842 modified_secs: 3,
843 modified_nanos: 4,
844 changed_secs: 5,
845 changed_nanos: 6,
846 },
847 checked_at_secs: 7,
848 description: Some("Inspect a tool".to_owned()),
849 },
850 );
851
852 save_cache(&path, &cache).unwrap();
853 let loaded = load_cache(&path);
854 assert_eq!(
855 loaded.entries["tool"].description.as_deref(),
856 Some("Inspect a tool")
857 );
858 assert_eq!(loaded.entries["tool"].fingerprint.size, 42);
859 }
860
861 #[test]
862 fn description_process_has_a_hard_timeout() {
863 let directory = tempdir().unwrap();
864 let mut command = Command::new("/bin/sleep");
865 command.arg("2");
866 let started = Instant::now();
867
868 assert_eq!(
869 run_bounded(command, directory.path(), Duration::from_millis(30)).as_deref(),
870 Some("")
871 );
872 assert!(started.elapsed() < Duration::from_secs(1));
873 }
874}