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    lookup_with_base_url(cmd, "https://cheat.sh")
35}
36
37/// Testable variant that accepts a custom base URL (e.g. a mockito server).
38///
39/// Separating the base URL enables unit tests without live network access.
40fn lookup_with_base_url(cmd: &str, base_url: &str) -> Result<String, Error> {
41    if cmd.is_empty() {
42        return Err(Error::Help("command name must not be empty".to_owned()));
43    }
44
45    let encoded = encode_cmd(cmd);
46    let url = format!("{base_url}/{encoded}?T");
47
48    let config = ureq::Agent::config_builder()
49        .timeout_global(Some(Duration::from_secs(10)))
50        .build();
51    let agent: ureq::Agent = config.into();
52
53    match agent.get(&url).call() {
54        Ok(mut response) => {
55            // Cap the response to 64 KiB to prevent unbounded memory growth.
56            const MAX_BYTES: u64 = 65_536;
57            let mut buf = String::new();
58            response
59                .body_mut()
60                .as_reader()
61                .take(MAX_BYTES)
62                .read_to_string(&mut buf)
63                .map_err(|e| Error::Help(format!("read response: {e}")))?;
64            Ok(buf)
65        }
66        Err(ureq::Error::StatusCode(404)) => {
67            Err(Error::Help(format!("no help available for '{cmd}'")))
68        }
69        Err(e) => Err(Error::Help(format!("network error: {e}"))),
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn test_encode_cmd_spaces() {
79        assert_eq!(encode_cmd("git commit"), "git%20commit");
80    }
81
82    #[test]
83    fn test_encode_cmd_simple() {
84        assert_eq!(encode_cmd("find"), "find");
85    }
86
87    #[test]
88    fn test_encode_cmd_special_chars() {
89        // & and % must be encoded to prevent URL injection.
90        assert_eq!(encode_cmd("foo&bar"), "foo%26bar");
91        assert_eq!(encode_cmd("foo%bar"), "foo%25bar");
92    }
93
94    #[test]
95    fn test_encode_cmd_hash_and_query() {
96        assert_eq!(encode_cmd("foo#bar"), "foo%23bar");
97        assert_eq!(encode_cmd("foo?bar"), "foo%3Fbar");
98    }
99
100    #[test]
101    fn test_encode_cmd_slash() {
102        assert_eq!(encode_cmd("a/b"), "a%2Fb");
103    }
104
105    #[test]
106    fn test_encode_cmd_unreserved_passthrough() {
107        // Unreserved chars (RFC 3986) must not be encoded.
108        assert_eq!(encode_cmd("abc-XYZ_0.9~"), "abc-XYZ_0.9~");
109    }
110
111    #[test]
112    fn test_encode_cmd_null_byte() {
113        assert_eq!(encode_cmd("foo\0bar"), "foo%00bar");
114    }
115
116    #[test]
117    fn test_lookup_empty_returns_error() {
118        let result = lookup("");
119        assert!(result.is_err());
120        let msg = result.unwrap_err().to_string();
121        assert!(msg.contains("empty"), "expected 'empty' in: {msg}");
122    }
123
124    #[test]
125    fn test_help_lookup_success() {
126        // Mock cheat.sh returning 200 with help text.
127        let mut server = mockito::Server::new();
128        let mock = server
129            .mock("GET", "/ls?T")
130            .with_status(200)
131            .with_header("content-type", "text/plain")
132            .with_body("ls - list directory contents\n  -l  long listing format\n")
133            .create();
134
135        let result = lookup_with_base_url("ls", &server.url());
136        assert!(result.is_ok(), "expected Ok, got: {result:?}");
137        let text = result.unwrap();
138        assert!(
139            text.contains("list directory"),
140            "response body must be returned: {text}"
141        );
142        mock.assert();
143    }
144
145    #[test]
146    fn test_help_lookup_not_found() {
147        // Mock cheat.sh returning 404 — must produce a Help error.
148        // The lookup function appends "?T" to the path, so we mock that exact path.
149        let mut server = mockito::Server::new();
150        let mock = server.mock("GET", "/nosuchcmd?T").with_status(404).create();
151
152        let result = lookup_with_base_url("nosuchcmd", &server.url());
153        assert!(result.is_err(), "expected Err on 404");
154        let msg = result.unwrap_err().to_string();
155        // 404 maps to Error::Help("no help available for '...'") in lookup_with_base_url.
156        assert!(
157            msg.contains("no help"),
158            "expected 'no help' error message, got: {msg}"
159        );
160        mock.assert();
161    }
162
163    #[test]
164    fn test_help_lookup_network_error() {
165        // Pointing at an unreachable address must produce a network-error variant.
166        // Port 1 is conventionally unroutable, so the connection is refused quickly.
167        let result = lookup_with_base_url("ls", "http://127.0.0.1:1");
168        assert!(result.is_err(), "expected Err on unreachable host");
169        let msg = result.unwrap_err().to_string();
170        assert!(
171            msg.contains("network") || msg.contains("error") || msg.contains("connect"),
172            "expected network error message, got: {msg}"
173        );
174    }
175
176    #[test]
177    fn test_help_encode_cmd_special_chars_in_lookup() {
178        // Verify encode_cmd is applied: "git commit" → "git%20commit" in URL path.
179        let mut server = mockito::Server::new();
180        // The path must use the percent-encoded form.
181        let mock = server
182            .mock("GET", "/git%20commit?T")
183            .with_status(200)
184            .with_header("content-type", "text/plain")
185            .with_body("git commit - record changes to the repository\n")
186            .create();
187
188        let result = lookup_with_base_url("git commit", &server.url());
189        assert!(result.is_ok(), "expected Ok: {result:?}");
190        mock.assert();
191    }
192
193    #[test]
194    fn test_help_response_cap() {
195        // A response larger than 64 KiB must be truncated to exactly 64 KiB.
196        let mut server = mockito::Server::new();
197        // 128 KiB of 'x' characters — well above the 64 KiB cap.
198        let large_body = "x".repeat(131_072);
199        let mock = server
200            .mock("GET", "/bigcmd?T")
201            .with_status(200)
202            .with_header("content-type", "text/plain")
203            .with_body(large_body.as_str())
204            .create();
205
206        let result = lookup_with_base_url("bigcmd", &server.url());
207        assert!(result.is_ok(), "expected Ok: {result:?}");
208        let text = result.unwrap();
209        assert!(
210            text.len() <= 65_536,
211            "response must be capped at 64 KiB, got {} bytes",
212            text.len()
213        );
214        // Must have received some content (not empty)
215        assert!(!text.is_empty(), "response must not be empty");
216        mock.assert();
217    }
218
219    #[test]
220    #[ignore = "requires network"]
221    fn test_lookup_known_command() {
222        let result = lookup("echo");
223        assert!(result.is_ok(), "lookup failed: {:?}", result.err());
224        let content = result.unwrap();
225        assert!(!content.is_empty());
226    }
227
228    #[test]
229    #[ignore = "requires network"]
230    fn test_lookup_nonexistent_command() {
231        // cheat.sh returns 200 with a "not found" page for most unknown commands
232        // rather than a 404, so we assert Ok with non-empty content OR a Help Err —
233        // either is acceptable; what is forbidden is a panic.
234        let result = lookup("__nonexistent_oo_test_xyz__");
235        match result {
236            Ok(content) => assert!(!content.is_empty(), "expected non-empty response"),
237            Err(e) => assert!(
238                e.to_string().contains("no help") || e.to_string().contains("network"),
239                "unexpected error variant: {e}"
240            ),
241        }
242    }
243}