Skip to main content

double_o/
help.rs

1use crate::error::Error;
2use std::io::Read;
3use std::time::Duration;
4
5/// Percent-encode a command string for safe inclusion in a URL path.
6///
7/// Only unreserved characters (alphanumeric, `-`, `_`, `.`, `~`) are kept as-is.
8/// Everything else is encoded as `%XX` using uppercase hex. This prevents
9/// URL injection via crafted command strings containing `&`, `%`, `?`, `#`, etc.
10pub fn encode_cmd(cmd: &str) -> String {
11    let mut out = String::with_capacity(cmd.len());
12    for byte in cmd.bytes() {
13        match byte {
14            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
15                out.push(byte as char);
16            }
17            b => {
18                // Use a nibble lookup table to avoid unwrap() per AGENTS.md policy.
19                const HEX: &[u8; 16] = b"0123456789ABCDEF";
20                out.push('%');
21                out.push(HEX[(b >> 4) as usize] as char);
22                out.push(HEX[(b & 0xf) as usize] as char);
23            }
24        }
25    }
26    out
27}
28
29/// Fetch a compressed cheat sheet for `cmd` from cheat.sh.
30///
31/// `?T` strips ANSI colour codes so the output is plain text.
32/// Response is capped at 64 KiB to prevent memory exhaustion.
33pub fn lookup(cmd: &str) -> Result<String, Error> {
34    if cmd.is_empty() {
35        return Err(Error::Help("command name must not be empty".to_owned()));
36    }
37
38    let encoded = encode_cmd(cmd);
39    let url = format!("https://cheat.sh/{encoded}?T");
40
41    let config = ureq::Agent::config_builder()
42        .timeout_global(Some(Duration::from_secs(10)))
43        .build();
44    let agent: ureq::Agent = config.into();
45
46    match agent.get(&url).call() {
47        Ok(mut response) => {
48            // Cap the response to 64 KiB to prevent unbounded memory growth.
49            const MAX_BYTES: u64 = 65_536;
50            let mut buf = String::new();
51            response
52                .body_mut()
53                .as_reader()
54                .take(MAX_BYTES)
55                .read_to_string(&mut buf)
56                .map_err(|e| Error::Help(format!("read response: {e}")))?;
57            Ok(buf)
58        }
59        Err(ureq::Error::StatusCode(404)) => {
60            Err(Error::Help(format!("no help available for '{cmd}'")))
61        }
62        Err(e) => Err(Error::Help(format!("network error: {e}"))),
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn test_encode_cmd_spaces() {
72        assert_eq!(encode_cmd("git commit"), "git%20commit");
73    }
74
75    #[test]
76    fn test_encode_cmd_simple() {
77        assert_eq!(encode_cmd("find"), "find");
78    }
79
80    #[test]
81    fn test_encode_cmd_special_chars() {
82        // & and % must be encoded to prevent URL injection.
83        assert_eq!(encode_cmd("foo&bar"), "foo%26bar");
84        assert_eq!(encode_cmd("foo%bar"), "foo%25bar");
85    }
86
87    #[test]
88    fn test_encode_cmd_hash_and_query() {
89        assert_eq!(encode_cmd("foo#bar"), "foo%23bar");
90        assert_eq!(encode_cmd("foo?bar"), "foo%3Fbar");
91    }
92
93    #[test]
94    fn test_encode_cmd_slash() {
95        assert_eq!(encode_cmd("a/b"), "a%2Fb");
96    }
97
98    #[test]
99    fn test_encode_cmd_unreserved_passthrough() {
100        // Unreserved chars (RFC 3986) must not be encoded.
101        assert_eq!(encode_cmd("abc-XYZ_0.9~"), "abc-XYZ_0.9~");
102    }
103
104    #[test]
105    fn test_encode_cmd_null_byte() {
106        assert_eq!(encode_cmd("foo\0bar"), "foo%00bar");
107    }
108
109    #[test]
110    fn test_lookup_empty_returns_error() {
111        let result = lookup("");
112        assert!(result.is_err());
113        let msg = result.unwrap_err().to_string();
114        assert!(msg.contains("empty"), "expected 'empty' in: {msg}");
115    }
116
117    #[test]
118    #[ignore = "requires network"]
119    fn test_lookup_known_command() {
120        let result = lookup("echo");
121        assert!(result.is_ok(), "lookup failed: {:?}", result.err());
122        let content = result.unwrap();
123        assert!(!content.is_empty());
124    }
125
126    #[test]
127    #[ignore = "requires network"]
128    fn test_lookup_nonexistent_command() {
129        // cheat.sh returns 200 with a "not found" page for most unknown commands
130        // rather than a 404, so we assert Ok with non-empty content OR a Help Err —
131        // either is acceptable; what is forbidden is a panic.
132        let result = lookup("__nonexistent_oo_test_xyz__");
133        match result {
134            Ok(content) => assert!(!content.is_empty(), "expected non-empty response"),
135            Err(e) => assert!(
136                e.to_string().contains("no help") || e.to_string().contains("network"),
137                "unexpected error variant: {e}"
138            ),
139        }
140    }
141}