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
112#[derive(Debug, Clone)]
113pub struct SuiteSelection {
114 pub suite_key: String,
115 pub suite_path: PathBuf,
116}
117
118fn normalize_suite_key(raw: &str) -> String {
119 let key = raw.trim();
120 let key = key.strip_suffix(".suite.json").unwrap_or(key);
121 let key = key.strip_suffix(".json").unwrap_or(key);
122 key.trim().to_string()
123}
124
125pub fn resolve_suite_selection(
126 repo_root: &Path,
127 suite: Option<&str>,
128 suite_file: Option<&str>,
129) -> Result<SuiteSelection> {
130 if suite.is_some() && suite_file.is_some() {
131 anyhow::bail!("Use only one of --suite or --suite-file");
132 }
133 let suite = suite.and_then(cli_util::trim_non_empty);
134 let suite_file = suite_file.and_then(cli_util::trim_non_empty);
135 if suite.is_none() && suite_file.is_none() {
136 anyhow::bail!("Missing suite (use --suite or --suite-file)");
137 }
138
139 if let Some(suite_file) = suite_file {
140 let suite_path = resolve_path_from_repo_root(repo_root, &suite_file);
141 let suite_path = std::fs::canonicalize(&suite_path).unwrap_or(suite_path);
142 if !suite_path.is_file() {
143 anyhow::bail!("Suite file not found: {}", suite_path.to_string_lossy());
144 }
145 let suite_key = suite_path
146 .file_name()
147 .and_then(|s| s.to_str())
148 .unwrap_or("suite")
149 .to_string();
150 return Ok(SuiteSelection {
151 suite_key,
152 suite_path,
153 });
154 }
155
156 let suite_key = normalize_suite_key(&suite.unwrap_or_default());
157 if suite_key.is_empty() {
158 anyhow::bail!("Missing suite (use --suite or --suite-file)");
159 }
160
161 let suites_dir_override = std::env::var("API_TEST_SUITES_DIR")
162 .ok()
163 .and_then(|s| cli_util::trim_non_empty(&s));
164
165 let candidate = if let Some(dir) = suites_dir_override {
166 let abs = resolve_path_from_repo_root(repo_root, &dir);
167 abs.join(format!("{suite_key}.suite.json"))
168 } else {
169 let mut p = repo_root
170 .join("tests/api/suites")
171 .join(format!("{suite_key}.suite.json"));
172 if !p.is_file() {
173 p = repo_root
174 .join("setup/api/suites")
175 .join(format!("{suite_key}.suite.json"));
176 }
177 p
178 };
179
180 let suite_path = std::fs::canonicalize(&candidate).unwrap_or(candidate);
181 if !suite_path.is_file() {
182 anyhow::bail!("Suite file not found: {}", suite_path.to_string_lossy());
183 }
184
185 Ok(SuiteSelection {
186 suite_key,
187 suite_path,
188 })
189}
190
191pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
192 let Some(parent) = path.parent() else {
193 anyhow::bail!("invalid path: {}", path.display());
194 };
195 std::fs::create_dir_all(parent)
196 .with_context(|| format!("create directory: {}", parent.display()))?;
197 std::fs::write(path, contents).with_context(|| format!("write file: {}", path.display()))?;
198 Ok(())
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 use tempfile::TempDir;
206
207 fn write(path: &Path, contents: &str) {
208 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
209 std::fs::write(path, contents).expect("write");
210 }
211
212 #[test]
213 fn suite_find_repo_root_success_and_failure() {
214 let tmp = TempDir::new().unwrap();
215 let root = tmp.path();
216 std::fs::create_dir_all(root.join(".git")).unwrap();
217 std::fs::create_dir_all(root.join("a/b/c")).unwrap();
218
219 let found = find_repo_root(&root.join("a/b/c")).unwrap();
220 assert_eq!(found, root);
221
222 let tmp2 = TempDir::new().unwrap();
223 let err = find_repo_root(tmp2.path()).unwrap_err();
224 assert!(format!("{err:#}").contains("Must run inside a git work tree"));
225 }
226
227 #[test]
228 fn suite_resolve_path_from_repo_root_handles_absolute_and_relative() {
229 let tmp = TempDir::new().unwrap();
230 let root = tmp.path();
231
232 assert_eq!(
233 resolve_path_from_repo_root(root, " setup/rest "),
234 root.join("setup/rest")
235 );
236 assert_eq!(
237 resolve_path_from_repo_root(root, "/abs/path"),
238 PathBuf::from("/abs/path")
239 );
240 }
241
242 #[cfg(windows)]
243 #[test]
244 fn suite_resolve_path_from_repo_root_handles_windows_absolute_paths() {
245 let tmp = TempDir::new().unwrap();
246 let root = tmp.path();
247
248 let drive_abs = r"C:\abs\path";
249 assert_eq!(
250 resolve_path_from_repo_root(root, drive_abs),
251 PathBuf::from(drive_abs)
252 );
253
254 let unc_abs = r"\\server\share\suite.yaml";
255 assert_eq!(
256 resolve_path_from_repo_root(root, unc_abs),
257 PathBuf::from(unc_abs)
258 );
259 }
260
261 #[test]
262 fn suite_resolve_rest_base_url_from_url_and_env_files() {
263 let tmp = TempDir::new().unwrap();
264 let setup_dir = tmp.path().join("setup/rest");
265
266 assert_eq!(
267 resolve_rest_base_url_for_env(&setup_dir, "https://example.test").unwrap(),
268 "https://example.test"
269 );
270
271 let err = resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap_err();
272 assert!(format!("{err:#}").contains("endpoints.env not found"));
273 assert!(format!("{err:#}").contains("setup/rest"));
274
275 let endpoints_env = setup_dir.join("endpoints.env");
276 let endpoints_local = setup_dir.join("endpoints.local.env");
277 write(&endpoints_env, "REST_URL_PROD=http://base.test\n");
278 write(
279 &endpoints_local,
280 "REST_URL_PROD=http://local.test\nREST_URL_LOCAL=http://x\n",
281 );
282
283 assert_eq!(
284 resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap(),
285 "http://local.test"
286 );
287
288 let err = resolve_rest_base_url_for_env(&setup_dir, "nope").unwrap_err();
289 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
290 assert!(format!("{err:#}").contains("available: local prod"));
291 }
292
293 #[test]
294 fn suite_resolve_gql_url_from_url_and_env_files() {
295 let tmp = TempDir::new().unwrap();
296 let setup_dir = tmp.path().join("setup/graphql");
297
298 assert_eq!(
299 resolve_gql_url_for_env(&setup_dir, "http://example.test/graphql").unwrap(),
300 "http://example.test/graphql"
301 );
302
303 let err = resolve_gql_url_for_env(&setup_dir, "prod").unwrap_err();
304 assert!(format!("{err:#}").contains("endpoints.env not found"));
305 assert!(format!("{err:#}").contains("setup/graphql"));
306
307 let endpoints_env = setup_dir.join("endpoints.env");
308 let endpoints_local = setup_dir.join("endpoints.local.env");
309 write(&endpoints_env, "GQL_URL_PROD=http://base.test/graphql\n");
310 write(
311 &endpoints_local,
312 "GQL_URL_PROD=http://local.test/graphql\nGQL_URL_LOCAL=http://x\n",
313 );
314
315 assert_eq!(
316 resolve_gql_url_for_env(&setup_dir, "prod").unwrap(),
317 "http://local.test/graphql"
318 );
319
320 let err = resolve_gql_url_for_env(&setup_dir, "nope").unwrap_err();
321 assert!(format!("{err:#}").contains("Unknown env 'nope'"));
322 assert!(format!("{err:#}").contains("available: local prod"));
323 }
324
325 #[test]
326 fn suite_write_file_creates_parent_dirs() {
327 let tmp = TempDir::new().unwrap();
328 let path = tmp.path().join("a/b/c.txt");
329 write_file(&path, b"hello").unwrap();
330 assert!(path.is_file());
331 assert_eq!(std::fs::read(&path).unwrap(), b"hello");
332 }
333
334 #[test]
335 fn suite_resolve_resolves_suite_name_under_tests_dir() {
336 unsafe { std::env::remove_var("API_TEST_SUITES_DIR") };
338
339 let tmp = TempDir::new().unwrap();
340 let root = tmp.path();
341 std::fs::create_dir_all(root.join(".git")).unwrap();
342 std::fs::create_dir_all(root.join("tests/api/suites")).unwrap();
343 std::fs::write(
344 root.join("tests/api/suites/smoke.suite.json"),
345 br#"{"version":1,"cases":[]}"#,
346 )
347 .unwrap();
348
349 let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
350 assert!(
351 sel.suite_path
352 .ends_with("tests/api/suites/smoke.suite.json")
353 );
354 }
355
356 #[test]
357 fn suite_resolve_resolves_suite_name_under_setup_dir() {
358 unsafe { std::env::remove_var("API_TEST_SUITES_DIR") };
360
361 let tmp = TempDir::new().unwrap();
362 let root = tmp.path();
363 std::fs::create_dir_all(root.join(".git")).unwrap();
364 std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
365 std::fs::write(
366 root.join("setup/api/suites/smoke.suite.json"),
367 br#"{"version":1,"cases":[]}"#,
368 )
369 .unwrap();
370
371 let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
372 assert!(
373 sel.suite_path
374 .ends_with("setup/api/suites/smoke.suite.json")
375 );
376 }
377
378 #[test]
379 fn suite_resolve_rejects_invalid_suite_args() {
380 let tmp = TempDir::new().unwrap();
381 let root = tmp.path();
382
383 let err = resolve_suite_selection(root, Some("a"), Some("b")).unwrap_err();
384 assert!(format!("{err:#}").contains("Use only one of --suite or --suite-file"));
385
386 let err = resolve_suite_selection(root, None, None).unwrap_err();
387 assert!(format!("{err:#}").contains("Missing suite (use --suite or --suite-file)"));
388 }
389
390 #[test]
391 fn suite_resolve_resolves_suite_file_relative_to_repo_root() {
392 let tmp = TempDir::new().unwrap();
393 let root = tmp.path();
394 std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
395 std::fs::write(
396 root.join("setup/api/suites/smoke.suite.json"),
397 br#"{"version":1,"cases":[]}"#,
398 )
399 .unwrap();
400
401 let sel =
402 resolve_suite_selection(root, None, Some("setup/api/suites/smoke.suite.json")).unwrap();
403 assert_eq!(sel.suite_key, "smoke.suite.json");
404 assert!(
405 sel.suite_path
406 .ends_with("setup/api/suites/smoke.suite.json")
407 );
408 }
409}