Skip to main content

aster/
commands.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::env;
3use std::fs::{self, File, OpenOptions};
4use std::io::{Read, Seek, SeekFrom, Write};
5use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
6use std::os::unix::process::CommandExt;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
11use std::sync::{Arc, Mutex};
12use std::thread;
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15use serde::{Deserialize, Serialize};
16
17const CACHE_VERSION: u32 = 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}