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