Skip to main content

api_testing_core/suite/
resolve.rs

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        // SAFETY: tests mutate process env in isolated test scope.
410        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        // SAFETY: tests mutate process env in isolated test scope.
432        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}