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