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
153#[derive(Debug, Clone)]
154pub struct SuiteSelection {
155 pub suite_key: String,
156 pub suite_path: PathBuf,
157}
158
159fn normalize_suite_key(raw: &str) -> String {
160 let key = raw.trim();
161 let key = key.strip_suffix(".suite.json").unwrap_or(key);
162 let key = key.strip_suffix(".json").unwrap_or(key);
163 key.trim().to_string()
164}
165
166pub fn resolve_suite_selection(
167 repo_root: &Path,
168 suite: Option<&str>,
169 suite_file: Option<&str>,
170) -> Result<SuiteSelection> {
171 if suite.is_some() && suite_file.is_some() {
172 anyhow::bail!("Use only one of --suite or --suite-file");
173 }
174 let suite = suite.and_then(cli_util::trim_non_empty);
175 let suite_file = suite_file.and_then(cli_util::trim_non_empty);
176 if suite.is_none() && suite_file.is_none() {
177 anyhow::bail!("Missing suite (use --suite or --suite-file)");
178 }
179
180 if let Some(suite_file) = suite_file {
181 let suite_path = resolve_path_from_repo_root(repo_root, &suite_file);
182 let suite_path = std::fs::canonicalize(&suite_path).unwrap_or(suite_path);
183 if !suite_path.is_file() {
184 anyhow::bail!("Suite file not found: {}", suite_path.to_string_lossy());
185 }
186 let suite_key = suite_path
187 .file_name()
188 .and_then(|s| s.to_str())
189 .unwrap_or("suite")
190 .to_string();
191 return Ok(SuiteSelection {
192 suite_key,
193 suite_path,
194 });
195 }
196
197 let suite_key = normalize_suite_key(&suite.unwrap_or_default());
198 if suite_key.is_empty() {
199 anyhow::bail!("Missing suite (use --suite or --suite-file)");
200 }
201
202 let suites_dir_override = std::env::var("API_TEST_SUITES_DIR")
203 .ok()
204 .and_then(|s| cli_util::trim_non_empty(&s));
205
206 let candidate = if let Some(dir) = suites_dir_override {
207 let abs = resolve_path_from_repo_root(repo_root, &dir);
208 abs.join(format!("{suite_key}.suite.json"))
209 } else {
210 let mut p = repo_root
211 .join("tests/api/suites")
212 .join(format!("{suite_key}.suite.json"));
213 if !p.is_file() {
214 p = repo_root
215 .join("setup/api/suites")
216 .join(format!("{suite_key}.suite.json"));
217 }
218 p
219 };
220
221 let suite_path = std::fs::canonicalize(&candidate).unwrap_or(candidate);
222 if !suite_path.is_file() {
223 anyhow::bail!("Suite file not found: {}", suite_path.to_string_lossy());
224 }
225
226 Ok(SuiteSelection {
227 suite_key,
228 suite_path,
229 })
230}
231
232pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
233 let Some(parent) = path.parent() else {
234 anyhow::bail!("invalid path: {}", path.display());
235 };
236 std::fs::create_dir_all(parent)
237 .with_context(|| format!("create directory: {}", parent.display()))?;
238 std::fs::write(path, contents).with_context(|| format!("write file: {}", path.display()))?;
239 Ok(())
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 use tempfile::TempDir;
247
248 fn write(path: &Path, contents: &str) {
249 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
250 std::fs::write(path, contents).expect("write");
251 }
252
253 #[test]
254 fn suite_find_repo_root_success_and_failure() {
255 let tmp = TempDir::new().unwrap();
256 let root = tmp.path();
257 std::fs::create_dir_all(root.join(".git")).unwrap();
258 std::fs::create_dir_all(root.join("a/b/c")).unwrap();
259
260 let found = find_repo_root(&root.join("a/b/c")).unwrap();
261 assert_eq!(found, root);
262
263 let tmp2 = TempDir::new().unwrap();
264 let err = find_repo_root(tmp2.path()).unwrap_err();
265 assert!(format!("{err:#}").contains("Must run inside a git work tree"));
266 }
267
268 #[test]
269 fn suite_resolve_path_from_repo_root_handles_absolute_and_relative() {
270 let tmp = TempDir::new().unwrap();
271 let root = tmp.path();
272
273 assert_eq!(
274 resolve_path_from_repo_root(root, " setup/rest "),
275 root.join("setup/rest")
276 );
277 assert_eq!(
278 resolve_path_from_repo_root(root, "/abs/path"),
279 PathBuf::from("/abs/path")
280 );
281 }
282
283 #[cfg(windows)]
284 #[test]
285 fn suite_resolve_path_from_repo_root_handles_windows_absolute_paths() {
286 let tmp = TempDir::new().unwrap();
287 let root = tmp.path();
288
289 let drive_abs = r"C:\abs\path";
290 assert_eq!(
291 resolve_path_from_repo_root(root, drive_abs),
292 PathBuf::from(drive_abs)
293 );
294
295 let unc_abs = r"\\server\share\suite.yaml";
296 assert_eq!(
297 resolve_path_from_repo_root(root, unc_abs),
298 PathBuf::from(unc_abs)
299 );
300 }
301
302 #[test]
303 fn suite_resolve_rest_base_url_from_url_and_env_files() {
304 let tmp = TempDir::new().unwrap();
305 let setup_dir = tmp.path().join("setup/rest");
306
307 assert_eq!(
308 resolve_rest_base_url_for_env(&setup_dir, "https://example.test").unwrap(),
309 "https://example.test"
310 );
311
312 let err = resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap_err();
313 assert!(format!("{err:#}").contains("endpoints.env not found"));
314 assert!(format!("{err:#}").contains("setup/rest"));
315
316 let endpoints_env = setup_dir.join("endpoints.env");
317 let endpoints_local = setup_dir.join("endpoints.local.env");
318 write(&endpoints_env, "REST_URL_PROD=http://base.test\n");
319 write(
320 &endpoints_local,
321 "REST_URL_PROD=http://local.test\nREST_URL_LOCAL=http://x\n",
322 );
323
324 assert_eq!(
325 resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap(),
326 "http://local.test"
327 );
328
329 let err = resolve_rest_base_url_for_env(&setup_dir, "nope").unwrap_err();
330 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
331 assert!(format!("{err:#}").contains("available: local prod"));
332 }
333
334 #[test]
335 fn suite_resolve_gql_url_from_url_and_env_files() {
336 let tmp = TempDir::new().unwrap();
337 let setup_dir = tmp.path().join("setup/graphql");
338
339 assert_eq!(
340 resolve_gql_url_for_env(&setup_dir, "http://example.test/graphql").unwrap(),
341 "http://example.test/graphql"
342 );
343
344 let err = resolve_gql_url_for_env(&setup_dir, "prod").unwrap_err();
345 assert!(format!("{err:#}").contains("endpoints.env not found"));
346 assert!(format!("{err:#}").contains("setup/graphql"));
347
348 let endpoints_env = setup_dir.join("endpoints.env");
349 let endpoints_local = setup_dir.join("endpoints.local.env");
350 write(&endpoints_env, "GQL_URL_PROD=http://base.test/graphql\n");
351 write(
352 &endpoints_local,
353 "GQL_URL_PROD=http://local.test/graphql\nGQL_URL_LOCAL=http://x\n",
354 );
355
356 assert_eq!(
357 resolve_gql_url_for_env(&setup_dir, "prod").unwrap(),
358 "http://local.test/graphql"
359 );
360
361 let err = resolve_gql_url_for_env(&setup_dir, "nope").unwrap_err();
362 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
363 assert!(format!("{err:#}").contains("available: local prod"));
364 }
365
366 #[test]
367 fn suite_resolve_grpc_url_from_url_and_env_files() {
368 let tmp = TempDir::new().unwrap();
369 let setup_dir = tmp.path().join("setup/grpc");
370
371 assert_eq!(
372 resolve_grpc_url_for_env(&setup_dir, "https://grpc.test:8443").unwrap(),
373 "https://grpc.test:8443"
374 );
375
376 let err = resolve_grpc_url_for_env(&setup_dir, "prod").unwrap_err();
377 assert!(format!("{err:#}").contains("endpoints.env not found"));
378 assert!(format!("{err:#}").contains("setup/grpc"));
379
380 let endpoints_env = setup_dir.join("endpoints.env");
381 let endpoints_local = setup_dir.join("endpoints.local.env");
382 write(&endpoints_env, "GRPC_URL_PROD=grpc.prod:443\n");
383 write(
384 &endpoints_local,
385 "GRPC_URL_PROD=grpc.local:443\nGRPC_URL_LOCAL=127.0.0.1:50051\n",
386 );
387
388 assert_eq!(
389 resolve_grpc_url_for_env(&setup_dir, "prod").unwrap(),
390 "grpc.local:443"
391 );
392
393 let err = resolve_grpc_url_for_env(&setup_dir, "nope").unwrap_err();
394 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
395 assert!(format!("{err:#}").contains("available: local prod"));
396 }
397
398 #[test]
399 fn suite_write_file_creates_parent_dirs() {
400 let tmp = TempDir::new().unwrap();
401 let path = tmp.path().join("a/b/c.txt");
402 write_file(&path, b"hello").unwrap();
403 assert!(path.is_file());
404 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
405 }
406
407 #[test]
408 fn suite_resolve_resolves_suite_name_under_tests_dir() {
409 unsafe { std::env::remove_var("API_TEST_SUITES_DIR") };
411
412 let tmp = TempDir::new().unwrap();
413 let root = tmp.path();
414 std::fs::create_dir_all(root.join(".git")).unwrap();
415 std::fs::create_dir_all(root.join("tests/api/suites")).unwrap();
416 std::fs::write(
417 root.join("tests/api/suites/smoke.suite.json"),
418 br#"{"version":1,"cases":[]}"#,
419 )
420 .unwrap();
421
422 let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
423 assert!(
424 sel.suite_path
425 .ends_with("tests/api/suites/smoke.suite.json")
426 );
427 }
428
429 #[test]
430 fn suite_resolve_resolves_suite_name_under_setup_dir() {
431 unsafe { std::env::remove_var("API_TEST_SUITES_DIR") };
433
434 let tmp = TempDir::new().unwrap();
435 let root = tmp.path();
436 std::fs::create_dir_all(root.join(".git")).unwrap();
437 std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
438 std::fs::write(
439 root.join("setup/api/suites/smoke.suite.json"),
440 br#"{"version":1,"cases":[]}"#,
441 )
442 .unwrap();
443
444 let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
445 assert!(
446 sel.suite_path
447 .ends_with("setup/api/suites/smoke.suite.json")
448 );
449 }
450
451 #[test]
452 fn suite_resolve_rejects_invalid_suite_args() {
453 let tmp = TempDir::new().unwrap();
454 let root = tmp.path();
455
456 let err = resolve_suite_selection(root, Some("a"), Some("b")).unwrap_err();
457 assert!(format!("{err:#}").contains("Use only one of --suite or --suite-file"));
458
459 let err = resolve_suite_selection(root, None, None).unwrap_err();
460 assert!(format!("{err:#}").contains("Missing suite (use --suite or --suite-file)"));
461 }
462
463 #[test]
464 fn suite_resolve_resolves_suite_file_relative_to_repo_root() {
465 let tmp = TempDir::new().unwrap();
466 let root = tmp.path();
467 std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
468 std::fs::write(
469 root.join("setup/api/suites/smoke.suite.json"),
470 br#"{"version":1,"cases":[]}"#,
471 )
472 .unwrap();
473
474 let sel =
475 resolve_suite_selection(root, None, Some("setup/api/suites/smoke.suite.json")).unwrap();
476 assert_eq!(sel.suite_key, "smoke.suite.json");
477 assert!(
478 sel.suite_path
479 .ends_with("setup/api/suites/smoke.suite.json")
480 );
481 }
482}