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