Skip to main content

api_testing_core/suite/
resolve.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4use nils_common::env as shared_env;
5
6use crate::{Result, cli_util};
7
8pub fn find_repo_root(start_dir: &Path) -> Result<PathBuf> {
9    let mut dir = start_dir;
10    loop {
11        if dir.join(".git").exists() {
12            return Ok(dir.to_path_buf());
13        }
14        match dir.parent() {
15            Some(parent) if parent != dir => dir = parent,
16            _ => anyhow::bail!("Must run inside a git work tree"),
17        }
18    }
19}
20
21pub fn resolve_path_from_repo_root(repo_root: &Path, raw: &str) -> PathBuf {
22    let raw = raw.trim();
23    let path = Path::new(raw);
24    if path.is_absolute() {
25        path.to_path_buf()
26    } else {
27        repo_root.join(path)
28    }
29}
30
31pub fn resolve_rest_base_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
32    let env_value = env_value.trim();
33    if env_value.starts_with("http://") || env_value.starts_with("https://") {
34        return Ok(env_value.to_string());
35    }
36
37    let endpoints_env = setup_dir.join("endpoints.env");
38    let endpoints_local = setup_dir.join("endpoints.local.env");
39    let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
40        vec![&endpoints_env, &endpoints_local]
41    } else {
42        Vec::new()
43    };
44    if endpoints_files.is_empty() {
45        anyhow::bail!("endpoints.env not found (expected under setup/rest/)");
46    }
47
48    let env_key = crate::env_file::normalize_env_key(env_value);
49    let key = format!("REST_URL_{env_key}");
50    let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
51    let Some(found) = found else {
52        let mut available = cli_util::list_available_suffixes(&endpoints_env, "REST_URL_");
53        if endpoints_local.is_file() {
54            available.extend(cli_util::list_available_suffixes(
55                &endpoints_local,
56                "REST_URL_",
57            ));
58            available.sort();
59            available.dedup();
60        }
61        let available = if available.is_empty() {
62            "none".to_string()
63        } else {
64            available.join(" ")
65        };
66        anyhow::bail!("Unknown env '{env_value}' (available: {available})");
67    };
68
69    Ok(found)
70}
71
72pub fn resolve_gql_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
73    let env_value = env_value.trim();
74    if env_value.starts_with("http://") || env_value.starts_with("https://") {
75        return Ok(env_value.to_string());
76    }
77
78    let endpoints_env = setup_dir.join("endpoints.env");
79    let endpoints_local = setup_dir.join("endpoints.local.env");
80    let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
81        vec![&endpoints_env, &endpoints_local]
82    } else {
83        Vec::new()
84    };
85    if endpoints_files.is_empty() {
86        anyhow::bail!("endpoints.env not found (expected under setup/graphql/)");
87    }
88
89    let env_key = crate::env_file::normalize_env_key(env_value);
90    let key = format!("GQL_URL_{env_key}");
91    let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
92    let Some(found) = found else {
93        let mut available = cli_util::list_available_suffixes(&endpoints_env, "GQL_URL_");
94        if endpoints_local.is_file() {
95            available.extend(cli_util::list_available_suffixes(
96                &endpoints_local,
97                "GQL_URL_",
98            ));
99            available.sort();
100            available.dedup();
101        }
102        let available = if available.is_empty() {
103            "none".to_string()
104        } else {
105            available.join(" ")
106        };
107        anyhow::bail!("Unknown env '{env_value}' (available: {available})");
108    };
109
110    Ok(found)
111}
112
113pub fn resolve_grpc_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
114    let env_value = env_value.trim();
115    if env_value.starts_with("http://") || env_value.starts_with("https://") {
116        return Ok(env_value.to_string());
117    }
118
119    let endpoints_env = setup_dir.join("endpoints.env");
120    let endpoints_local = setup_dir.join("endpoints.local.env");
121    let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
122        vec![&endpoints_env, &endpoints_local]
123    } else {
124        Vec::new()
125    };
126    if endpoints_files.is_empty() {
127        anyhow::bail!("endpoints.env not found (expected under setup/grpc/)");
128    }
129
130    let env_key = crate::env_file::normalize_env_key(env_value);
131    let key = format!("GRPC_URL_{env_key}");
132    let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
133    let Some(found) = found else {
134        let mut available = cli_util::list_available_suffixes(&endpoints_env, "GRPC_URL_");
135        if endpoints_local.is_file() {
136            available.extend(cli_util::list_available_suffixes(
137                &endpoints_local,
138                "GRPC_URL_",
139            ));
140            available.sort();
141            available.dedup();
142        }
143        let available = if available.is_empty() {
144            "none".to_string()
145        } else {
146            available.join(" ")
147        };
148        anyhow::bail!("Unknown env '{env_value}' (available: {available})");
149    };
150
151    Ok(found)
152}
153
154pub fn resolve_ws_url_for_env(setup_dir: &Path, env_value: &str) -> Result<String> {
155    let env_value = env_value.trim();
156    if env_value.starts_with("ws://") || env_value.starts_with("wss://") {
157        return Ok(env_value.to_string());
158    }
159
160    let endpoints_env = setup_dir.join("endpoints.env");
161    let endpoints_local = setup_dir.join("endpoints.local.env");
162    let endpoints_files: Vec<&Path> = if endpoints_env.is_file() {
163        vec![&endpoints_env, &endpoints_local]
164    } else {
165        Vec::new()
166    };
167    if endpoints_files.is_empty() {
168        anyhow::bail!("endpoints.env not found (expected under setup/websocket/)");
169    }
170
171    let env_key = crate::env_file::normalize_env_key(env_value);
172    let key = format!("WS_URL_{env_key}");
173    let found = crate::env_file::read_var_last_wins(&key, &endpoints_files)?;
174    let Some(found) = found else {
175        let mut available = cli_util::list_available_suffixes(&endpoints_env, "WS_URL_");
176        if endpoints_local.is_file() {
177            available.extend(cli_util::list_available_suffixes(
178                &endpoints_local,
179                "WS_URL_",
180            ));
181            available.sort();
182            available.dedup();
183        }
184        let available = if available.is_empty() {
185            "none".to_string()
186        } else {
187            available.join(" ")
188        };
189        anyhow::bail!("Unknown env '{env_value}' (available: {available})");
190    };
191
192    Ok(found)
193}
194
195#[derive(Debug, Clone)]
196pub struct SuiteSelection {
197    pub suite_key: String,
198    pub suite_path: PathBuf,
199}
200
201fn normalize_suite_key(raw: &str) -> String {
202    let key = raw.trim();
203    let key = key.strip_suffix(".suite.json").unwrap_or(key);
204    let key = key.strip_suffix(".json").unwrap_or(key);
205    key.trim().to_string()
206}
207
208pub fn resolve_suite_selection(
209    repo_root: &Path,
210    suite: Option<&str>,
211    suite_file: Option<&str>,
212) -> Result<SuiteSelection> {
213    if suite.is_some() && suite_file.is_some() {
214        anyhow::bail!("Use only one of --suite or --suite-file");
215    }
216    let suite = suite.and_then(cli_util::trim_non_empty);
217    let suite_file = suite_file.and_then(cli_util::trim_non_empty);
218    if suite.is_none() && suite_file.is_none() {
219        anyhow::bail!("Missing suite (use --suite or --suite-file)");
220    }
221
222    if let Some(suite_file) = suite_file {
223        let suite_path = resolve_path_from_repo_root(repo_root, &suite_file);
224        let suite_path = std::fs::canonicalize(&suite_path).unwrap_or(suite_path);
225        if !suite_path.is_file() {
226            anyhow::bail!("Suite file not found: {}", suite_path.to_string_lossy());
227        }
228        let suite_key = suite_path
229            .file_name()
230            .and_then(|s| s.to_str())
231            .unwrap_or("suite")
232            .to_string();
233        return Ok(SuiteSelection {
234            suite_key,
235            suite_path,
236        });
237    }
238
239    let suite_key = normalize_suite_key(&suite.unwrap_or_default());
240    if suite_key.is_empty() {
241        anyhow::bail!("Missing suite (use --suite or --suite-file)");
242    }
243
244    let suites_dir_override =
245        shared_env::env_non_empty("API_TEST_SUITES_DIR").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    use nils_test_support::{EnvGuard, GlobalStateLock};
287
288    use tempfile::TempDir;
289
290    fn write(path: &Path, contents: &str) {
291        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
292        std::fs::write(path, contents).expect("write");
293    }
294
295    #[test]
296    fn suite_find_repo_root_success_and_failure() {
297        let tmp = TempDir::new().unwrap();
298        let root = tmp.path();
299        std::fs::create_dir_all(root.join(".git")).unwrap();
300        std::fs::create_dir_all(root.join("a/b/c")).unwrap();
301
302        let found = find_repo_root(&root.join("a/b/c")).unwrap();
303        assert_eq!(found, root);
304
305        let tmp2 = TempDir::new().unwrap();
306        let err = find_repo_root(tmp2.path()).unwrap_err();
307        assert!(format!("{err:#}").contains("Must run inside a git work tree"));
308    }
309
310    #[test]
311    fn suite_resolve_path_from_repo_root_handles_absolute_and_relative() {
312        let tmp = TempDir::new().unwrap();
313        let root = tmp.path();
314
315        assert_eq!(
316            resolve_path_from_repo_root(root, " setup/rest "),
317            root.join("setup/rest")
318        );
319        assert_eq!(
320            resolve_path_from_repo_root(root, "/abs/path"),
321            PathBuf::from("/abs/path")
322        );
323    }
324
325    #[cfg(windows)]
326    #[test]
327    fn suite_resolve_path_from_repo_root_handles_windows_absolute_paths() {
328        let tmp = TempDir::new().unwrap();
329        let root = tmp.path();
330
331        let drive_abs = r"C:\abs\path";
332        assert_eq!(
333            resolve_path_from_repo_root(root, drive_abs),
334            PathBuf::from(drive_abs)
335        );
336
337        let unc_abs = r"\\server\share\suite.yaml";
338        assert_eq!(
339            resolve_path_from_repo_root(root, unc_abs),
340            PathBuf::from(unc_abs)
341        );
342    }
343
344    #[test]
345    fn suite_resolve_rest_base_url_from_url_and_env_files() {
346        let tmp = TempDir::new().unwrap();
347        let setup_dir = tmp.path().join("setup/rest");
348
349        assert_eq!(
350            resolve_rest_base_url_for_env(&setup_dir, "https://example.test").unwrap(),
351            "https://example.test"
352        );
353
354        let err = resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap_err();
355        assert!(format!("{err:#}").contains("endpoints.env not found"));
356        assert!(format!("{err:#}").contains("setup/rest"));
357
358        let endpoints_env = setup_dir.join("endpoints.env");
359        let endpoints_local = setup_dir.join("endpoints.local.env");
360        write(&endpoints_env, "REST_URL_PROD=http://base.test\n");
361        write(
362            &endpoints_local,
363            "REST_URL_PROD=http://local.test\nREST_URL_LOCAL=http://x\n",
364        );
365
366        assert_eq!(
367            resolve_rest_base_url_for_env(&setup_dir, "prod").unwrap(),
368            "http://local.test"
369        );
370
371        let err = resolve_rest_base_url_for_env(&setup_dir, "nope").unwrap_err();
372        assert!(format!("{err:#}").contains("Unknown env 'nope'"));
373        assert!(format!("{err:#}").contains("available: local prod"));
374    }
375
376    #[test]
377    fn suite_resolve_gql_url_from_url_and_env_files() {
378        let tmp = TempDir::new().unwrap();
379        let setup_dir = tmp.path().join("setup/graphql");
380
381        assert_eq!(
382            resolve_gql_url_for_env(&setup_dir, "http://example.test/graphql").unwrap(),
383            "http://example.test/graphql"
384        );
385
386        let err = resolve_gql_url_for_env(&setup_dir, "prod").unwrap_err();
387        assert!(format!("{err:#}").contains("endpoints.env not found"));
388        assert!(format!("{err:#}").contains("setup/graphql"));
389
390        let endpoints_env = setup_dir.join("endpoints.env");
391        let endpoints_local = setup_dir.join("endpoints.local.env");
392        write(&endpoints_env, "GQL_URL_PROD=http://base.test/graphql\n");
393        write(
394            &endpoints_local,
395            "GQL_URL_PROD=http://local.test/graphql\nGQL_URL_LOCAL=http://x\n",
396        );
397
398        assert_eq!(
399            resolve_gql_url_for_env(&setup_dir, "prod").unwrap(),
400            "http://local.test/graphql"
401        );
402
403        let err = resolve_gql_url_for_env(&setup_dir, "nope").unwrap_err();
404        assert!(format!("{err:#}").contains("Unknown env 'nope'"));
405        assert!(format!("{err:#}").contains("available: local prod"));
406    }
407
408    #[test]
409    fn suite_resolve_grpc_url_from_url_and_env_files() {
410        let tmp = TempDir::new().unwrap();
411        let setup_dir = tmp.path().join("setup/grpc");
412
413        assert_eq!(
414            resolve_grpc_url_for_env(&setup_dir, "https://grpc.test:8443").unwrap(),
415            "https://grpc.test:8443"
416        );
417
418        let err = resolve_grpc_url_for_env(&setup_dir, "prod").unwrap_err();
419        assert!(format!("{err:#}").contains("endpoints.env not found"));
420        assert!(format!("{err:#}").contains("setup/grpc"));
421
422        let endpoints_env = setup_dir.join("endpoints.env");
423        let endpoints_local = setup_dir.join("endpoints.local.env");
424        write(&endpoints_env, "GRPC_URL_PROD=grpc.prod:443\n");
425        write(
426            &endpoints_local,
427            "GRPC_URL_PROD=grpc.local:443\nGRPC_URL_LOCAL=127.0.0.1:50051\n",
428        );
429
430        assert_eq!(
431            resolve_grpc_url_for_env(&setup_dir, "prod").unwrap(),
432            "grpc.local:443"
433        );
434
435        let err = resolve_grpc_url_for_env(&setup_dir, "nope").unwrap_err();
436        assert!(format!("{err:#}").contains("Unknown env 'nope'"));
437        assert!(format!("{err:#}").contains("available: local prod"));
438    }
439
440    #[test]
441    fn suite_resolve_ws_url_from_url_and_env_files() {
442        let tmp = TempDir::new().unwrap();
443        let setup_dir = tmp.path().join("setup/websocket");
444
445        assert_eq!(
446            resolve_ws_url_for_env(&setup_dir, "wss://socket.test/ws").unwrap(),
447            "wss://socket.test/ws"
448        );
449
450        let err = resolve_ws_url_for_env(&setup_dir, "prod").unwrap_err();
451        assert!(format!("{err:#}").contains("endpoints.env not found"));
452        assert!(format!("{err:#}").contains("setup/websocket"));
453
454        let endpoints_env = setup_dir.join("endpoints.env");
455        let endpoints_local = setup_dir.join("endpoints.local.env");
456        write(&endpoints_env, "WS_URL_PROD=ws://socket.prod/ws\n");
457        write(
458            &endpoints_local,
459            "WS_URL_PROD=ws://socket.local/ws\nWS_URL_LOCAL=ws://127.0.0.1:9001/ws\n",
460        );
461
462        assert_eq!(
463            resolve_ws_url_for_env(&setup_dir, "prod").unwrap(),
464            "ws://socket.local/ws"
465        );
466
467        let err = resolve_ws_url_for_env(&setup_dir, "nope").unwrap_err();
468        assert!(format!("{err:#}").contains("Unknown env 'nope'"));
469        assert!(format!("{err:#}").contains("available: local prod"));
470    }
471
472    #[test]
473    fn suite_write_file_creates_parent_dirs() {
474        let tmp = TempDir::new().unwrap();
475        let path = tmp.path().join("a/b/c.txt");
476        write_file(&path, b"hello").unwrap();
477        assert!(path.is_file());
478        assert_eq!(std::fs::read(&path).unwrap(), b"hello");
479    }
480
481    #[test]
482    fn suite_resolve_resolves_suite_name_under_tests_dir() {
483        let lock = GlobalStateLock::new();
484        let _guard = EnvGuard::remove(&lock, "API_TEST_SUITES_DIR");
485
486        let tmp = TempDir::new().unwrap();
487        let root = tmp.path();
488        std::fs::create_dir_all(root.join(".git")).unwrap();
489        std::fs::create_dir_all(root.join("tests/api/suites")).unwrap();
490        std::fs::write(
491            root.join("tests/api/suites/smoke.suite.json"),
492            br#"{"version":1,"cases":[]}"#,
493        )
494        .unwrap();
495
496        let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
497        assert!(
498            sel.suite_path
499                .ends_with("tests/api/suites/smoke.suite.json")
500        );
501    }
502
503    #[test]
504    fn suite_resolve_resolves_suite_name_under_setup_dir() {
505        let lock = GlobalStateLock::new();
506        let _guard = EnvGuard::remove(&lock, "API_TEST_SUITES_DIR");
507
508        let tmp = TempDir::new().unwrap();
509        let root = tmp.path();
510        std::fs::create_dir_all(root.join(".git")).unwrap();
511        std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
512        std::fs::write(
513            root.join("setup/api/suites/smoke.suite.json"),
514            br#"{"version":1,"cases":[]}"#,
515        )
516        .unwrap();
517
518        let sel = resolve_suite_selection(root, Some("smoke"), None).unwrap();
519        assert!(
520            sel.suite_path
521                .ends_with("setup/api/suites/smoke.suite.json")
522        );
523    }
524
525    #[test]
526    fn suite_resolve_rejects_invalid_suite_args() {
527        let tmp = TempDir::new().unwrap();
528        let root = tmp.path();
529
530        let err = resolve_suite_selection(root, Some("a"), Some("b")).unwrap_err();
531        assert!(format!("{err:#}").contains("Use only one of --suite or --suite-file"));
532
533        let err = resolve_suite_selection(root, None, None).unwrap_err();
534        assert!(format!("{err:#}").contains("Missing suite (use --suite or --suite-file)"));
535    }
536
537    #[test]
538    fn suite_resolve_resolves_suite_file_relative_to_repo_root() {
539        let tmp = TempDir::new().unwrap();
540        let root = tmp.path();
541        std::fs::create_dir_all(root.join("setup/api/suites")).unwrap();
542        std::fs::write(
543            root.join("setup/api/suites/smoke.suite.json"),
544            br#"{"version":1,"cases":[]}"#,
545        )
546        .unwrap();
547
548        let sel =
549            resolve_suite_selection(root, None, Some("setup/api/suites/smoke.suite.json")).unwrap();
550        assert_eq!(sel.suite_key, "smoke.suite.json");
551        assert!(
552            sel.suite_path
553                .ends_with("setup/api/suites/smoke.suite.json")
554        );
555    }
556}