1use crate::error::Error;
2use std::io::Read;
3use std::time::Duration;
4
5pub 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 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
29pub fn lookup(cmd: &str) -> Result<String, Error> {
34 lookup_with_base_url(cmd, "https://cheat.sh")
35}
36
37fn 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 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 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 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 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 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 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 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 let mut server = mockito::Server::new();
180 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 let mut server = mockito::Server::new();
197 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 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 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}