1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use nils_common::env as shared_env;
5
6use crate::{Result, cli_util};
7
8pub fn find_repo_root(start_dir: &Path) -> Result<PathBuf> {
9 let mut dir = start_dir;
10 loop {
11 if dir.join(".git").exists() {
12 return Ok(dir.to_path_buf());
13 }
14 match dir.parent() {
15 Some(parent) if parent != dir => dir = parent,
16 _ => anyhow::bail!("Must run inside a git work tree"),
17 }
18 }
19}
20
21pub fn resolve_path_from_repo_root(repo_root: &Path, raw: &str) -> PathBuf {
22 let raw = raw.trim();
23 let path = Path::new(raw);
24 if path.is_absolute() {
25 path.to_path_buf()
26 } else {
27 repo_root.join(path)
28 }
29}
30
31pub fn resolve_rest_base_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
32 let env_value = env_value.trim();
33 if env_value.starts_with("http://") || env_value.starts_with("https://") {
34 return Ok(env_value.to_string());
35 }
36
37 let endpoints_env = setup_dir.join("endpoints.env");
38 let endpoints_local = setup_dir.join("endpoints.local.env");
39 let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
40 vec![&endpoints_env, &endpoints_local]
41 } else {
42 Vec::new()
43 };
44 if endpoints_files.is_empty() {
45 anyhow::bail!("endpoints.env not found (expected under setup/rest/)");
46 }
47
48 let env_key = crate::env_file::normalize_env_key(env_value);
49 let key = format!("REST_URL_{env_key}");
50 let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
51 let Some(found) = found else {
52 let mut available = cli_util::list_available_suffixes(&endpoints_env, "REST_URL_");
53 if endpoints_local.is_file() {
54 available.extend(cli_util::list_available_suffixes(
55 &endpoints_local,
56 "REST_URL_",
57 ));
58 available.sort();
59 available.dedup();
60 }
61 let available = if available.is_empty() {
62 "none".to_string()
63 } else {
64 available.join(" ")
65 };
66 anyhow::bail!("Unknown env '{env_value}' (available: {available})");
67 };
68
69 Ok(found)
70}
71
72pub fn resolve_gql_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
73 let env_value = env_value.trim();
74 if env_value.starts_with("http://") || env_value.starts_with("https://") {
75 return Ok(env_value.to_string());
76 }
77
78 let endpoints_env = setup_dir.join("endpoints.env");
79 let endpoints_local = setup_dir.join("endpoints.local.env");
80 let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
81 vec![&endpoints_env, &endpoints_local]
82 } else {
83 Vec::new()
84 };
85 if endpoints_files.is_empty() {
86 anyhow::bail!("endpoints.env not found (expected under setup/graphql/)");
87 }
88
89 let env_key = crate::env_file::normalize_env_key(env_value);
90 let key = format!("GQL_URL_{env_key}");
91 let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
92 let Some(found) = found else {
93 let mut available = cli_util::list_available_suffixes(&endpoints_env, "GQL_URL_");
94 if endpoints_local.is_file() {
95 available.extend(cli_util::list_available_suffixes(
96 &endpoints_local,
97 "GQL_URL_",
98 ));
99 available.sort();
100 available.dedup();
101 }
102 let available = if available.is_empty() {
103 "none".to_string()
104 } else {
105 available.join(" ")
106 };
107 anyhow::bail!("Unknown env '{env_value}' (available: {available})");
108 };
109
110 Ok(found)
111}
112
113pub fn resolve_grpc_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
114 let env_value = env_value.trim();
115 if env_value.starts_with("http://") || env_value.starts_with("https://") {
116 return Ok(env_value.to_string());
117 }
118
119 let endpoints_env = setup_dir.join("endpoints.env");
120 let endpoints_local = setup_dir.join("endpoints.local.env");
121 let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
122 vec![&endpoints_env, &endpoints_local]
123 } else {
124 Vec::new()
125 };
126 if endpoints_files.is_empty() {
127 anyhow::bail!("endpoints.env not found (expected under setup/grpc/)");
128 }
129
130 let env_key = crate::env_file::normalize_env_key(env_value);
131 let key = format!("GRPC_URL_{env_key}");
132 let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
133 let Some(found) = found else {
134 let mut available = cli_util::list_available_suffixes(&endpoints_env, "GRPC_URL_");
135 if endpoints_local.is_file() {
136 available.extend(cli_util::list_available_suffixes(
137 &endpoints_local,
138 "GRPC_URL_",
139 ));
140 available.sort();
141 available.dedup();
142 }
143 let available = if available.is_empty() {
144 "none".to_string()
145 } else {
146 available.join(" ")
147 };
148 anyhow::bail!("Unknown env '{env_value}' (available: {available})");
149 };
150
151 Ok(found)
152}
153
154pub fn resolve_ws_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
155 let env_value = env_value.trim();
156 if env_value.starts_with("ws://") || env_value.starts_with("wss://") {
157 return Ok(env_value.to_string());
158 }
159
160 let endpoints_env = setup_dir.join("endpoints.env");
161 let endpoints_local = setup_dir.join("endpoints.local.env");
162 let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
163 vec![&endpoints_env, &endpoints_local]
164 } else {
165 Vec::new()
166 };
167 if endpoints_files.is_empty() {
168 anyhow::bail!("endpoints.env not found (expected under setup/websocket/)");
169 }
170
171 let env_key = crate::env_file::normalize_env_key(env_value);
172 let key = format!("WS_URL_{env_key}");
173 let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
174 let Some(found) = found else {
175 let mut available = cli_util::list_available_suffixes(&endpoints_env, "WS_URL_");
176 if endpoints_local.is_file() {
177 available.extend(cli_util::list_available_suffixes(
178 &endpoints_local,
179 "WS_URL_",
180 ));
181 available.sort();
182 available.dedup();
183 }
184 let available = if available.is_empty() {
185 "none".to_string()
186 } else {
187 available.join(" ")
188 };
189 anyhow::bail!("Unknown env '{env_value}' (available: {available})");
190 };
191
192 Ok(found)
193}
194
195#[derive(Debug, Clone)]
196pub struct SuiteSelection {
197 pub suite_key: String,
198 pub suite_path: PathBuf,
199}
200
201fn normalize_suite_key(raw: &str) -> String {
202 let key = raw.trim();
203 let key = key.strip_suffix(".suite.json").unwrap_or(key);
204 let key = key.strip_suffix(".json").unwrap_or(key);
205 key.trim().to_string()
206}
207
208pub fn resolve_suite_selection(
209 repo_root: &Path,
210 suite: Option<&str>,
211 suite_file: Option<&str>,
212) -> Result<SuiteSelection> {
213 if suite.is_some() && suite_file.is_some() {
214 anyhow::bail!("Use only one of --suite or --suite-file");
215 }
216 let suite = suite.and_then(cli_util::trim_non_empty);
217 let suite_file = suite_file.and_then(cli_util::trim_non_empty);
218 if suite.is_none() && suite_file.is_none() {
219 anyhow::bail!("Missing suite (use --suite or --suite-file)");
220 }
221
222 if let Some(suite_file) = suite_file {
223 let suite_path = resolve_path_from_repo_root(repo_root, &suite_file);
224 let suite_path = std::fs::canonicalize(&suite_path).unwrap_or(suite_path);
225 if !suite_path.is_file() {
226 anyhow::bail!("Suite file not found: {}", suite_path.to_string_lossy());
227 }
228 let suite_key = suite_path
229 .file_name()
230 .and_then(|s| s.to_str())
231 .unwrap_or("suite")
232 .to_string();
233 return Ok(SuiteSelection {
234 suite_key,
235 suite_path,
236 });
237 }
238
239 let suite_key = normalize_suite_key(&suite.unwrap_or_default());
240 if suite_key.is_empty() {
241 anyhow::bail!("Missing suite (use --suite or --suite-file)");
242 }
243
244 let suites_dir_override =
245 shared_env::env_non_empty("API_TEST_SUITES_DIR").and_then(|s| cli_util::trim_non_empty(&s));
246
247 let candidate = if let Some(dir) = suites_dir_override {
248 let abs = resolve_path_from_repo_root(repo_root, &dir);
249 abs.join(format!("{suite_key}.suite.json"))
250 } else {
251 let mut p = repo_root
252 .join("tests/api/suites")
253 .join(format!("{suite_key}.suite.json"));
254 if !p.is_file() {
255 p = repo_root
256 .join("setup/api/suites")
257 .join(format!("{suite_key}.suite.json"));
258 }
259 p
260 };
261
262 let suite_path = std::fs::canonicalize(&candidate).unwrap_or(candidate);
263 if !suite_path.is_file() {
264 anyhow::bail!("Suite file not found: {}", suite_path.to_string_lossy());
265 }
266
267 Ok(SuiteSelection {
268 suite_key,
269 suite_path,
270 })
271}
272
273pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
274 let Some(parent) = path.parent() else {
275 anyhow::bail!("invalid path: {}", path.display());
276 };
277 std::fs::create_dir_all(parent)
278 .with_context(|| format!("create directory: {}", parent.display()))?;
279 std::fs::write(path, contents).with_context(|| format!("write file: {}", path.display()))?;
280 Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use nils_test_support::{EnvGuard, GlobalStateLock};
287
288 use tempfile::TempDir;
289
290 fn write(path: &Path, contents: &str) {
291 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
292 std::fs::write(path, contents).expect("write");
293 }
294
295 #[test]
296 fn suite_find_repo_root_success_and_failure() {
297 let tmp = TempDir::new().unwrap();
298 let root = tmp.path();
299 std::fs::create_dir_all(root.join(".git")).unwrap();
300 std::fs::create_dir_all(root.join("a/b/c")).unwrap();
301
302 let found = find_repo_root(&root.join("a/b/c")).unwrap();
303 assert_eq!(found, root);
304
305 let tmp2 = TempDir::new().unwrap();
306 let err = find_repo_root(tmp2.path()).unwrap_err();
307 assert!(format!("{err:#}").contains("Must run inside a git work tree"));
308 }
309
310 #[test]
311 fn suite_resolve_path_from_repo_root_handles_absolute_and_relative() {
312 let tmp = TempDir::new().unwrap();
313 let root = tmp.path();
314
315 assert_eq!(
316 resolve_path_from_repo_root(root, " setup/rest "),
317 root.join("setup/rest")
318 );
319 assert_eq!(
320 resolve_path_from_repo_root(root, "/abs/path"),
321 PathBuf::from("/abs/path")
322 );
323 }
324
325 #[cfg(windows)]
326 #[test]
327 fn suite_resolve_path_from_repo_root_handles_windows_absolute_paths() {
328 let tmp = TempDir::new().unwrap();
329 let root = tmp.path();
330
331 let drive_abs = r"C:\abs\path";
332 assert_eq!(
333 resolve_path_from_repo_root(root, drive_abs),
334 PathBuf::from(drive_abs)
335 );
336
337 let unc_abs = r"\\server\share\suite.yaml";
338 assert_eq!(
339 resolve_path_from_repo_root(root, unc_abs),
340 PathBuf::from(unc_abs)
341 );
342 }
343
344 #[test]
345 fn suite_resolve_rest_base_url_from_url_and_env_files() {
346 let tmp = TempDir::new().unwrap();
347 let setup_dir = tmp.path().join("setup/rest");
348
349 assert_eq!(
350 resolve_rest_base_url_for_env(&setup_dir, "https://example.test").unwrap(),
351 "https://example.test"
352 );
353
354 let err = resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap_err();
355 assert!(format!("{err:#}").contains("endpoints.env not found"));
356 assert!(format!("{err:#}").contains("setup/rest"));
357
358 let endpoints_env = setup_dir.join("endpoints.env");
359 let endpoints_local = setup_dir.join("endpoints.local.env");
360 write(&endpoints_env, "REST_URL_PROD=http://base.test\n");
361 write(
362 &endpoints_local,
363 "REST_URL_PROD=http://local.test\nREST_URL_LOCAL=http://x\n",
364 );
365
366 assert_eq!(
367 resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap(),
368 "http://local.test"
369 );
370
371 let err = resolve_rest_base_url_for_env(&setup_dir, "nope").unwrap_err();
372 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
373 assert!(format!("{err:#}").contains("available: local prod"));
374 }
375
376 #[test]
377 fn suite_resolve_gql_url_from_url_and_env_files() {
378 let tmp = TempDir::new().unwrap();
379 let setup_dir = tmp.path().join("setup/graphql");
380
381 assert_eq!(
382 resolve_gql_url_for_env(&setup_dir, "http://example.test/graphql").unwrap(),
383 "http://example.test/graphql"
384 );
385
386 let err = resolve_gql_url_for_env(&setup_dir, "prod").unwrap_err();
387 assert!(format!("{err:#}").contains("endpoints.env not found"));
388 assert!(format!("{err:#}").contains("setup/graphql"));
389
390 let endpoints_env = setup_dir.join("endpoints.env");
391 let endpoints_local = setup_dir.join("endpoints.local.env");
392 write(&endpoints_env, "GQL_URL_PROD=http://base.test/graphql\n");
393 write(
394 &endpoints_local,
395 "GQL_URL_PROD=http://local.test/graphql\nGQL_URL_LOCAL=http://x\n",
396 );
397
398 assert_eq!(
399 resolve_gql_url_for_env(&setup_dir, "prod").unwrap(),
400 "http://local.test/graphql"
401 );
402
403 let err = resolve_gql_url_for_env(&setup_dir, "nope").unwrap_err();
404 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
405 assert!(format!("{err:#}").contains("available: local prod"));
406 }
407
408 #[test]
409 fn suite_resolve_grpc_url_from_url_and_env_files() {
410 let tmp = TempDir::new().unwrap();
411 let setup_dir = tmp.path().join("setup/grpc");
412
413 assert_eq!(
414 resolve_grpc_url_for_env(&setup_dir, "https://grpc.test:8443").unwrap(),
415 "https://grpc.test:8443"
416 );
417
418 let err = resolve_grpc_url_for_env(&setup_dir, "prod").unwrap_err();
419 assert!(format!("{err:#}").contains("endpoints.env not found"));
420 assert!(format!("{err:#}").contains("setup/grpc"));
421
422 let endpoints_env = setup_dir.join("endpoints.env");
423 let endpoints_local = setup_dir.join("endpoints.local.env");
424 write(&endpoints_env, "GRPC_URL_PROD=grpc.prod:443\n");
425 write(
426 &endpoints_local,
427 "GRPC_URL_PROD=grpc.local:443\nGRPC_URL_LOCAL=127.0.0.1:50051\n",
428 );
429
430 assert_eq!(
431 resolve_grpc_url_for_env(&setup_dir, "prod").unwrap(),
432 "grpc.local:443"
433 );
434
435 let err = resolve_grpc_url_for_env(&setup_dir, "nope").unwrap_err();
436 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
437 assert!(format!("{err:#}").contains("available: local prod"));
438 }
439
440 #[test]
441 fn suite_resolve_ws_url_from_url_and_env_files() {
442 let tmp = TempDir::new().unwrap();
443 let setup_dir = tmp.path().join("setup/websocket");
444
445 assert_eq!(
446 resolve_ws_url_for_env(&setup_dir, "wss://socket.test/ws").unwrap(),
447 "wss://socket.test/ws"
448 );
449
450 let err = resolve_ws_url_for_env(&setup_dir, "prod").unwrap_err();
451 assert!(format!("{err:#}").contains("endpoints.env not found"));
452 assert!(format!("{err:#}").contains("setup/websocket"));
453
454 let endpoints_env = setup_dir.join("endpoints.env");
455 let endpoints_local = setup_dir.join("endpoints.local.env");
456 write(&endpoints_env, "WS_URL_PROD=ws://socket.prod/ws\n");
457 write(
458 &endpoints_local,
459 "WS_URL_PROD=ws://socket.local/ws\nWS_URL_LOCAL=ws://127.0.0.1:9001/ws\n",
460 );
461
462 assert_eq!(
463 resolve_ws_url_for_env(&setup_dir, "prod").unwrap(),
464 "ws://socket.local/ws"
465 );
466
467 let err = resolve_ws_url_for_env(&setup_dir, "nope").unwrap_err();
468 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
469 assert!(format!("{err:#}").contains("available: local prod"));
470 }
471
472 #[test]
473 fn suite_write_file_creates_parent_dirs() {
474 let tmp = TempDir::new().unwrap();
475 let path = tmp.path().join("a/b/c.txt");
476 write_file(&path, b"hello").unwrap();
477 assert!(path.is_file());
478 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
479 }
480
481 #[test]
482 fn suite_resolve_resolves_suite_name_under_tests_dir() {
483 let lock = GlobalStateLock::new();
484 let _guard = EnvGuard::remove(&lock, "API_TEST_SUITES_DIR");
485
486 let tmp = TempDir::new().unwrap();
487 let root = tmp.path();
488 std::fs::create_dir_all(root.join(".git")).unwrap();
489 std::fs::create_dir_all(root.join("tests/api/suites")).unwrap();
490 std::fs::write(
491 root.join("tests/api/suites/smoke.suite.json"),
492 br#"{"version":1,"cases":[]}"#,
493 )
494 .unwrap();
495
496 let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
497 assert!(
498 sel.suite_path
499 .ends_with("tests/api/suites/smoke.suite.json")
500 );
501 }
502
503 #[test]
504 fn suite_resolve_resolves_suite_name_under_setup_dir() {
505 let lock = GlobalStateLock::new();
506 let _guard = EnvGuard::remove(&lock, "API_TEST_SUITES_DIR");
507
508 let tmp = TempDir::new().unwrap();
509 let root = tmp.path();
510 std::fs::create_dir_all(root.join(".git")).unwrap();
511 std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
512 std::fs::write(
513 root.join("setup/api/suites/smoke.suite.json"),
514 br#"{"version":1,"cases":[]}"#,
515 )
516 .unwrap();
517
518 let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
519 assert!(
520 sel.suite_path
521 .ends_with("setup/api/suites/smoke.suite.json")
522 );
523 }
524
525 #[test]
526 fn suite_resolve_rejects_invalid_suite_args() {
527 let tmp = TempDir::new().unwrap();
528 let root = tmp.path();
529
530 let err = resolve_suite_selection(root, Some("a"), Some("b")).unwrap_err();
531 assert!(format!("{err:#}").contains("Use only one of --suite or --suite-file"));
532
533 let err = resolve_suite_selection(root, None, None).unwrap_err();
534 assert!(format!("{err:#}").contains("Missing suite (use --suite or --suite-file)"));
535 }
536
537 #[test]
538 fn suite_resolve_resolves_suite_file_relative_to_repo_root() {
539 let tmp = TempDir::new().unwrap();
540 let root = tmp.path();
541 std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
542 std::fs::write(
543 root.join("setup/api/suites/smoke.suite.json"),
544 br#"{"version":1,"cases":[]}"#,
545 )
546 .unwrap();
547
548 let sel =
549 resolve_suite_selection(root, None, Some("setup/api/suites/smoke.suite.json")).unwrap();
550 assert_eq!(sel.suite_key, "smoke.suite.json");
551 assert!(
552 sel.suite_path
553 .ends_with("setup/api/suites/smoke.suite.json")
554 );
555 }
556}