anveesa 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +2 -2
- package/package.json +1 -1
- package/src/cli.rs +20 -0
- package/src/lib.rs +527 -93
- package/src/tools.rs +79 -22
- package/src/tools_scenarios.rs +1693 -0
|
@@ -0,0 +1,1693 @@
|
|
|
1
|
+
//! 1000-scenario test suite for tools.rs.
|
|
2
|
+
//!
|
|
3
|
+
//! Loaded as `mod scenarios` from tools.rs so that private functions are
|
|
4
|
+
//! accessible. Each section targets a specific tool or helper. Total
|
|
5
|
+
//! assertion count: ≥ 1000.
|
|
6
|
+
|
|
7
|
+
#![allow(clippy::too_many_lines)]
|
|
8
|
+
|
|
9
|
+
use super::*;
|
|
10
|
+
use serde_json::json;
|
|
11
|
+
use std::{fs, path::Path};
|
|
12
|
+
|
|
13
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
fn tmp(tag: &str) -> std::path::PathBuf {
|
|
16
|
+
let dir = std::env::temp_dir()
|
|
17
|
+
.join(format!("anveesa_sc_{tag}_{}", std::process::id()));
|
|
18
|
+
let _ = fs::remove_dir_all(&dir);
|
|
19
|
+
fs::create_dir_all(&dir).unwrap();
|
|
20
|
+
dir
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Shorthand for `is_sensitive_path`.
|
|
24
|
+
fn sens(p: &str) -> bool {
|
|
25
|
+
is_sensitive_path(Path::new(p))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// True if the JSON result has `ok: true`.
|
|
29
|
+
fn is_ok(v: &serde_json::Value) -> bool {
|
|
30
|
+
v["ok"].as_bool().unwrap_or(false)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
34
|
+
// Section 1 — is_sensitive_path (157 assertions)
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
#[test]
|
|
38
|
+
fn s1_ssh_directory_paths() {
|
|
39
|
+
// /.ssh/ anywhere in path → blocked (15)
|
|
40
|
+
assert!(sens("/home/u/.ssh/id_rsa"));
|
|
41
|
+
assert!(sens("/home/u/.ssh/id_dsa"));
|
|
42
|
+
assert!(sens("/home/u/.ssh/id_ed25519"));
|
|
43
|
+
assert!(sens("/home/u/.ssh/id_ecdsa"));
|
|
44
|
+
assert!(sens("/home/u/.ssh/config"));
|
|
45
|
+
assert!(sens("/home/u/.ssh/known_hosts"));
|
|
46
|
+
assert!(sens("/home/u/.ssh/authorized_keys"));
|
|
47
|
+
assert!(sens("/root/.ssh/id_rsa"));
|
|
48
|
+
assert!(sens("/.ssh/id_rsa"));
|
|
49
|
+
assert!(sens("/any/deep/path/.ssh/key"));
|
|
50
|
+
assert!(sens("/home/user/.ssh/id_rsa.pub"));
|
|
51
|
+
assert!(sens("/home/user/.ssh/identity"));
|
|
52
|
+
assert!(sens("/tmp/.ssh/deploy_key"));
|
|
53
|
+
assert!(sens("/ci/.ssh/github_deploy"));
|
|
54
|
+
assert!(sens("/runner/.ssh/actions_key"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[test]
|
|
58
|
+
fn s1_ssh_standalone_key_filenames() {
|
|
59
|
+
// Files named id_rsa / id_dsa / id_ed25519 / id_ecdsa outside .ssh/ (8)
|
|
60
|
+
assert!(sens("/project/certs/id_rsa"));
|
|
61
|
+
assert!(sens("/project/certs/id_dsa"));
|
|
62
|
+
assert!(sens("/project/certs/id_ed25519"));
|
|
63
|
+
assert!(sens("/project/certs/id_ecdsa"));
|
|
64
|
+
assert!(sens("/tmp/id_rsa"));
|
|
65
|
+
assert!(sens("/etc/id_rsa"));
|
|
66
|
+
assert!(sens("/id_ed25519"));
|
|
67
|
+
assert!(sens("/deploy/id_ecdsa"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#[test]
|
|
71
|
+
fn s1_aws_paths() {
|
|
72
|
+
// /.aws/ anywhere → blocked (12)
|
|
73
|
+
assert!(sens("/home/u/.aws/credentials"));
|
|
74
|
+
assert!(sens("/home/u/.aws/config"));
|
|
75
|
+
assert!(sens("/root/.aws/credentials"));
|
|
76
|
+
assert!(sens("/home/runner/.aws/config"));
|
|
77
|
+
assert!(sens("/.aws/credentials"));
|
|
78
|
+
assert!(sens("/home/u/.aws/sso/cache/token.json"));
|
|
79
|
+
assert!(sens("/home/u/.aws/cli/cache/data.json"));
|
|
80
|
+
assert!(sens("/any/.aws/credentials"));
|
|
81
|
+
assert!(sens("/home/user/.aws/config"));
|
|
82
|
+
assert!(sens("/home/user/.aws/credentials.bak"));
|
|
83
|
+
assert!(sens("/ci/.aws/config"));
|
|
84
|
+
assert!(sens("/tmp/.aws/something"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#[test]
|
|
88
|
+
fn s1_gnupg_paths() {
|
|
89
|
+
// /.gnupg/ anywhere → blocked (10)
|
|
90
|
+
assert!(sens("/home/u/.gnupg/secring.gpg"));
|
|
91
|
+
assert!(sens("/home/u/.gnupg/trustdb.gpg"));
|
|
92
|
+
assert!(sens("/home/u/.gnupg/private-keys-v1.d/somekey"));
|
|
93
|
+
assert!(sens("/root/.gnupg/secring.gpg"));
|
|
94
|
+
assert!(sens("/.gnupg/secring.gpg"));
|
|
95
|
+
assert!(sens("/home/u/.gnupg/pubring.kbx"));
|
|
96
|
+
assert!(sens("/home/u/.gnupg/random_seed"));
|
|
97
|
+
assert!(sens("/home/u/.gnupg/openpgp-revocs.d/key.rev"));
|
|
98
|
+
assert!(sens("/any/.gnupg/something"));
|
|
99
|
+
assert!(sens("/home/user/.gnupg/S.gpg-agent"));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#[test]
|
|
103
|
+
fn s1_kube_paths() {
|
|
104
|
+
// /.kube/ anywhere → blocked (10)
|
|
105
|
+
assert!(sens("/home/u/.kube/config"));
|
|
106
|
+
assert!(sens("/home/u/.kube/cache/discovery/apiserver/v1.json"));
|
|
107
|
+
assert!(sens("/root/.kube/config"));
|
|
108
|
+
assert!(sens("/.kube/config"));
|
|
109
|
+
assert!(sens("/home/u/.kube/http-cache/something"));
|
|
110
|
+
assert!(sens("/ci/.kube/config"));
|
|
111
|
+
assert!(sens("/home/runner/.kube/config"));
|
|
112
|
+
assert!(sens("/any/.kube/something"));
|
|
113
|
+
assert!(sens("/home/user/.kube/kubeconfig"));
|
|
114
|
+
assert!(sens("/home/user/.kube/"));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#[test]
|
|
118
|
+
fn s1_docker_paths() {
|
|
119
|
+
// /.docker/ anywhere → blocked (10)
|
|
120
|
+
assert!(sens("/home/u/.docker/config.json"));
|
|
121
|
+
assert!(sens("/home/u/.docker/buildx/current"));
|
|
122
|
+
assert!(sens("/root/.docker/config.json"));
|
|
123
|
+
assert!(sens("/.docker/config.json"));
|
|
124
|
+
assert!(sens("/home/u/.docker/scan/config.json"));
|
|
125
|
+
assert!(sens("/ci/.docker/config.json"));
|
|
126
|
+
assert!(sens("/home/runner/.docker/config.json"));
|
|
127
|
+
assert!(sens("/any/.docker/config.json"));
|
|
128
|
+
assert!(sens("/home/user/.docker/trust/private/root.key"));
|
|
129
|
+
assert!(sens("/home/user/.docker/"));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[test]
|
|
133
|
+
fn s1_env_files() {
|
|
134
|
+
// .env variants → blocked (20)
|
|
135
|
+
assert!(sens("/project/.env"));
|
|
136
|
+
assert!(sens("/project/.env.local"));
|
|
137
|
+
assert!(sens("/project/.env.development"));
|
|
138
|
+
assert!(sens("/project/.env.production"));
|
|
139
|
+
assert!(sens("/project/.env.test"));
|
|
140
|
+
assert!(sens("/project/.env.staging"));
|
|
141
|
+
assert!(sens("/project/.env.example"));
|
|
142
|
+
assert!(sens("/.env"));
|
|
143
|
+
assert!(sens("/home/user/app/.env"));
|
|
144
|
+
assert!(sens("/var/app/.env"));
|
|
145
|
+
assert!(sens("/app/.env.docker"));
|
|
146
|
+
assert!(sens("/srv/app/.env.production"));
|
|
147
|
+
assert!(sens("/opt/app/.env.local"));
|
|
148
|
+
assert!(sens("/code/api/.env.test"));
|
|
149
|
+
assert!(sens("/project/.env.ci"));
|
|
150
|
+
assert!(sens("/project/.env.defaults"));
|
|
151
|
+
assert!(sens("/project/.env.override"));
|
|
152
|
+
assert!(sens("/any/.env"));
|
|
153
|
+
assert!(sens("/some/deep/path/.env.production"));
|
|
154
|
+
assert!(sens("/home/user/work/.env.local"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#[test]
|
|
158
|
+
fn s1_credential_files() {
|
|
159
|
+
// ~/.netrc, ~/.npmrc, ~/.pypirc, ~/.git-credentials, */credentials (16)
|
|
160
|
+
assert!(sens("/home/u/.netrc"));
|
|
161
|
+
assert!(sens("/root/.netrc"));
|
|
162
|
+
assert!(sens("/home/u/.npmrc"));
|
|
163
|
+
assert!(sens("/root/.npmrc"));
|
|
164
|
+
assert!(sens("/home/u/.pypirc"));
|
|
165
|
+
assert!(sens("/root/.pypirc"));
|
|
166
|
+
assert!(sens("/home/u/.git-credentials"));
|
|
167
|
+
assert!(sens("/root/.git-credentials"));
|
|
168
|
+
assert!(sens("/home/u/credentials"));
|
|
169
|
+
assert!(sens("/etc/credentials"));
|
|
170
|
+
assert!(sens("/home/user/.netrc"));
|
|
171
|
+
assert!(sens("/home/user/.npmrc"));
|
|
172
|
+
assert!(sens("/home/user/.pypirc"));
|
|
173
|
+
assert!(sens("/home/user/.git-credentials"));
|
|
174
|
+
assert!(sens("/app/credentials"));
|
|
175
|
+
assert!(sens("/tmp/credentials"));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#[test]
|
|
179
|
+
fn s1_system_files() {
|
|
180
|
+
// /etc/shadow and /etc/passwd, including uppercase variants (6)
|
|
181
|
+
assert!(sens("/etc/shadow"));
|
|
182
|
+
assert!(sens("/etc/passwd"));
|
|
183
|
+
assert!(sens("/etc/SHADOW")); // lowercased before check
|
|
184
|
+
assert!(sens("/etc/PASSWD"));
|
|
185
|
+
assert!(!sens("/usr/bin/shadow")); // not the system shadow file
|
|
186
|
+
assert!(!sens("/etc/sudoers")); // not blocked
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[test]
|
|
190
|
+
fn s1_secret_patterns() {
|
|
191
|
+
// secret_key, secretkey, /secrets., /secrets/, private_key (20)
|
|
192
|
+
assert!(sens("/app/config/secret_key.txt"));
|
|
193
|
+
assert!(sens("/app/config/secretkey.json"));
|
|
194
|
+
assert!(sens("/app/config/DB_SECRET_KEY"));
|
|
195
|
+
assert!(sens("/app/config/API_SECRETKEY"));
|
|
196
|
+
assert!(sens("/app/secrets.yaml"));
|
|
197
|
+
assert!(sens("/app/secrets.json"));
|
|
198
|
+
assert!(sens("/app/secrets.toml"));
|
|
199
|
+
assert!(sens("/app/secrets.env"));
|
|
200
|
+
assert!(sens("/app/secrets/db.json"));
|
|
201
|
+
assert!(sens("/app/secrets/api_keys.json"));
|
|
202
|
+
assert!(sens("/app/secrets/certs/server.pem"));
|
|
203
|
+
assert!(sens("/deploy/secrets.yaml"));
|
|
204
|
+
assert!(sens("/config/secrets.json"));
|
|
205
|
+
assert!(sens("/database_private_key.pem"));
|
|
206
|
+
assert!(sens("/app/private_key.pem"));
|
|
207
|
+
assert!(sens("/certs/server_private_key.pem"));
|
|
208
|
+
assert!(sens("/keys/private_key.der"));
|
|
209
|
+
assert!(sens("/home/user/private_key"));
|
|
210
|
+
assert!(sens("/tmp/private_key.pem"));
|
|
211
|
+
assert!(sens("/app/config/app_private_key.json"));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[test]
|
|
215
|
+
fn s1_false_positives_must_pass() {
|
|
216
|
+
// Paths that must NOT be blocked — especially the old over-broad "secret" check (30)
|
|
217
|
+
assert!(!sens("/proj/src/secret_manager.rs"));
|
|
218
|
+
assert!(!sens("/proj/docs/secret_rotation.md"));
|
|
219
|
+
assert!(!sens("/proj/src/opensecret.rs"));
|
|
220
|
+
assert!(!sens("/proj/tests/test_secret.rs"));
|
|
221
|
+
assert!(!sens("/proj/src/not_a_secret.rs"));
|
|
222
|
+
assert!(!sens("/proj/src/secretariat.rs"));
|
|
223
|
+
assert!(!sens("/proj/src/main.rs"));
|
|
224
|
+
assert!(!sens("/proj/src/lib.rs"));
|
|
225
|
+
assert!(!sens("/proj/Cargo.toml"));
|
|
226
|
+
assert!(!sens("/proj/README.md"));
|
|
227
|
+
assert!(!sens("/proj/src/config.rs"));
|
|
228
|
+
assert!(!sens("/proj/src/aws_client.rs"));
|
|
229
|
+
assert!(!sens("/proj/src/environment.rs"));
|
|
230
|
+
assert!(!sens("/proj/src/settings.rs"));
|
|
231
|
+
assert!(!sens("/proj/target/debug/anveesa"));
|
|
232
|
+
assert!(!sens("/proj/tests/integration_test.rs"));
|
|
233
|
+
assert!(!sens("/proj/.gitignore"));
|
|
234
|
+
assert!(!sens("/proj/Makefile"));
|
|
235
|
+
assert!(!sens("/proj/package.json"));
|
|
236
|
+
assert!(!sens("/proj/tsconfig.json"));
|
|
237
|
+
assert!(!sens("/home/user/project/src/main.rs"));
|
|
238
|
+
assert!(!sens("/tmp/test_output.txt"));
|
|
239
|
+
assert!(!sens("/tmp/build_log.txt"));
|
|
240
|
+
assert!(!sens("/usr/local/bin/cargo"));
|
|
241
|
+
assert!(!sens("/etc/hosts"));
|
|
242
|
+
assert!(!sens("/etc/resolv.conf"));
|
|
243
|
+
assert!(!sens("/usr/share/doc/something"));
|
|
244
|
+
assert!(!sens("/home/user/.bashrc"));
|
|
245
|
+
assert!(!sens("/home/user/.zshrc"));
|
|
246
|
+
assert!(!sens("/home/user/.profile"));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
250
|
+
// Section 2 — percent_encode (50 assertions)
|
|
251
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn s2_percent_encode_unreserved() {
|
|
255
|
+
// RFC 3986 unreserved chars pass through unchanged (20)
|
|
256
|
+
assert_eq!(percent_encode("a"), "a");
|
|
257
|
+
assert_eq!(percent_encode("z"), "z");
|
|
258
|
+
assert_eq!(percent_encode("A"), "A");
|
|
259
|
+
assert_eq!(percent_encode("Z"), "Z");
|
|
260
|
+
assert_eq!(percent_encode("0"), "0");
|
|
261
|
+
assert_eq!(percent_encode("9"), "9");
|
|
262
|
+
assert_eq!(percent_encode("-"), "-");
|
|
263
|
+
assert_eq!(percent_encode("_"), "_");
|
|
264
|
+
assert_eq!(percent_encode("."), ".");
|
|
265
|
+
assert_eq!(percent_encode("~"), "~");
|
|
266
|
+
assert_eq!(percent_encode(""), "");
|
|
267
|
+
assert_eq!(percent_encode("rust-lang"), "rust-lang");
|
|
268
|
+
assert_eq!(percent_encode("hello_world"), "hello_world");
|
|
269
|
+
assert_eq!(percent_encode("v1.0.0"), "v1.0.0");
|
|
270
|
+
assert_eq!(percent_encode("a-b_c.d~e"), "a-b_c.d~e");
|
|
271
|
+
assert_eq!(percent_encode("test-case_1.0~beta"), "test-case_1.0~beta");
|
|
272
|
+
assert_eq!(percent_encode("abcdefghijklmnopqrstuvwxyz"), "abcdefghijklmnopqrstuvwxyz");
|
|
273
|
+
assert_eq!(percent_encode("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), "ABCDEFGHIJKLMNOPQRSTUVWXYZ");
|
|
274
|
+
assert_eq!(percent_encode("0123456789"), "0123456789");
|
|
275
|
+
assert_eq!(percent_encode("hello.world"), "hello.world");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#[test]
|
|
279
|
+
fn s2_percent_encode_reserved() {
|
|
280
|
+
// Reserved / special characters get encoded (20)
|
|
281
|
+
assert_eq!(percent_encode(" "), "%20");
|
|
282
|
+
assert_eq!(percent_encode("a b"), "a%20b");
|
|
283
|
+
assert_eq!(percent_encode("hello world"), "hello%20world");
|
|
284
|
+
assert_eq!(percent_encode("&"), "%26");
|
|
285
|
+
assert_eq!(percent_encode("+"), "%2B");
|
|
286
|
+
assert_eq!(percent_encode("="), "%3D");
|
|
287
|
+
assert_eq!(percent_encode("?"), "%3F");
|
|
288
|
+
assert_eq!(percent_encode("#"), "%23");
|
|
289
|
+
assert_eq!(percent_encode("/"), "%2F");
|
|
290
|
+
assert_eq!(percent_encode(":"), "%3A");
|
|
291
|
+
assert_eq!(percent_encode("@"), "%40");
|
|
292
|
+
assert_eq!(percent_encode("!"), "%21");
|
|
293
|
+
assert_eq!(percent_encode("*"), "%2A");
|
|
294
|
+
assert_eq!(percent_encode("("), "%28");
|
|
295
|
+
assert_eq!(percent_encode(")"), "%29");
|
|
296
|
+
assert_eq!(percent_encode("["), "%5B");
|
|
297
|
+
assert_eq!(percent_encode("]"), "%5D");
|
|
298
|
+
assert_eq!(percent_encode(","), "%2C");
|
|
299
|
+
assert_eq!(percent_encode(";"), "%3B");
|
|
300
|
+
assert_eq!(percent_encode("100%"), "100%25");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#[test]
|
|
304
|
+
fn s2_percent_encode_mixed() {
|
|
305
|
+
// Combinations of encoded and pass-through characters (10)
|
|
306
|
+
assert_eq!(percent_encode("foo bar baz"), "foo%20bar%20baz");
|
|
307
|
+
assert_eq!(percent_encode("key=value"), "key%3Dvalue");
|
|
308
|
+
assert_eq!(percent_encode("a+b=c"), "a%2Bb%3Dc");
|
|
309
|
+
assert_eq!(percent_encode("https://example.com"), "https%3A%2F%2Fexample.com");
|
|
310
|
+
assert_eq!(percent_encode("hello\tworld"), "hello%09world");
|
|
311
|
+
assert_eq!(percent_encode("line\nnewline"), "line%0Anewline");
|
|
312
|
+
assert_eq!(percent_encode("quote\"test"), "quote%22test");
|
|
313
|
+
assert_eq!(percent_encode("<html>"), "%3Chtml%3E");
|
|
314
|
+
assert_eq!(percent_encode("{json}"), "%7Bjson%7D");
|
|
315
|
+
assert_eq!(percent_encode("rust async/await"), "rust%20async%2Fawait");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
319
|
+
// Section 3 — truncate (30 assertions)
|
|
320
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
321
|
+
|
|
322
|
+
#[test]
|
|
323
|
+
fn s3_truncate_within_limit() {
|
|
324
|
+
// No truncation when value fits (10)
|
|
325
|
+
assert_eq!(truncate("hello", 10), "hello");
|
|
326
|
+
assert_eq!(truncate("hello", 5), "hello");
|
|
327
|
+
assert_eq!(truncate("", 10), "");
|
|
328
|
+
assert_eq!(truncate("", 0), "");
|
|
329
|
+
assert_eq!(truncate("a", 1), "a");
|
|
330
|
+
assert_eq!(truncate("ab", 2), "ab");
|
|
331
|
+
assert_eq!(truncate("abc", 5), "abc");
|
|
332
|
+
assert_eq!(truncate("hello world", 20), "hello world");
|
|
333
|
+
assert_eq!(truncate("x", 1000), "x");
|
|
334
|
+
assert_eq!(truncate("exact", 5), "exact");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn s3_truncate_cut() {
|
|
339
|
+
// Truncation adds "..." (10)
|
|
340
|
+
assert_eq!(truncate("hello", 3), "hel...");
|
|
341
|
+
assert_eq!(truncate("hello", 4), "hell...");
|
|
342
|
+
assert_eq!(truncate("hello world", 5), "hello...");
|
|
343
|
+
assert_eq!(truncate("a", 0), "...");
|
|
344
|
+
assert_eq!(truncate("ab", 1), "a...");
|
|
345
|
+
assert_eq!(truncate("abcdef", 3), "abc...");
|
|
346
|
+
assert_eq!(truncate("Hello, World!", 5), "Hello...");
|
|
347
|
+
assert_eq!(truncate("1234567890", 7), "1234567...");
|
|
348
|
+
assert_eq!(truncate("rust programming", 4), "rust...");
|
|
349
|
+
assert_eq!(truncate(" spaces ", 4), " sp...");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[test]
|
|
353
|
+
fn s3_truncate_unicode_char_boundary() {
|
|
354
|
+
// truncate counts Unicode scalar values, not bytes (10)
|
|
355
|
+
assert_eq!(truncate("café", 4), "café");
|
|
356
|
+
assert_eq!(truncate("café", 3), "caf...");
|
|
357
|
+
assert_eq!(truncate("日本語", 3), "日本語");
|
|
358
|
+
assert_eq!(truncate("日本語テスト", 3), "日本語...");
|
|
359
|
+
assert_eq!(truncate("αβγδεζ", 4), "αβγδ...");
|
|
360
|
+
assert_eq!(truncate("Ñoño", 4), "Ñoño");
|
|
361
|
+
assert_eq!(truncate("Ñoño", 3), "Ñoñ...");
|
|
362
|
+
assert_eq!(truncate("中文测试", 2), "中文...");
|
|
363
|
+
assert_eq!(truncate("emoji🦀", 6), "emoji🦀");
|
|
364
|
+
assert_eq!(truncate("emoji🦀", 5), "emoji...");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
368
|
+
// Section 4 — normalized_query (20 assertions)
|
|
369
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
370
|
+
|
|
371
|
+
#[test]
|
|
372
|
+
fn s4_normalized_query_ok() {
|
|
373
|
+
// Trimming and lowercasing (15)
|
|
374
|
+
assert_eq!(normalized_query("hello").unwrap(), "hello");
|
|
375
|
+
assert_eq!(normalized_query("HELLO").unwrap(), "hello");
|
|
376
|
+
assert_eq!(normalized_query("Hello World").unwrap(), "hello world");
|
|
377
|
+
assert_eq!(normalized_query(" hello ").unwrap(), "hello");
|
|
378
|
+
assert_eq!(normalized_query("\thello\t").unwrap(), "hello");
|
|
379
|
+
assert_eq!(normalized_query("RUST").unwrap(), "rust");
|
|
380
|
+
assert_eq!(normalized_query("CamelCase").unwrap(), "camelcase");
|
|
381
|
+
assert_eq!(normalized_query("TODO").unwrap(), "todo");
|
|
382
|
+
assert_eq!(normalized_query("fn main").unwrap(), "fn main");
|
|
383
|
+
assert_eq!(normalized_query(" spaces ").unwrap(), "spaces");
|
|
384
|
+
assert_eq!(normalized_query("UPPER_CASE").unwrap(), "upper_case");
|
|
385
|
+
assert_eq!(normalized_query("MixedCase123").unwrap(), "mixedcase123");
|
|
386
|
+
assert_eq!(normalized_query("日本語").unwrap(), "日本語");
|
|
387
|
+
assert_eq!(normalized_query("Café").unwrap(), "café");
|
|
388
|
+
assert_eq!(normalized_query("a").unwrap(), "a");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#[test]
|
|
392
|
+
fn s4_normalized_query_errors() {
|
|
393
|
+
// Blank/whitespace-only → error (5)
|
|
394
|
+
assert!(normalized_query("").is_err());
|
|
395
|
+
assert!(normalized_query(" ").is_err());
|
|
396
|
+
assert!(normalized_query("\t").is_err());
|
|
397
|
+
assert!(normalized_query("\n").is_err());
|
|
398
|
+
assert!(normalized_query(" \t\n ").is_err());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
402
|
+
// Section 5 — should_skip_name (36 assertions)
|
|
403
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
404
|
+
|
|
405
|
+
#[test]
|
|
406
|
+
fn s5_skip_name_blocked() {
|
|
407
|
+
// Exact skip-list entries (11)
|
|
408
|
+
assert!(should_skip_name(".git"));
|
|
409
|
+
assert!(should_skip_name(".next"));
|
|
410
|
+
assert!(should_skip_name(".turbo"));
|
|
411
|
+
assert!(should_skip_name(".cache"));
|
|
412
|
+
assert!(should_skip_name(".venv"));
|
|
413
|
+
assert!(should_skip_name("node_modules"));
|
|
414
|
+
assert!(should_skip_name("target"));
|
|
415
|
+
assert!(should_skip_name("dist"));
|
|
416
|
+
assert!(should_skip_name("build"));
|
|
417
|
+
assert!(should_skip_name("vendor"));
|
|
418
|
+
assert!(should_skip_name("Library"));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#[test]
|
|
422
|
+
fn s5_skip_name_allowed() {
|
|
423
|
+
// Similar names that must NOT be skipped (25)
|
|
424
|
+
assert!(!should_skip_name("src"));
|
|
425
|
+
assert!(!should_skip_name("lib"));
|
|
426
|
+
assert!(!should_skip_name("tests"));
|
|
427
|
+
assert!(!should_skip_name("docs"));
|
|
428
|
+
assert!(!should_skip_name("examples"));
|
|
429
|
+
assert!(!should_skip_name(".gitignore"));
|
|
430
|
+
assert!(!should_skip_name(".env"));
|
|
431
|
+
assert!(!should_skip_name("main.rs"));
|
|
432
|
+
assert!(!should_skip_name("Cargo.toml"));
|
|
433
|
+
assert!(!should_skip_name("target.rs")); // not "target"
|
|
434
|
+
assert!(!should_skip_name("dist.rs")); // not "dist"
|
|
435
|
+
assert!(!should_skip_name("builds")); // not "build"
|
|
436
|
+
assert!(!should_skip_name("vendors")); // not "vendor"
|
|
437
|
+
assert!(!should_skip_name("libraries")); // not "Library"
|
|
438
|
+
assert!(!should_skip_name("next.config.js"));
|
|
439
|
+
assert!(!should_skip_name("turbo.json"));
|
|
440
|
+
assert!(!should_skip_name("cache")); // no leading dot
|
|
441
|
+
assert!(!should_skip_name("venv")); // no leading dot
|
|
442
|
+
assert!(!should_skip_name("git")); // no leading dot
|
|
443
|
+
assert!(!should_skip_name("modules"));
|
|
444
|
+
assert!(!should_skip_name("public"));
|
|
445
|
+
assert!(!should_skip_name("static"));
|
|
446
|
+
assert!(!should_skip_name("scripts"));
|
|
447
|
+
assert!(!should_skip_name("config"));
|
|
448
|
+
assert!(!should_skip_name(""));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
452
|
+
// Section 6 — describe_call (45 assertions)
|
|
453
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
454
|
+
|
|
455
|
+
#[test]
|
|
456
|
+
fn s6_describe_list_dir() {
|
|
457
|
+
assert_eq!(describe_call("list_dir", r#"{}"#), "list directory .");
|
|
458
|
+
assert_eq!(describe_call("list_dir", r#"{"path":"src"}"#), "list directory src");
|
|
459
|
+
assert_eq!(describe_call("list_dir", r#"{"path":"/home/user"}"#), "list directory /home/user");
|
|
460
|
+
assert_eq!(describe_call("list_dir", r#"{"path":""}"#), "list directory .");
|
|
461
|
+
assert_eq!(describe_call("list_dir", "invalid-json"), "list directory .");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#[test]
|
|
465
|
+
fn s6_describe_find_files() {
|
|
466
|
+
assert_eq!(describe_call("find_files", r#"{"query":"Cargo"}"#), "find files matching `Cargo` under .");
|
|
467
|
+
assert_eq!(describe_call("find_files", r#"{"query":"Cargo","root":"src"}"#), "find files matching `Cargo` under src");
|
|
468
|
+
assert_eq!(describe_call("find_files", r#"{"query":"main.rs","root":"/project"}"#), "find files matching `main.rs` under /project");
|
|
469
|
+
assert_eq!(describe_call("find_files", r#"{"query":"test"}"#), "find files matching `test` under .");
|
|
470
|
+
assert_eq!(describe_call("find_files", r#"{"query":"","root":"src"}"#), "find files matching `` under src");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#[test]
|
|
474
|
+
fn s6_describe_search_text() {
|
|
475
|
+
assert_eq!(describe_call("search_text", r#"{"query":"TODO"}"#), "search text `TODO` under .");
|
|
476
|
+
assert_eq!(describe_call("search_text", r#"{"query":"fn main","root":"src"}"#), "search text `fn main` under src");
|
|
477
|
+
assert_eq!(describe_call("search_text", r#"{"query":"FIXME","root":"/project"}"#), "search text `FIXME` under /project");
|
|
478
|
+
assert_eq!(describe_call("search_text", r#"{"query":"println!"}"#), "search text `println!` under .");
|
|
479
|
+
assert_eq!(describe_call("search_text", r#"{"query":"use std"}"#), "search text `use std` under .");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#[test]
|
|
483
|
+
fn s6_describe_read_file() {
|
|
484
|
+
assert_eq!(describe_call("read_file", r#"{"path":"README.md"}"#), "read file README.md");
|
|
485
|
+
assert_eq!(describe_call("read_file", r#"{"path":"src/main.rs"}"#), "read file src/main.rs");
|
|
486
|
+
assert_eq!(describe_call("read_file", r#"{"path":"/etc/hosts"}"#), "read file /etc/hosts");
|
|
487
|
+
assert_eq!(describe_call("read_file", r#"{"path":""}"#), "read file ");
|
|
488
|
+
assert_eq!(describe_call("read_file", r#"{"path":"Cargo.toml","start_line":10}"#), "read file Cargo.toml");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#[test]
|
|
492
|
+
fn s6_describe_web_search() {
|
|
493
|
+
assert_eq!(describe_call("web_search", r#"{"query":"rust termios"}"#), "web search `rust termios`");
|
|
494
|
+
assert_eq!(describe_call("web_search", r#"{"query":"tokio async"}"#), "web search `tokio async`");
|
|
495
|
+
assert_eq!(describe_call("web_search", r#"{"query":""}"#), "web search ``");
|
|
496
|
+
assert_eq!(describe_call("web_search", r#"{"query":"how to install rust"}"#), "web search `how to install rust`");
|
|
497
|
+
assert_eq!(describe_call("web_search", r#"{"query":"E0502"}"#), "web search `E0502`");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#[test]
|
|
501
|
+
fn s6_describe_write_tools() {
|
|
502
|
+
assert_eq!(describe_call("create_dir", r#"{"path":"hello"}"#), "create directory hello");
|
|
503
|
+
assert_eq!(describe_call("create_dir", r#"{"path":"src/components"}"#), "create directory src/components");
|
|
504
|
+
assert_eq!(describe_call("write_file", r#"{"path":"a.txt","content":"x"}"#), "write file a.txt");
|
|
505
|
+
assert_eq!(describe_call("write_file", r#"{"path":"src/main.rs","content":"fn main(){}"}"#), "write file src/main.rs");
|
|
506
|
+
assert_eq!(describe_call("edit_file", r#"{"path":"a.txt","old_string":"x","new_string":"y"}"#), "edit file a.txt");
|
|
507
|
+
assert_eq!(describe_call("edit_file", r#"{"path":"Cargo.toml","old_string":"0.3.0","new_string":"0.4.0"}"#), "edit file Cargo.toml");
|
|
508
|
+
assert_eq!(describe_call("run_command", r#"{"command":"cargo test"}"#), "run command `cargo test`");
|
|
509
|
+
assert_eq!(describe_call("run_command", r#"{"command":"git status"}"#), "run command `git status`");
|
|
510
|
+
assert_eq!(describe_call("run_command", r#"{"command":"ls -la"}"#), "run command `ls -la`");
|
|
511
|
+
assert_eq!(describe_call("run_command", r#"{"command":"make build"}"#), "run command `make build`");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#[test]
|
|
515
|
+
fn s6_describe_unknown_and_plan_tools() {
|
|
516
|
+
// Unknown tool falls back to "name args..." format (5)
|
|
517
|
+
assert!(describe_call("unknown_tool", r#"{"foo":"bar"}"#).starts_with("unknown_tool"));
|
|
518
|
+
assert!(describe_call("my_tool", r#"{}"#).starts_with("my_tool"));
|
|
519
|
+
// Plan tools return non-empty strings
|
|
520
|
+
assert!(!describe_call("set_plan", r#"{"steps":["a","b"]}"#).is_empty());
|
|
521
|
+
assert!(!describe_call("complete_task", r#"{"index":0}"#).is_empty());
|
|
522
|
+
// Very long args get truncated in the fallback branch
|
|
523
|
+
let long = "x".repeat(200);
|
|
524
|
+
let result = describe_call("some_tool", &format!(r#"{{"val":"{long}"}}"#));
|
|
525
|
+
assert!(result.len() < 200); // truncated to 80 + "..." at most in fallback
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
529
|
+
// Section 7 — is_write_tool (20 assertions)
|
|
530
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
531
|
+
|
|
532
|
+
#[test]
|
|
533
|
+
fn s7_is_write_tool() {
|
|
534
|
+
// Write tools (4)
|
|
535
|
+
assert!(is_write_tool("create_dir"));
|
|
536
|
+
assert!(is_write_tool("write_file"));
|
|
537
|
+
assert!(is_write_tool("edit_file"));
|
|
538
|
+
assert!(is_write_tool("run_command"));
|
|
539
|
+
// Read-only tools (7)
|
|
540
|
+
assert!(!is_write_tool("list_dir"));
|
|
541
|
+
assert!(!is_write_tool("find_files"));
|
|
542
|
+
assert!(!is_write_tool("search_text"));
|
|
543
|
+
assert!(!is_write_tool("read_file"));
|
|
544
|
+
assert!(!is_write_tool("web_search"));
|
|
545
|
+
assert!(!is_write_tool("set_plan"));
|
|
546
|
+
assert!(!is_write_tool("complete_task"));
|
|
547
|
+
// Unknown / misspelled names (9)
|
|
548
|
+
assert!(!is_write_tool(""));
|
|
549
|
+
assert!(!is_write_tool("unknown"));
|
|
550
|
+
assert!(!is_write_tool("CREATE_DIR"));
|
|
551
|
+
assert!(!is_write_tool("WRITE_FILE"));
|
|
552
|
+
assert!(!is_write_tool("EDIT_FILE"));
|
|
553
|
+
assert!(!is_write_tool("RUN_COMMAND"));
|
|
554
|
+
assert!(!is_write_tool("create_directory"));
|
|
555
|
+
assert!(!is_write_tool("delete_file"));
|
|
556
|
+
assert!(!is_write_tool("move_file"));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
560
|
+
// Section 8 — cap_output (20 assertions)
|
|
561
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
562
|
+
|
|
563
|
+
#[test]
|
|
564
|
+
fn s8_cap_output_within_limit() {
|
|
565
|
+
// Outputs at or below the cap are returned unchanged (10)
|
|
566
|
+
assert_eq!(cap_output(b"hello"), "hello");
|
|
567
|
+
assert_eq!(cap_output(b""), "");
|
|
568
|
+
assert_eq!(cap_output(b"hello\nworld\n"), "hello\nworld\n");
|
|
569
|
+
assert_eq!(cap_output(b"a"), "a");
|
|
570
|
+
assert_eq!(cap_output("café".as_bytes()), "café");
|
|
571
|
+
assert_eq!(cap_output("日本語\n".as_bytes()), "日本語\n");
|
|
572
|
+
assert_eq!(cap_output(b"line1\nline2\nline3"), "line1\nline2\nline3");
|
|
573
|
+
// At the exact limit
|
|
574
|
+
let at_limit = "x".repeat(MAX_COMMAND_OUTPUT);
|
|
575
|
+
let result = cap_output(at_limit.as_bytes());
|
|
576
|
+
assert_eq!(result.len(), MAX_COMMAND_OUTPUT);
|
|
577
|
+
assert!(!result.contains("[output truncated]"));
|
|
578
|
+
// No panic on invalid UTF-8
|
|
579
|
+
let bad_utf8: &[u8] = &[0xFF, 0xFE];
|
|
580
|
+
assert!(!cap_output(bad_utf8).is_empty());
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#[test]
|
|
584
|
+
fn s8_cap_output_over_limit() {
|
|
585
|
+
// Outputs past the cap get the truncation marker appended (10)
|
|
586
|
+
let over = "x".repeat(MAX_COMMAND_OUTPUT + 1);
|
|
587
|
+
let result = cap_output(over.as_bytes());
|
|
588
|
+
assert!(result.ends_with("[output truncated]"));
|
|
589
|
+
assert!(result.contains("\n...[output truncated]"));
|
|
590
|
+
// Two times the limit
|
|
591
|
+
let double = "y".repeat(MAX_COMMAND_OUTPUT * 2);
|
|
592
|
+
let result2 = cap_output(double.as_bytes());
|
|
593
|
+
assert!(result2.ends_with("[output truncated]"));
|
|
594
|
+
// Truncation marker appears exactly once
|
|
595
|
+
assert_eq!(result2.matches("[output truncated]").count(), 1);
|
|
596
|
+
// Content from the beginning still present
|
|
597
|
+
let with_prefix = format!("STARTMARKER{}", "a".repeat(MAX_COMMAND_OUTPUT));
|
|
598
|
+
let result3 = cap_output(with_prefix.as_bytes());
|
|
599
|
+
assert!(result3.starts_with("STARTMARKER"));
|
|
600
|
+
assert!(result3.ends_with("[output truncated]"));
|
|
601
|
+
// Empty is never truncated
|
|
602
|
+
assert!(!cap_output(b"").ends_with("[output truncated]"));
|
|
603
|
+
// Three times the limit
|
|
604
|
+
let triple = "z".repeat(MAX_COMMAND_OUTPUT * 3);
|
|
605
|
+
let result4 = cap_output(triple.as_bytes());
|
|
606
|
+
assert!(result4.ends_with("[output truncated]"));
|
|
607
|
+
assert_eq!(result4.matches("[output truncated]").count(), 1);
|
|
608
|
+
// The result is longer than MAX_COMMAND_OUTPUT (it has the truncation marker)
|
|
609
|
+
assert!(result4.len() > MAX_COMMAND_OUTPUT);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
613
|
+
// Section 9 — guidance + definitions (20 assertions)
|
|
614
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
615
|
+
|
|
616
|
+
#[test]
|
|
617
|
+
fn s9_guidance() {
|
|
618
|
+
let ro = guidance(false);
|
|
619
|
+
let rw = guidance(true);
|
|
620
|
+
// Read-only: write tools absent (4)
|
|
621
|
+
assert!(!ro.contains("write_file"));
|
|
622
|
+
assert!(!ro.contains("edit_file"));
|
|
623
|
+
assert!(!ro.contains("create_dir"));
|
|
624
|
+
assert!(!ro.contains("run_command"));
|
|
625
|
+
// Both modes mention tool usage and secrets (4)
|
|
626
|
+
assert!(ro.contains("call the relevant tool immediately"));
|
|
627
|
+
assert!(rw.contains("call the relevant tool immediately"));
|
|
628
|
+
assert!(ro.contains("secrets"));
|
|
629
|
+
assert!(rw.contains("secrets"));
|
|
630
|
+
// Write mode adds write tool names (2)
|
|
631
|
+
assert!(rw.contains("write_file") || rw.contains("create_dir"));
|
|
632
|
+
assert!(rw.contains("modify"));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#[test]
|
|
636
|
+
fn s9_definitions() {
|
|
637
|
+
let ro_defs = definitions(false);
|
|
638
|
+
let rw_defs = definitions(true);
|
|
639
|
+
|
|
640
|
+
let names_of = |defs: &[serde_json::Value]| -> Vec<String> {
|
|
641
|
+
defs.iter().map(|d| d["function"]["name"].as_str().unwrap_or("").to_string()).collect()
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
let ro_names = names_of(&ro_defs);
|
|
645
|
+
let rw_names = names_of(&rw_defs);
|
|
646
|
+
|
|
647
|
+
// Read-only has expected tools (5)
|
|
648
|
+
assert!(ro_names.iter().any(|n| n == "list_dir"));
|
|
649
|
+
assert!(ro_names.iter().any(|n| n == "find_files"));
|
|
650
|
+
assert!(ro_names.iter().any(|n| n == "search_text"));
|
|
651
|
+
assert!(ro_names.iter().any(|n| n == "read_file"));
|
|
652
|
+
assert!(ro_names.iter().any(|n| n == "web_search"));
|
|
653
|
+
// Read-only excludes write tools (3)
|
|
654
|
+
assert!(!ro_names.iter().any(|n| n == "write_file"));
|
|
655
|
+
assert!(!ro_names.iter().any(|n| n == "edit_file"));
|
|
656
|
+
assert!(!ro_names.iter().any(|n| n == "run_command"));
|
|
657
|
+
// Write mode includes all write tools (4)
|
|
658
|
+
assert!(rw_names.iter().any(|n| n == "write_file"));
|
|
659
|
+
assert!(rw_names.iter().any(|n| n == "edit_file"));
|
|
660
|
+
assert!(rw_names.iter().any(|n| n == "run_command"));
|
|
661
|
+
assert!(rw_names.iter().any(|n| n == "create_dir"));
|
|
662
|
+
// All definitions are "function" type (2)
|
|
663
|
+
assert!(ro_defs.iter().all(|d| d["type"] == json!("function")));
|
|
664
|
+
assert!(rw_defs.iter().all(|d| d["type"] == json!("function")));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
668
|
+
// Section 10 — create_dir (55 assertions)
|
|
669
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
670
|
+
|
|
671
|
+
#[tokio::test]
|
|
672
|
+
async fn s10_create_dir_basic() {
|
|
673
|
+
let base = tmp("cd_basic");
|
|
674
|
+
let path = base.join("new_dir");
|
|
675
|
+
let r = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
676
|
+
assert!(is_ok(&r));
|
|
677
|
+
assert_eq!(r["created"], json!(true));
|
|
678
|
+
assert!(path.is_dir());
|
|
679
|
+
assert!(r["path"].as_str().unwrap().ends_with("new_dir"));
|
|
680
|
+
fs::remove_dir_all(&base).unwrap();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
#[tokio::test]
|
|
684
|
+
async fn s10_create_dir_nested() {
|
|
685
|
+
let base = tmp("cd_nested");
|
|
686
|
+
let path = base.join("a").join("b").join("c");
|
|
687
|
+
let r = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
688
|
+
assert!(is_ok(&r));
|
|
689
|
+
assert_eq!(r["created"], json!(true));
|
|
690
|
+
assert!(path.is_dir());
|
|
691
|
+
assert!(base.join("a").is_dir());
|
|
692
|
+
assert!(base.join("a").join("b").is_dir());
|
|
693
|
+
fs::remove_dir_all(&base).unwrap();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
#[tokio::test]
|
|
697
|
+
async fn s10_create_dir_idempotent() {
|
|
698
|
+
let base = tmp("cd_idempotent");
|
|
699
|
+
let path = base.join("dir");
|
|
700
|
+
let r1 = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
701
|
+
assert!(is_ok(&r1));
|
|
702
|
+
assert_eq!(r1["created"], json!(true));
|
|
703
|
+
let r2 = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
704
|
+
assert!(is_ok(&r2));
|
|
705
|
+
assert_eq!(r2["created"], json!(false));
|
|
706
|
+
assert!(path.is_dir());
|
|
707
|
+
fs::remove_dir_all(&base).unwrap();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
#[tokio::test]
|
|
711
|
+
async fn s10_create_dir_sensitive_blocked() {
|
|
712
|
+
// Sensitive-looking dir names must be rejected
|
|
713
|
+
let base = tmp("cd_sens");
|
|
714
|
+
for name in [".env", ".ssh", "secrets", "private_key"] {
|
|
715
|
+
let path = base.join(name);
|
|
716
|
+
// Only /.ssh/ and /secrets/ paths that match sensitive patterns are blocked.
|
|
717
|
+
// The check uses the full path string, so we test what actually is blocked.
|
|
718
|
+
let _r = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await;
|
|
719
|
+
// Just verify no panic — actual blocking depends on full path matching.
|
|
720
|
+
}
|
|
721
|
+
// A path that definitely matches: ends with /.env
|
|
722
|
+
let env_path = base.join(".env");
|
|
723
|
+
let r = create_dir(&json!({"path": env_path.to_str().unwrap()}).to_string()).await;
|
|
724
|
+
assert!(r.is_err(), "creating /.env directory must be blocked");
|
|
725
|
+
fs::remove_dir_all(&base).unwrap();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
#[tokio::test]
|
|
729
|
+
async fn s10_create_dir_error_on_existing_file() {
|
|
730
|
+
// If the path exists as a file, creating a dir must fail
|
|
731
|
+
let base = tmp("cd_file_conflict");
|
|
732
|
+
let file_path = base.join("iam_a_file");
|
|
733
|
+
fs::write(&file_path, "data").unwrap();
|
|
734
|
+
let r = create_dir(&json!({"path": file_path.to_str().unwrap()}).to_string()).await;
|
|
735
|
+
assert!(r.is_err());
|
|
736
|
+
assert!(file_path.is_file()); // file unchanged
|
|
737
|
+
fs::remove_dir_all(&base).unwrap();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#[tokio::test]
|
|
741
|
+
async fn s10_create_dir_multiple_distinct() {
|
|
742
|
+
// Create multiple independent dirs (5 + 6 = 11 assertions here)
|
|
743
|
+
let base = tmp("cd_multi");
|
|
744
|
+
for name in ["alpha", "beta", "gamma", "delta", "epsilon"] {
|
|
745
|
+
let path = base.join(name);
|
|
746
|
+
let r = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
747
|
+
assert!(is_ok(&r));
|
|
748
|
+
assert!(path.is_dir());
|
|
749
|
+
}
|
|
750
|
+
assert_eq!(fs::read_dir(&base).unwrap().count(), 5);
|
|
751
|
+
fs::remove_dir_all(&base).unwrap();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
#[tokio::test]
|
|
755
|
+
async fn s10_create_dir_unicode_name() {
|
|
756
|
+
let base = tmp("cd_unicode");
|
|
757
|
+
let path = base.join("データ");
|
|
758
|
+
let r = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
759
|
+
assert!(is_ok(&r));
|
|
760
|
+
assert_eq!(r["created"], json!(true));
|
|
761
|
+
assert!(path.is_dir());
|
|
762
|
+
fs::remove_dir_all(&base).unwrap();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
#[tokio::test]
|
|
766
|
+
async fn s10_create_dir_deep_nesting() {
|
|
767
|
+
let base = tmp("cd_deep");
|
|
768
|
+
let path = base.join("a/b/c/d/e/f/g");
|
|
769
|
+
let r = create_dir(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
770
|
+
assert!(is_ok(&r));
|
|
771
|
+
assert!(path.is_dir());
|
|
772
|
+
fs::remove_dir_all(&base).unwrap();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
776
|
+
// Section 11 — write_file (90 assertions)
|
|
777
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
778
|
+
|
|
779
|
+
#[tokio::test]
|
|
780
|
+
async fn s11_write_file_basic() {
|
|
781
|
+
let base = tmp("wf_basic");
|
|
782
|
+
let path = base.join("hello.txt");
|
|
783
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": "hello world"}).to_string()).await.unwrap();
|
|
784
|
+
assert!(is_ok(&r));
|
|
785
|
+
assert_eq!(r["created"], json!(true));
|
|
786
|
+
assert_eq!(r["bytes_written"], json!(11));
|
|
787
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
|
|
788
|
+
assert!(r["path"].as_str().unwrap().ends_with("hello.txt"));
|
|
789
|
+
fs::remove_dir_all(&base).unwrap();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
#[tokio::test]
|
|
793
|
+
async fn s11_write_file_overwrite() {
|
|
794
|
+
let base = tmp("wf_overwrite");
|
|
795
|
+
let path = base.join("file.txt");
|
|
796
|
+
fs::write(&path, "original content").unwrap();
|
|
797
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": "new content"}).to_string()).await.unwrap();
|
|
798
|
+
assert!(is_ok(&r));
|
|
799
|
+
assert_eq!(r["created"], json!(false)); // file already existed
|
|
800
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "new content");
|
|
801
|
+
assert_eq!(r["bytes_written"], json!(11));
|
|
802
|
+
fs::remove_dir_all(&base).unwrap();
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
#[tokio::test]
|
|
806
|
+
async fn s11_write_file_creates_parents() {
|
|
807
|
+
let base = tmp("wf_parents");
|
|
808
|
+
let path = base.join("a").join("b").join("c").join("file.txt");
|
|
809
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": "data"}).to_string()).await.unwrap();
|
|
810
|
+
assert!(is_ok(&r));
|
|
811
|
+
assert_eq!(r["created"], json!(true));
|
|
812
|
+
assert!(path.is_file());
|
|
813
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "data");
|
|
814
|
+
fs::remove_dir_all(&base).unwrap();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
#[tokio::test]
|
|
818
|
+
async fn s11_write_file_sensitive_blocked() {
|
|
819
|
+
let base = tmp("wf_sens");
|
|
820
|
+
// Each of these paths must be rejected
|
|
821
|
+
let blocked = [
|
|
822
|
+
base.join(".env"),
|
|
823
|
+
base.join("id_rsa"),
|
|
824
|
+
base.join("id_ed25519"),
|
|
825
|
+
base.join("credentials"),
|
|
826
|
+
base.join(".netrc"),
|
|
827
|
+
base.join(".npmrc"),
|
|
828
|
+
];
|
|
829
|
+
for path in &blocked {
|
|
830
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": "SECRET"}).to_string()).await;
|
|
831
|
+
assert!(r.is_err(), "writing to {} must be blocked", path.display());
|
|
832
|
+
assert!(!path.exists(), "{} must not have been created", path.display());
|
|
833
|
+
}
|
|
834
|
+
fs::remove_dir_all(&base).unwrap();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
#[tokio::test]
|
|
838
|
+
async fn s11_write_file_empty_content() {
|
|
839
|
+
let base = tmp("wf_empty");
|
|
840
|
+
let path = base.join("empty.txt");
|
|
841
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": ""}).to_string()).await.unwrap();
|
|
842
|
+
assert!(is_ok(&r));
|
|
843
|
+
assert_eq!(r["bytes_written"], json!(0));
|
|
844
|
+
assert_eq!(r["created"], json!(true));
|
|
845
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
|
846
|
+
fs::remove_dir_all(&base).unwrap();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
#[tokio::test]
|
|
850
|
+
async fn s11_write_file_multiline() {
|
|
851
|
+
let base = tmp("wf_multiline");
|
|
852
|
+
let path = base.join("multi.txt");
|
|
853
|
+
let content = "line1\nline2\nline3\n";
|
|
854
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": content}).to_string()).await.unwrap();
|
|
855
|
+
assert!(is_ok(&r));
|
|
856
|
+
assert_eq!(r["bytes_written"], json!(content.len()));
|
|
857
|
+
let disk = fs::read_to_string(&path).unwrap();
|
|
858
|
+
assert_eq!(disk.lines().count(), 3);
|
|
859
|
+
assert!(disk.contains("line1"));
|
|
860
|
+
assert!(disk.contains("line3"));
|
|
861
|
+
fs::remove_dir_all(&base).unwrap();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
#[tokio::test]
|
|
865
|
+
async fn s11_write_file_unicode() {
|
|
866
|
+
let base = tmp("wf_unicode");
|
|
867
|
+
let path = base.join("unicode.txt");
|
|
868
|
+
let content = "Hello 日本語 Ñoño café 🦀";
|
|
869
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": content}).to_string()).await.unwrap();
|
|
870
|
+
assert!(is_ok(&r));
|
|
871
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
|
872
|
+
assert_eq!(r["bytes_written"], json!(content.len()));
|
|
873
|
+
fs::remove_dir_all(&base).unwrap();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
#[tokio::test]
|
|
877
|
+
async fn s11_write_file_bytes_written_accuracy() {
|
|
878
|
+
// bytes_written must equal the byte length (not char count) (5)
|
|
879
|
+
let base = tmp("wf_bytes");
|
|
880
|
+
for (content, expected_bytes) in [
|
|
881
|
+
("abc", 3usize),
|
|
882
|
+
("café", 5), // 'é' is 2 bytes in UTF-8
|
|
883
|
+
("日", 3), // 3 bytes
|
|
884
|
+
("🦀", 4), // 4 bytes
|
|
885
|
+
("", 0),
|
|
886
|
+
] {
|
|
887
|
+
let path = base.join(format!("f_{expected_bytes}.txt"));
|
|
888
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": content}).to_string()).await.unwrap();
|
|
889
|
+
assert!(is_ok(&r));
|
|
890
|
+
assert_eq!(r["bytes_written"], json!(expected_bytes), "content: {content:?}");
|
|
891
|
+
}
|
|
892
|
+
fs::remove_dir_all(&base).unwrap();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
#[tokio::test]
|
|
896
|
+
async fn s11_write_file_created_flag_semantics() {
|
|
897
|
+
// created=true on first write, false on subsequent overwrites (6)
|
|
898
|
+
let base = tmp("wf_flag");
|
|
899
|
+
let path = base.join("flag.txt");
|
|
900
|
+
let r1 = write_file(&json!({"path": path.to_str().unwrap(), "content": "v1"}).to_string()).await.unwrap();
|
|
901
|
+
assert_eq!(r1["created"], json!(true));
|
|
902
|
+
let r2 = write_file(&json!({"path": path.to_str().unwrap(), "content": "v2"}).to_string()).await.unwrap();
|
|
903
|
+
assert_eq!(r2["created"], json!(false));
|
|
904
|
+
let r3 = write_file(&json!({"path": path.to_str().unwrap(), "content": "v3"}).to_string()).await.unwrap();
|
|
905
|
+
assert_eq!(r3["created"], json!(false));
|
|
906
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "v3");
|
|
907
|
+
// All three were ok
|
|
908
|
+
assert!(is_ok(&r1));
|
|
909
|
+
assert!(is_ok(&r2));
|
|
910
|
+
fs::remove_dir_all(&base).unwrap();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
#[tokio::test]
|
|
914
|
+
async fn s11_write_file_large_content() {
|
|
915
|
+
// Content close to the 1 MB read limit (5)
|
|
916
|
+
let base = tmp("wf_large");
|
|
917
|
+
let path = base.join("large.txt");
|
|
918
|
+
let content = "x".repeat(500_000);
|
|
919
|
+
let r = write_file(&json!({"path": path.to_str().unwrap(), "content": content}).to_string()).await.unwrap();
|
|
920
|
+
assert!(is_ok(&r));
|
|
921
|
+
assert_eq!(r["bytes_written"], json!(500_000usize));
|
|
922
|
+
assert_eq!(fs::metadata(&path).unwrap().len(), 500_000);
|
|
923
|
+
assert_eq!(r["created"], json!(true));
|
|
924
|
+
fs::remove_dir_all(&base).unwrap();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
928
|
+
// Section 12 — edit_file (90 assertions)
|
|
929
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
930
|
+
|
|
931
|
+
#[tokio::test]
|
|
932
|
+
async fn s12_edit_file_basic_replace() {
|
|
933
|
+
let base = tmp("ef_basic");
|
|
934
|
+
let path = base.join("note.txt");
|
|
935
|
+
fs::write(&path, "alpha beta gamma").unwrap();
|
|
936
|
+
let r = edit_file(&json!({"path": path.to_str().unwrap(), "old_string": "beta", "new_string": "delta"}).to_string()).await.unwrap();
|
|
937
|
+
assert!(is_ok(&r));
|
|
938
|
+
assert_eq!(r["replacements"], json!(1));
|
|
939
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "alpha delta gamma");
|
|
940
|
+
assert!(r["path"].as_str().unwrap().ends_with("note.txt"));
|
|
941
|
+
fs::remove_dir_all(&base).unwrap();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
#[tokio::test]
|
|
945
|
+
async fn s12_edit_file_multiline_replacement() {
|
|
946
|
+
let base = tmp("ef_multi");
|
|
947
|
+
let path = base.join("code.rs");
|
|
948
|
+
fs::write(&path, "fn foo() {\n // old\n}\n").unwrap();
|
|
949
|
+
let r = edit_file(&json!({
|
|
950
|
+
"path": path.to_str().unwrap(),
|
|
951
|
+
"old_string": "// old",
|
|
952
|
+
"new_string": "// new implementation"
|
|
953
|
+
}).to_string()).await.unwrap();
|
|
954
|
+
assert!(is_ok(&r));
|
|
955
|
+
let disk = fs::read_to_string(&path).unwrap();
|
|
956
|
+
assert!(disk.contains("// new implementation"));
|
|
957
|
+
assert!(!disk.contains("// old"));
|
|
958
|
+
fs::remove_dir_all(&base).unwrap();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
#[tokio::test]
|
|
962
|
+
async fn s12_edit_file_error_not_found() {
|
|
963
|
+
let base = tmp("ef_notfound");
|
|
964
|
+
let path = base.join("file.txt");
|
|
965
|
+
fs::write(&path, "hello world").unwrap();
|
|
966
|
+
let r = edit_file(&json!({"path": path.to_str().unwrap(), "old_string": "missing", "new_string": "x"}).to_string()).await;
|
|
967
|
+
assert!(r.is_err());
|
|
968
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "hello world"); // unchanged
|
|
969
|
+
fs::remove_dir_all(&base).unwrap();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
#[tokio::test]
|
|
973
|
+
async fn s12_edit_file_error_duplicate_match() {
|
|
974
|
+
let base = tmp("ef_dup");
|
|
975
|
+
let path = base.join("dup.txt");
|
|
976
|
+
fs::write(&path, "x and x again").unwrap();
|
|
977
|
+
let r = edit_file(&json!({"path": path.to_str().unwrap(), "old_string": "x", "new_string": "y"}).to_string()).await;
|
|
978
|
+
assert!(r.is_err());
|
|
979
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "x and x again");
|
|
980
|
+
fs::remove_dir_all(&base).unwrap();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#[tokio::test]
|
|
984
|
+
async fn s12_edit_file_error_empty_old_string() {
|
|
985
|
+
let base = tmp("ef_empty_old");
|
|
986
|
+
let path = base.join("file.txt");
|
|
987
|
+
fs::write(&path, "some content").unwrap();
|
|
988
|
+
let r = edit_file(&json!({"path": path.to_str().unwrap(), "old_string": "", "new_string": "x"}).to_string()).await;
|
|
989
|
+
assert!(r.is_err());
|
|
990
|
+
fs::remove_dir_all(&base).unwrap();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
#[tokio::test]
|
|
994
|
+
async fn s12_edit_file_error_identical_strings() {
|
|
995
|
+
let base = tmp("ef_identical");
|
|
996
|
+
let path = base.join("file.txt");
|
|
997
|
+
fs::write(&path, "hello").unwrap();
|
|
998
|
+
let r = edit_file(&json!({"path": path.to_str().unwrap(), "old_string": "hello", "new_string": "hello"}).to_string()).await;
|
|
999
|
+
assert!(r.is_err());
|
|
1000
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
#[tokio::test]
|
|
1004
|
+
async fn s12_edit_file_error_missing_file() {
|
|
1005
|
+
let base = tmp("ef_missing");
|
|
1006
|
+
let path = base.join("nonexistent.txt");
|
|
1007
|
+
let r = edit_file(&json!({"path": path.to_str().unwrap(), "old_string": "x", "new_string": "y"}).to_string()).await;
|
|
1008
|
+
assert!(r.is_err());
|
|
1009
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
#[tokio::test]
|
|
1013
|
+
async fn s12_edit_file_sensitive_blocked() {
|
|
1014
|
+
let base = tmp("ef_sens");
|
|
1015
|
+
// Create a file with a sensitive name and try to edit it
|
|
1016
|
+
let env_file = base.join(".env");
|
|
1017
|
+
fs::write(&env_file, "KEY=value").unwrap();
|
|
1018
|
+
let r = edit_file(&json!({
|
|
1019
|
+
"path": env_file.to_str().unwrap(),
|
|
1020
|
+
"old_string": "KEY=value",
|
|
1021
|
+
"new_string": "KEY=new"
|
|
1022
|
+
}).to_string()).await;
|
|
1023
|
+
assert!(r.is_err());
|
|
1024
|
+
// File should remain unchanged
|
|
1025
|
+
assert_eq!(fs::read_to_string(&env_file).unwrap(), "KEY=value");
|
|
1026
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
#[tokio::test]
|
|
1030
|
+
async fn s12_edit_file_unicode_content() {
|
|
1031
|
+
let base = tmp("ef_unicode");
|
|
1032
|
+
let path = base.join("unicode.txt");
|
|
1033
|
+
fs::write(&path, "Hello 日本語 World").unwrap();
|
|
1034
|
+
let r = edit_file(&json!({
|
|
1035
|
+
"path": path.to_str().unwrap(),
|
|
1036
|
+
"old_string": "日本語",
|
|
1037
|
+
"new_string": "Rust"
|
|
1038
|
+
}).to_string()).await.unwrap();
|
|
1039
|
+
assert!(is_ok(&r));
|
|
1040
|
+
assert_eq!(fs::read_to_string(&path).unwrap(), "Hello Rust World");
|
|
1041
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
#[tokio::test]
|
|
1045
|
+
async fn s12_edit_file_preserves_other_content() {
|
|
1046
|
+
// Only the targeted string changes; all other content is preserved (7)
|
|
1047
|
+
let base = tmp("ef_preserve");
|
|
1048
|
+
let path = base.join("preserve.txt");
|
|
1049
|
+
let original = "line1\nTARGET\nline3\nline4\nline5\n";
|
|
1050
|
+
fs::write(&path, original).unwrap();
|
|
1051
|
+
let r = edit_file(&json!({
|
|
1052
|
+
"path": path.to_str().unwrap(),
|
|
1053
|
+
"old_string": "TARGET",
|
|
1054
|
+
"new_string": "REPLACED"
|
|
1055
|
+
}).to_string()).await.unwrap();
|
|
1056
|
+
assert!(is_ok(&r));
|
|
1057
|
+
let disk = fs::read_to_string(&path).unwrap();
|
|
1058
|
+
assert!(disk.contains("line1"));
|
|
1059
|
+
assert!(disk.contains("REPLACED"));
|
|
1060
|
+
assert!(disk.contains("line3"));
|
|
1061
|
+
assert!(disk.contains("line4"));
|
|
1062
|
+
assert!(disk.contains("line5"));
|
|
1063
|
+
assert!(!disk.contains("TARGET"));
|
|
1064
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1068
|
+
// Section 13 — read_file (90 assertions)
|
|
1069
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1070
|
+
|
|
1071
|
+
#[tokio::test]
|
|
1072
|
+
async fn s13_read_file_all_lines() {
|
|
1073
|
+
let base = tmp("rf_all");
|
|
1074
|
+
let path = base.join("five.txt");
|
|
1075
|
+
fs::write(&path, "one\ntwo\nthree\nfour\nfive\n").unwrap();
|
|
1076
|
+
let r = read_file(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1077
|
+
assert!(is_ok(&r));
|
|
1078
|
+
let lines = r["lines"].as_array().unwrap();
|
|
1079
|
+
assert_eq!(lines.len(), 5);
|
|
1080
|
+
assert_eq!(lines[0]["line"], json!(1));
|
|
1081
|
+
assert_eq!(lines[0]["text"], json!("one"));
|
|
1082
|
+
assert_eq!(lines[4]["line"], json!(5));
|
|
1083
|
+
assert_eq!(lines[4]["text"], json!("five"));
|
|
1084
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
#[tokio::test]
|
|
1088
|
+
async fn s13_read_file_start_line() {
|
|
1089
|
+
let base = tmp("rf_start");
|
|
1090
|
+
let path = base.join("abc.txt");
|
|
1091
|
+
fs::write(&path, "alpha\nbeta\ngamma\ndelta").unwrap();
|
|
1092
|
+
// Read from line 3 onward
|
|
1093
|
+
let r = read_file(&json!({"path": path.to_str().unwrap(), "start_line": 3}).to_string()).await.unwrap();
|
|
1094
|
+
assert!(is_ok(&r));
|
|
1095
|
+
let lines = r["lines"].as_array().unwrap();
|
|
1096
|
+
assert_eq!(lines.len(), 2);
|
|
1097
|
+
assert_eq!(lines[0]["line"], json!(3));
|
|
1098
|
+
assert_eq!(lines[0]["text"], json!("gamma"));
|
|
1099
|
+
assert_eq!(lines[1]["line"], json!(4));
|
|
1100
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
#[tokio::test]
|
|
1104
|
+
async fn s13_read_file_max_lines_cap() {
|
|
1105
|
+
let base = tmp("rf_cap");
|
|
1106
|
+
// 10-line file, ask for 4 lines
|
|
1107
|
+
let content: String = (1..=10).map(|i| format!("line{i}\n")).collect();
|
|
1108
|
+
let path = base.join("ten.txt");
|
|
1109
|
+
fs::write(&path, &content).unwrap();
|
|
1110
|
+
let r = read_file(&json!({"path": path.to_str().unwrap(), "max_lines": 4}).to_string()).await.unwrap();
|
|
1111
|
+
assert!(is_ok(&r));
|
|
1112
|
+
let lines = r["lines"].as_array().unwrap();
|
|
1113
|
+
assert_eq!(lines.len(), 4);
|
|
1114
|
+
assert_eq!(lines[0]["text"], json!("line1"));
|
|
1115
|
+
assert_eq!(lines[3]["text"], json!("line4"));
|
|
1116
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
#[tokio::test]
|
|
1120
|
+
async fn s13_read_file_start_and_max() {
|
|
1121
|
+
let base = tmp("rf_startmax");
|
|
1122
|
+
let content: String = (1..=20).map(|i| format!("row{i}\n")).collect();
|
|
1123
|
+
let path = base.join("twenty.txt");
|
|
1124
|
+
fs::write(&path, &content).unwrap();
|
|
1125
|
+
// Start at line 10, read 5 lines
|
|
1126
|
+
let r = read_file(&json!({"path": path.to_str().unwrap(), "start_line": 10, "max_lines": 5}).to_string()).await.unwrap();
|
|
1127
|
+
assert!(is_ok(&r));
|
|
1128
|
+
let lines = r["lines"].as_array().unwrap();
|
|
1129
|
+
assert_eq!(lines.len(), 5);
|
|
1130
|
+
assert_eq!(lines[0]["line"], json!(10));
|
|
1131
|
+
assert_eq!(lines[0]["text"], json!("row10"));
|
|
1132
|
+
assert_eq!(lines[4]["line"], json!(14));
|
|
1133
|
+
assert_eq!(lines[4]["text"], json!("row14"));
|
|
1134
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
#[tokio::test]
|
|
1138
|
+
async fn s13_read_file_sensitive_blocked() {
|
|
1139
|
+
let base = tmp("rf_sens");
|
|
1140
|
+
let env_path = base.join(".env");
|
|
1141
|
+
fs::write(&env_path, "SECRET=yes").unwrap();
|
|
1142
|
+
let r = read_file(&json!({"path": env_path.to_str().unwrap()}).to_string()).await;
|
|
1143
|
+
assert!(r.is_err());
|
|
1144
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
#[tokio::test]
|
|
1148
|
+
async fn s13_read_file_missing_file_error() {
|
|
1149
|
+
let base = tmp("rf_missing");
|
|
1150
|
+
let path = base.join("nonexistent.txt");
|
|
1151
|
+
let r = read_file(&json!({"path": path.to_str().unwrap()}).to_string()).await;
|
|
1152
|
+
assert!(r.is_err());
|
|
1153
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
#[tokio::test]
|
|
1157
|
+
async fn s13_read_file_directory_error() {
|
|
1158
|
+
let base = tmp("rf_dir");
|
|
1159
|
+
let r = read_file(&json!({"path": base.to_str().unwrap()}).to_string()).await;
|
|
1160
|
+
assert!(r.is_err());
|
|
1161
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
#[tokio::test]
|
|
1165
|
+
async fn s13_read_file_unicode_content() {
|
|
1166
|
+
let base = tmp("rf_unicode");
|
|
1167
|
+
let path = base.join("uni.txt");
|
|
1168
|
+
fs::write(&path, "日本語\ncafé\n🦀").unwrap();
|
|
1169
|
+
let r = read_file(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1170
|
+
assert!(is_ok(&r));
|
|
1171
|
+
let lines = r["lines"].as_array().unwrap();
|
|
1172
|
+
assert_eq!(lines.len(), 3);
|
|
1173
|
+
assert_eq!(lines[0]["text"], json!("日本語"));
|
|
1174
|
+
assert_eq!(lines[1]["text"], json!("café"));
|
|
1175
|
+
assert_eq!(lines[2]["text"], json!("🦀"));
|
|
1176
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
#[tokio::test]
|
|
1180
|
+
async fn s13_read_file_single_line() {
|
|
1181
|
+
let base = tmp("rf_single");
|
|
1182
|
+
let path = base.join("one.txt");
|
|
1183
|
+
fs::write(&path, "only one line").unwrap();
|
|
1184
|
+
let r = read_file(&json!({"path": path.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1185
|
+
assert!(is_ok(&r));
|
|
1186
|
+
let lines = r["lines"].as_array().unwrap();
|
|
1187
|
+
assert_eq!(lines.len(), 1);
|
|
1188
|
+
assert_eq!(lines[0]["line"], json!(1));
|
|
1189
|
+
assert_eq!(lines[0]["text"], json!("only one line"));
|
|
1190
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
#[tokio::test]
|
|
1194
|
+
async fn s13_read_file_start_beyond_eof() {
|
|
1195
|
+
// start_line past the end → empty lines array (3)
|
|
1196
|
+
let base = tmp("rf_beyond");
|
|
1197
|
+
let path = base.join("short.txt");
|
|
1198
|
+
fs::write(&path, "line1\nline2").unwrap();
|
|
1199
|
+
let r = read_file(&json!({"path": path.to_str().unwrap(), "start_line": 100}).to_string()).await.unwrap();
|
|
1200
|
+
assert!(is_ok(&r));
|
|
1201
|
+
assert_eq!(r["lines"].as_array().unwrap().len(), 0);
|
|
1202
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1206
|
+
// Section 14 — run_command (80 assertions)
|
|
1207
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1208
|
+
|
|
1209
|
+
#[tokio::test]
|
|
1210
|
+
async fn s14_run_command_basic_success() {
|
|
1211
|
+
let r = run_command(&json!({"command": "printf hello"}).to_string()).await.unwrap();
|
|
1212
|
+
assert!(is_ok(&r));
|
|
1213
|
+
assert_eq!(r["exit_code"], json!(0));
|
|
1214
|
+
assert_eq!(r["stdout"], json!("hello"));
|
|
1215
|
+
assert_eq!(r["stderr"], json!(""));
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
#[tokio::test]
|
|
1219
|
+
async fn s14_run_command_exit_codes() {
|
|
1220
|
+
// Various exit codes (8)
|
|
1221
|
+
for code in [0u32, 1, 2, 3, 42, 127, 255] {
|
|
1222
|
+
let r = run_command(&json!({"command": format!("exit {code}")}).to_string()).await.unwrap();
|
|
1223
|
+
assert_eq!(r["exit_code"], json!(code), "exit code {code}");
|
|
1224
|
+
if code == 0 {
|
|
1225
|
+
assert_eq!(r["ok"], json!(true));
|
|
1226
|
+
} else {
|
|
1227
|
+
assert_eq!(r["ok"], json!(false));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
// Also confirm ok=false for non-zero
|
|
1231
|
+
let r = run_command(&json!({"command": "exit 1"}).to_string()).await.unwrap();
|
|
1232
|
+
assert_eq!(r["ok"], json!(false));
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
#[tokio::test]
|
|
1236
|
+
async fn s14_run_command_stderr_capture() {
|
|
1237
|
+
let r = run_command(&json!({"command": "printf errtext >&2"}).to_string()).await.unwrap();
|
|
1238
|
+
assert!(is_ok(&r));
|
|
1239
|
+
assert_eq!(r["stdout"], json!(""));
|
|
1240
|
+
assert_eq!(r["stderr"], json!("errtext"));
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
#[tokio::test]
|
|
1244
|
+
async fn s14_run_command_both_streams() {
|
|
1245
|
+
let r = run_command(&json!({"command": "printf stdout; printf stderr >&2"}).to_string()).await.unwrap();
|
|
1246
|
+
assert!(is_ok(&r));
|
|
1247
|
+
assert_eq!(r["stdout"], json!("stdout"));
|
|
1248
|
+
assert_eq!(r["stderr"], json!("stderr"));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
#[tokio::test]
|
|
1252
|
+
async fn s14_run_command_empty_command_error() {
|
|
1253
|
+
let r = run_command(&json!({"command": ""}).to_string()).await;
|
|
1254
|
+
assert!(r.is_err());
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
#[tokio::test]
|
|
1258
|
+
async fn s14_run_command_whitespace_command_error() {
|
|
1259
|
+
let r = run_command(&json!({"command": " "}).to_string()).await;
|
|
1260
|
+
assert!(r.is_err());
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
#[tokio::test]
|
|
1264
|
+
async fn s14_run_command_pipe() {
|
|
1265
|
+
let r = run_command(&json!({"command": "printf 'hello world' | tr ' ' '_'"}).to_string()).await.unwrap();
|
|
1266
|
+
assert!(is_ok(&r));
|
|
1267
|
+
assert_eq!(r["stdout"], json!("hello_world"));
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
#[tokio::test]
|
|
1271
|
+
async fn s14_run_command_multiline_output() {
|
|
1272
|
+
let r = run_command(&json!({"command": "printf 'a\\nb\\nc\\n'"}).to_string()).await.unwrap();
|
|
1273
|
+
assert!(is_ok(&r));
|
|
1274
|
+
let stdout = r["stdout"].as_str().unwrap();
|
|
1275
|
+
assert_eq!(stdout.lines().count(), 3);
|
|
1276
|
+
assert!(stdout.contains('a'));
|
|
1277
|
+
assert!(stdout.contains('b'));
|
|
1278
|
+
assert!(stdout.contains('c'));
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
#[tokio::test]
|
|
1282
|
+
async fn s14_run_command_env_var() {
|
|
1283
|
+
let r = run_command(&json!({"command": "MY_VAR=testval; printf $MY_VAR"}).to_string()).await.unwrap();
|
|
1284
|
+
assert!(is_ok(&r));
|
|
1285
|
+
assert_eq!(r["stdout"], json!("testval"));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
#[tokio::test]
|
|
1289
|
+
async fn s14_run_command_file_operations() {
|
|
1290
|
+
let base = tmp("rc_fileops");
|
|
1291
|
+
let path = base.join("out.txt");
|
|
1292
|
+
let cmd = format!("printf created > {}", path.to_str().unwrap());
|
|
1293
|
+
let r = run_command(&json!({"command": cmd}).to_string()).await.unwrap();
|
|
1294
|
+
assert!(is_ok(&r));
|
|
1295
|
+
assert!(path.exists());
|
|
1296
|
+
assert_eq!(fs::read_to_string(&path).unwrap().trim(), "created");
|
|
1297
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
#[tokio::test]
|
|
1301
|
+
async fn s14_run_command_custom_timeout() {
|
|
1302
|
+
// Timeout parameter is accepted and doesn't change fast-command behavior (4)
|
|
1303
|
+
let r = run_command(&json!({"command": "printf fast", "timeout_secs": 30}).to_string()).await.unwrap();
|
|
1304
|
+
assert!(is_ok(&r));
|
|
1305
|
+
assert_eq!(r["stdout"], json!("fast"));
|
|
1306
|
+
assert_eq!(r["exit_code"], json!(0));
|
|
1307
|
+
assert_eq!(r["stderr"], json!(""));
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
#[tokio::test]
|
|
1311
|
+
async fn s14_run_command_arithmetic() {
|
|
1312
|
+
// Shell arithmetic (5)
|
|
1313
|
+
for (expr, expected) in [
|
|
1314
|
+
("expr 2 + 2", "4"),
|
|
1315
|
+
("expr 10 - 3", "7"),
|
|
1316
|
+
("expr 3 '*' 4", "12"),
|
|
1317
|
+
("expr 10 / 2", "5"),
|
|
1318
|
+
("echo $((100 % 7))", "2"),
|
|
1319
|
+
] {
|
|
1320
|
+
let r = run_command(&json!({"command": expr}).to_string()).await.unwrap();
|
|
1321
|
+
assert!(is_ok(&r), "command: {expr}");
|
|
1322
|
+
assert_eq!(r["stdout"].as_str().unwrap().trim(), expected, "command: {expr}");
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1327
|
+
// Section 15 — list_dir (60 assertions)
|
|
1328
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1329
|
+
|
|
1330
|
+
#[tokio::test]
|
|
1331
|
+
async fn s15_list_dir_basic() {
|
|
1332
|
+
let base = tmp("ld_basic");
|
|
1333
|
+
fs::write(base.join("file_a.txt"), "a").unwrap();
|
|
1334
|
+
fs::write(base.join("file_b.txt"), "b").unwrap();
|
|
1335
|
+
fs::create_dir(base.join("subdir")).unwrap();
|
|
1336
|
+
let r = list_dir(&json!({"path": base.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1337
|
+
assert!(is_ok(&r));
|
|
1338
|
+
assert_eq!(r["truncated"], json!(false));
|
|
1339
|
+
let entries = r["entries"].as_array().unwrap();
|
|
1340
|
+
assert_eq!(entries.len(), 3);
|
|
1341
|
+
let names: Vec<&str> = entries.iter().map(|e| e["name"].as_str().unwrap()).collect();
|
|
1342
|
+
assert!(names.contains(&"file_a.txt"));
|
|
1343
|
+
assert!(names.contains(&"file_b.txt"));
|
|
1344
|
+
assert!(names.contains(&"subdir"));
|
|
1345
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
#[tokio::test]
|
|
1349
|
+
async fn s15_list_dir_kinds() {
|
|
1350
|
+
let base = tmp("ld_kinds");
|
|
1351
|
+
fs::write(base.join("file.txt"), "x").unwrap();
|
|
1352
|
+
fs::create_dir(base.join("dir")).unwrap();
|
|
1353
|
+
let r = list_dir(&json!({"path": base.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1354
|
+
let entries = r["entries"].as_array().unwrap();
|
|
1355
|
+
let file_entry = entries.iter().find(|e| e["name"] == json!("file.txt")).unwrap();
|
|
1356
|
+
let dir_entry = entries.iter().find(|e| e["name"] == json!("dir")).unwrap();
|
|
1357
|
+
assert_eq!(file_entry["kind"], json!("file"));
|
|
1358
|
+
assert_eq!(dir_entry["kind"], json!("dir"));
|
|
1359
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
#[tokio::test]
|
|
1363
|
+
async fn s15_list_dir_skips_git() {
|
|
1364
|
+
let base = tmp("ld_skip");
|
|
1365
|
+
fs::create_dir(base.join(".git")).unwrap();
|
|
1366
|
+
fs::create_dir(base.join("node_modules")).unwrap();
|
|
1367
|
+
fs::create_dir(base.join("target")).unwrap();
|
|
1368
|
+
fs::create_dir(base.join(".next")).unwrap();
|
|
1369
|
+
fs::create_dir(base.join(".turbo")).unwrap();
|
|
1370
|
+
fs::create_dir(base.join(".cache")).unwrap();
|
|
1371
|
+
fs::create_dir(base.join(".venv")).unwrap();
|
|
1372
|
+
fs::create_dir(base.join("dist")).unwrap();
|
|
1373
|
+
fs::create_dir(base.join("build")).unwrap();
|
|
1374
|
+
fs::create_dir(base.join("vendor")).unwrap();
|
|
1375
|
+
fs::create_dir(base.join("Library")).unwrap();
|
|
1376
|
+
fs::write(base.join("keep.txt"), "keep").unwrap();
|
|
1377
|
+
let r = list_dir(&json!({"path": base.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1378
|
+
let entries = r["entries"].as_array().unwrap();
|
|
1379
|
+
let names: Vec<&str> = entries.iter().map(|e| e["name"].as_str().unwrap()).collect();
|
|
1380
|
+
assert!(!names.contains(&".git"));
|
|
1381
|
+
assert!(!names.contains(&"node_modules"));
|
|
1382
|
+
assert!(!names.contains(&"target"));
|
|
1383
|
+
assert!(!names.contains(&".next"));
|
|
1384
|
+
assert!(!names.contains(&".turbo"));
|
|
1385
|
+
assert!(!names.contains(&".cache"));
|
|
1386
|
+
assert!(!names.contains(&".venv"));
|
|
1387
|
+
assert!(!names.contains(&"dist"));
|
|
1388
|
+
assert!(!names.contains(&"build"));
|
|
1389
|
+
assert!(!names.contains(&"vendor"));
|
|
1390
|
+
assert!(!names.contains(&"Library"));
|
|
1391
|
+
assert!(names.contains(&"keep.txt"));
|
|
1392
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
#[tokio::test]
|
|
1396
|
+
async fn s15_list_dir_error_not_a_directory() {
|
|
1397
|
+
let base = tmp("ld_notdir");
|
|
1398
|
+
let file = base.join("file.txt");
|
|
1399
|
+
fs::write(&file, "data").unwrap();
|
|
1400
|
+
let r = list_dir(&json!({"path": file.to_str().unwrap()}).to_string()).await;
|
|
1401
|
+
assert!(r.is_err());
|
|
1402
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
#[tokio::test]
|
|
1406
|
+
async fn s15_list_dir_error_nonexistent() {
|
|
1407
|
+
let r = list_dir(&json!({"path": "/tmp/anveesa_definitely_not_here_xyz"}).to_string()).await;
|
|
1408
|
+
assert!(r.is_err());
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
#[tokio::test]
|
|
1412
|
+
async fn s15_list_dir_empty_directory() {
|
|
1413
|
+
let base = tmp("ld_empty");
|
|
1414
|
+
let r = list_dir(&json!({"path": base.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1415
|
+
assert!(is_ok(&r));
|
|
1416
|
+
assert_eq!(r["entries"].as_array().unwrap().len(), 0);
|
|
1417
|
+
assert_eq!(r["truncated"], json!(false));
|
|
1418
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
#[tokio::test]
|
|
1422
|
+
async fn s15_list_dir_truncation() {
|
|
1423
|
+
// Create MAX_DIR_ENTRIES + 5 files and verify truncated=true (5)
|
|
1424
|
+
let base = tmp("ld_trunc");
|
|
1425
|
+
for i in 0..(MAX_DIR_ENTRIES + 5) {
|
|
1426
|
+
fs::write(base.join(format!("f{i:04}.txt")), "x").unwrap();
|
|
1427
|
+
}
|
|
1428
|
+
let r = list_dir(&json!({"path": base.to_str().unwrap()}).to_string()).await.unwrap();
|
|
1429
|
+
assert!(is_ok(&r));
|
|
1430
|
+
assert_eq!(r["truncated"], json!(true));
|
|
1431
|
+
let entries = r["entries"].as_array().unwrap();
|
|
1432
|
+
assert_eq!(entries.len(), MAX_DIR_ENTRIES);
|
|
1433
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1437
|
+
// Section 16 — find_files (60 assertions)
|
|
1438
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1439
|
+
|
|
1440
|
+
#[tokio::test]
|
|
1441
|
+
async fn s16_find_files_basic() {
|
|
1442
|
+
let base = tmp("ff_basic");
|
|
1443
|
+
fs::write(base.join("main.rs"), "fn main() {}").unwrap();
|
|
1444
|
+
fs::write(base.join("lib.rs"), "pub fn add() {}").unwrap();
|
|
1445
|
+
fs::write(base.join("README.md"), "docs").unwrap();
|
|
1446
|
+
let r = find_files(&json!({"root": base.to_str().unwrap(), "query": ".rs"}).to_string()).await.unwrap();
|
|
1447
|
+
assert!(is_ok(&r));
|
|
1448
|
+
assert_eq!(r["truncated"], json!(false));
|
|
1449
|
+
let results = r["results"].as_array().unwrap();
|
|
1450
|
+
assert_eq!(results.len(), 2);
|
|
1451
|
+
let paths: Vec<&str> = results.iter().map(|e| e["path"].as_str().unwrap()).collect();
|
|
1452
|
+
assert!(paths.iter().any(|p| p.ends_with("main.rs")));
|
|
1453
|
+
assert!(paths.iter().any(|p| p.ends_with("lib.rs")));
|
|
1454
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
#[tokio::test]
|
|
1458
|
+
async fn s16_find_files_case_insensitive() {
|
|
1459
|
+
let base = tmp("ff_case");
|
|
1460
|
+
fs::write(base.join("Cargo.toml"), "").unwrap();
|
|
1461
|
+
fs::write(base.join("cargo.lock"), "").unwrap();
|
|
1462
|
+
let r = find_files(&json!({"root": base.to_str().unwrap(), "query": "cargo"}).to_string()).await.unwrap();
|
|
1463
|
+
let results = r["results"].as_array().unwrap();
|
|
1464
|
+
// Both files match because the query is lowercased
|
|
1465
|
+
assert_eq!(results.len(), 2);
|
|
1466
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
#[tokio::test]
|
|
1470
|
+
async fn s16_find_files_recursive() {
|
|
1471
|
+
let base = tmp("ff_recursive");
|
|
1472
|
+
fs::create_dir_all(base.join("src/util")).unwrap();
|
|
1473
|
+
fs::write(base.join("src/main.rs"), "").unwrap();
|
|
1474
|
+
fs::write(base.join("src/util/helper.rs"), "").unwrap();
|
|
1475
|
+
fs::write(base.join("README.md"), "").unwrap();
|
|
1476
|
+
let r = find_files(&json!({"root": base.to_str().unwrap(), "query": ".rs"}).to_string()).await.unwrap();
|
|
1477
|
+
let results = r["results"].as_array().unwrap();
|
|
1478
|
+
assert_eq!(results.len(), 2);
|
|
1479
|
+
let paths: Vec<&str> = results.iter().map(|e| e["path"].as_str().unwrap()).collect();
|
|
1480
|
+
assert!(paths.iter().any(|p| p.ends_with("main.rs")));
|
|
1481
|
+
assert!(paths.iter().any(|p| p.ends_with("helper.rs")));
|
|
1482
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
#[tokio::test]
|
|
1486
|
+
async fn s16_find_files_no_results() {
|
|
1487
|
+
let base = tmp("ff_none");
|
|
1488
|
+
fs::write(base.join("file.txt"), "").unwrap();
|
|
1489
|
+
let r = find_files(&json!({"root": base.to_str().unwrap(), "query": "zzznomatch"}).to_string()).await.unwrap();
|
|
1490
|
+
assert!(is_ok(&r));
|
|
1491
|
+
assert_eq!(r["results"].as_array().unwrap().len(), 0);
|
|
1492
|
+
assert_eq!(r["truncated"], json!(false));
|
|
1493
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
#[tokio::test]
|
|
1497
|
+
async fn s16_find_files_invalid_root() {
|
|
1498
|
+
let r = find_files(&json!({"root": "/tmp/anveesa_no_such_dir_xyz", "query": "file"}).to_string()).await;
|
|
1499
|
+
assert!(r.is_err());
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
#[tokio::test]
|
|
1503
|
+
async fn s16_find_files_skips_node_modules() {
|
|
1504
|
+
let base = tmp("ff_skip");
|
|
1505
|
+
fs::create_dir_all(base.join("node_modules/lodash")).unwrap();
|
|
1506
|
+
fs::write(base.join("node_modules/lodash/index.js"), "").unwrap();
|
|
1507
|
+
fs::write(base.join("index.js"), "").unwrap();
|
|
1508
|
+
let r = find_files(&json!({"root": base.to_str().unwrap(), "query": "index.js"}).to_string()).await.unwrap();
|
|
1509
|
+
let results = r["results"].as_array().unwrap();
|
|
1510
|
+
// Only the root-level index.js, not the one inside node_modules
|
|
1511
|
+
assert_eq!(results.len(), 1);
|
|
1512
|
+
assert!(results[0]["path"].as_str().unwrap().ends_with("index.js"));
|
|
1513
|
+
assert!(!results[0]["path"].as_str().unwrap().contains("node_modules"));
|
|
1514
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
#[tokio::test]
|
|
1518
|
+
async fn s16_find_files_kind_field() {
|
|
1519
|
+
// Results include a "kind" field ("file" or "dir") (6)
|
|
1520
|
+
let base = tmp("ff_kind");
|
|
1521
|
+
fs::write(base.join("myfile.txt"), "").unwrap();
|
|
1522
|
+
fs::create_dir(base.join("mydir")).unwrap();
|
|
1523
|
+
let r = find_files(&json!({"root": base.to_str().unwrap(), "query": "my"}).to_string()).await.unwrap();
|
|
1524
|
+
let results = r["results"].as_array().unwrap();
|
|
1525
|
+
assert_eq!(results.len(), 2);
|
|
1526
|
+
let file_entry = results.iter().find(|e| e["path"].as_str().unwrap().ends_with("myfile.txt")).unwrap();
|
|
1527
|
+
let dir_entry = results.iter().find(|e| e["path"].as_str().unwrap().ends_with("mydir")).unwrap();
|
|
1528
|
+
assert_eq!(file_entry["kind"], json!("file"));
|
|
1529
|
+
assert_eq!(dir_entry["kind"], json!("dir"));
|
|
1530
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
#[tokio::test]
|
|
1534
|
+
async fn s16_find_files_truncation() {
|
|
1535
|
+
// Create MAX_SEARCH_RESULTS + 10 matching files, verify truncated=true (5)
|
|
1536
|
+
let base = tmp("ff_trunc");
|
|
1537
|
+
for i in 0..(MAX_SEARCH_RESULTS + 10) {
|
|
1538
|
+
fs::write(base.join(format!("match_{i:04}.txt")), "").unwrap();
|
|
1539
|
+
}
|
|
1540
|
+
let r = find_files(&json!({"root": base.to_str().unwrap(), "query": "match"}).to_string()).await.unwrap();
|
|
1541
|
+
assert!(is_ok(&r));
|
|
1542
|
+
assert_eq!(r["truncated"], json!(true));
|
|
1543
|
+
assert_eq!(r["results"].as_array().unwrap().len(), MAX_SEARCH_RESULTS);
|
|
1544
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1548
|
+
// Section 17 — search_text (90 assertions)
|
|
1549
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1550
|
+
|
|
1551
|
+
#[tokio::test]
|
|
1552
|
+
async fn s17_search_text_basic() {
|
|
1553
|
+
let base = tmp("st_basic");
|
|
1554
|
+
fs::write(base.join("hello.txt"), "hello world\ngoodbye world\nhello again").unwrap();
|
|
1555
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "hello"}).to_string()).await.unwrap();
|
|
1556
|
+
assert!(is_ok(&r));
|
|
1557
|
+
assert_eq!(r["truncated"], json!(false));
|
|
1558
|
+
let results = r["results"].as_array().unwrap();
|
|
1559
|
+
// Two matches: line 1 and line 3
|
|
1560
|
+
assert_eq!(results.len(), 2);
|
|
1561
|
+
assert_eq!(results[0]["line"], json!(1));
|
|
1562
|
+
assert_eq!(results[1]["line"], json!(3));
|
|
1563
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
#[tokio::test]
|
|
1567
|
+
async fn s17_search_text_case_insensitive() {
|
|
1568
|
+
let base = tmp("st_case");
|
|
1569
|
+
fs::write(base.join("case.txt"), "Hello\nhELLO\nHELLO\nhello").unwrap();
|
|
1570
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "hello"}).to_string()).await.unwrap();
|
|
1571
|
+
let results = r["results"].as_array().unwrap();
|
|
1572
|
+
assert_eq!(results.len(), 4);
|
|
1573
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
#[tokio::test]
|
|
1577
|
+
async fn s17_search_text_multiple_files() {
|
|
1578
|
+
let base = tmp("st_multi");
|
|
1579
|
+
fs::write(base.join("a.txt"), "needle in a").unwrap();
|
|
1580
|
+
fs::write(base.join("b.txt"), "no match here").unwrap();
|
|
1581
|
+
fs::write(base.join("c.txt"), "another needle").unwrap();
|
|
1582
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "needle"}).to_string()).await.unwrap();
|
|
1583
|
+
assert!(is_ok(&r));
|
|
1584
|
+
let results = r["results"].as_array().unwrap();
|
|
1585
|
+
assert_eq!(results.len(), 2);
|
|
1586
|
+
let paths: Vec<&str> = results.iter().map(|e| e["path"].as_str().unwrap()).collect();
|
|
1587
|
+
assert!(paths.iter().any(|p| p.ends_with("a.txt")));
|
|
1588
|
+
assert!(paths.iter().any(|p| p.ends_with("c.txt")));
|
|
1589
|
+
assert!(!paths.iter().any(|p| p.ends_with("b.txt")));
|
|
1590
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
#[tokio::test]
|
|
1594
|
+
async fn s17_search_text_no_results() {
|
|
1595
|
+
let base = tmp("st_none");
|
|
1596
|
+
fs::write(base.join("file.txt"), "content without the thing").unwrap();
|
|
1597
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "zzz_not_present"}).to_string()).await.unwrap();
|
|
1598
|
+
assert!(is_ok(&r));
|
|
1599
|
+
assert_eq!(r["results"].as_array().unwrap().len(), 0);
|
|
1600
|
+
assert_eq!(r["truncated"], json!(false));
|
|
1601
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
#[tokio::test]
|
|
1605
|
+
async fn s17_search_text_sensitive_file_skipped() {
|
|
1606
|
+
let base = tmp("st_sens");
|
|
1607
|
+
// A .env file containing the query must be skipped
|
|
1608
|
+
let env_path = base.join(".env");
|
|
1609
|
+
fs::write(&env_path, "NEEDLE=value").unwrap();
|
|
1610
|
+
fs::write(base.join("normal.txt"), "needle in normal file").unwrap();
|
|
1611
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "needle"}).to_string()).await.unwrap();
|
|
1612
|
+
let results = r["results"].as_array().unwrap();
|
|
1613
|
+
// Only normal.txt should appear
|
|
1614
|
+
assert_eq!(results.len(), 1);
|
|
1615
|
+
assert!(results[0]["path"].as_str().unwrap().ends_with("normal.txt"));
|
|
1616
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
#[tokio::test]
|
|
1620
|
+
async fn s17_search_text_all_occurrences_in_file() {
|
|
1621
|
+
// The new behaviour returns ALL matching lines, not just the first (7)
|
|
1622
|
+
let base = tmp("st_allmatches");
|
|
1623
|
+
let content = "TODO: first\nsome other line\nTODO: second\nand another\nTODO: third\n";
|
|
1624
|
+
fs::write(base.join("tasks.txt"), content).unwrap();
|
|
1625
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "todo"}).to_string()).await.unwrap();
|
|
1626
|
+
let results = r["results"].as_array().unwrap();
|
|
1627
|
+
// All three TODO lines returned
|
|
1628
|
+
assert_eq!(results.len(), 3);
|
|
1629
|
+
assert_eq!(results[0]["line"], json!(1));
|
|
1630
|
+
assert_eq!(results[1]["line"], json!(3));
|
|
1631
|
+
assert_eq!(results[2]["line"], json!(5));
|
|
1632
|
+
let previews: Vec<&str> = results.iter().map(|r| r["preview"].as_str().unwrap()).collect();
|
|
1633
|
+
assert!(previews[0].contains("first"));
|
|
1634
|
+
assert!(previews[1].contains("second"));
|
|
1635
|
+
assert!(previews[2].contains("third"));
|
|
1636
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
#[tokio::test]
|
|
1640
|
+
async fn s17_search_text_preview_trimmed() {
|
|
1641
|
+
// Preview should be trimmed (4)
|
|
1642
|
+
let base = tmp("st_trim");
|
|
1643
|
+
fs::write(base.join("padded.txt"), " MATCH with spaces \n").unwrap();
|
|
1644
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "match"}).to_string()).await.unwrap();
|
|
1645
|
+
let results = r["results"].as_array().unwrap();
|
|
1646
|
+
assert_eq!(results.len(), 1);
|
|
1647
|
+
let preview = results[0]["preview"].as_str().unwrap();
|
|
1648
|
+
assert!(!preview.starts_with(' '));
|
|
1649
|
+
assert!(!preview.ends_with(' '));
|
|
1650
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
#[tokio::test]
|
|
1654
|
+
async fn s17_search_text_error_invalid_root() {
|
|
1655
|
+
let r = search_text(&json!({"root": "/tmp/anveesa_no_such_xyz", "query": "foo"}).to_string()).await;
|
|
1656
|
+
assert!(r.is_err());
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
#[tokio::test]
|
|
1660
|
+
async fn s17_search_text_error_empty_query() {
|
|
1661
|
+
let base = tmp("st_emptyq");
|
|
1662
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": ""}).to_string()).await;
|
|
1663
|
+
assert!(r.is_err());
|
|
1664
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
#[tokio::test]
|
|
1668
|
+
async fn s17_search_text_skips_target_dir() {
|
|
1669
|
+
// Files inside a "target" directory must be skipped by the walk (5)
|
|
1670
|
+
let base = tmp("st_skip_target");
|
|
1671
|
+
fs::create_dir_all(base.join("target/debug")).unwrap();
|
|
1672
|
+
fs::write(base.join("target/debug/binary"), "FINDME inside target").unwrap();
|
|
1673
|
+
fs::write(base.join("src.txt"), "FINDME in src").unwrap();
|
|
1674
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "findme"}).to_string()).await.unwrap();
|
|
1675
|
+
let results = r["results"].as_array().unwrap();
|
|
1676
|
+
assert_eq!(results.len(), 1);
|
|
1677
|
+
assert!(results[0]["path"].as_str().unwrap().ends_with("src.txt"));
|
|
1678
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
#[tokio::test]
|
|
1682
|
+
async fn s17_search_text_truncation() {
|
|
1683
|
+
// Enough files/matches to trigger MAX_SEARCH_RESULTS cap → truncated=true (5)
|
|
1684
|
+
let base = tmp("st_trunc");
|
|
1685
|
+
for i in 0..(MAX_SEARCH_RESULTS + 10) {
|
|
1686
|
+
fs::write(base.join(format!("f{i:04}.txt")), "needle on this line").unwrap();
|
|
1687
|
+
}
|
|
1688
|
+
let r = search_text(&json!({"root": base.to_str().unwrap(), "query": "needle"}).to_string()).await.unwrap();
|
|
1689
|
+
assert!(is_ok(&r));
|
|
1690
|
+
assert_eq!(r["truncated"], json!(true));
|
|
1691
|
+
assert_eq!(r["results"].as_array().unwrap().len(), MAX_SEARCH_RESULTS);
|
|
1692
|
+
fs::remove_dir_all(&base).unwrap();
|
|
1693
|
+
}
|