Skip to main content

api_testing_core/
config.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Context;
4
5use crate::Result;
6
7const SETUP_DIR_ERROR: &str = "Failed to resolve setup dir (try --config-dir).";
8const INVOCATION_DIR_ERROR: &str = "Failed to resolve invocation dir for setup discovery";
9const SETUP_GRAPHQL_ERROR: &str = "failed to resolve setup/graphql";
10
11#[derive(Debug, Clone, Copy)]
12enum FallbackMode {
13    None,
14    Upwards(&'static str),
15    Direct(&'static str, &'static str),
16}
17
18#[derive(Debug)]
19struct SetupDiscovery<'a> {
20    cwd: &'a Path,
21    seed: PathBuf,
22    config_dir_explicit: bool,
23    files: &'static [&'static str],
24    seed_fallback: FallbackMode,
25    invocation_dir: Option<&'a Path>,
26    invocation_fallback: FallbackMode,
27}
28
29impl<'a> SetupDiscovery<'a> {
30    fn resolve(&self) -> Result<PathBuf> {
31        let seed_abs = abs_dir(self.cwd, &self.seed).context(SETUP_DIR_ERROR)?;
32
33        if let Some(dir) = find_upwards_for_files(&seed_abs, self.files) {
34            return Ok(dir);
35        }
36
37        if let FallbackMode::Upwards(subdir) = self.seed_fallback
38            && let Some(found_setup) = find_upwards_for_setup_subdir(&seed_abs, subdir)
39        {
40            return Ok(found_setup);
41        }
42
43        if self.config_dir_explicit {
44            return Ok(seed_abs);
45        }
46
47        match self.invocation_fallback {
48            FallbackMode::None => {}
49            FallbackMode::Upwards(subdir) => {
50                let invocation_dir = self
51                    .invocation_dir
52                    .expect("invocation_dir required for upwards fallback");
53                let invocation_abs =
54                    abs_dir(self.cwd, invocation_dir).context(INVOCATION_DIR_ERROR)?;
55                if let Some(found_setup) = find_upwards_for_setup_subdir(&invocation_abs, subdir) {
56                    return Ok(found_setup);
57                }
58            }
59            FallbackMode::Direct(subdir, ctx) => {
60                let invocation_dir = self
61                    .invocation_dir
62                    .expect("invocation_dir required for direct fallback");
63                let invocation_abs =
64                    abs_dir(self.cwd, invocation_dir).context(INVOCATION_DIR_ERROR)?;
65                let fallback = invocation_abs.join(subdir);
66                if fallback.is_dir() {
67                    return abs_dir(self.cwd, &fallback).context(ctx);
68                }
69            }
70        }
71
72        Ok(seed_abs)
73    }
74}
75
76fn abs_dir(base_dir: &Path, path: &Path) -> Result<PathBuf> {
77    let joined = if path.is_absolute() {
78        path.to_path_buf()
79    } else {
80        base_dir.join(path)
81    };
82
83    std::fs::canonicalize(&joined)
84        .with_context(|| format!("failed to resolve directory path: {}", joined.display()))
85}
86
87fn find_upwards_for_file(start_dir: &Path, filename: &str) -> Option<PathBuf> {
88    let mut dir = start_dir;
89    loop {
90        if dir.join(filename).is_file() {
91            return Some(dir.to_path_buf());
92        }
93
94        match dir.parent() {
95            Some(parent) if parent != dir => dir = parent,
96            _ => return None,
97        }
98    }
99}
100
101fn find_upwards_for_files(start_dir: &Path, filenames: &[&str]) -> Option<PathBuf> {
102    for filename in filenames {
103        if let Some(found) = find_upwards_for_file(start_dir, filename) {
104            return Some(found);
105        }
106    }
107    None
108}
109
110fn find_upwards_for_setup_subdir(start_dir: &Path, rel_subdir: &str) -> Option<PathBuf> {
111    let mut dir = start_dir;
112    loop {
113        let candidate = dir.join(rel_subdir);
114        if candidate.is_dir() {
115            return Some(candidate);
116        }
117
118        match dir.parent() {
119            Some(parent) if parent != dir => dir = parent,
120            _ => return None,
121        }
122    }
123}
124
125pub fn resolve_rest_setup_dir_for_call(
126    cwd: &Path,
127    invocation_dir: &Path,
128    request_file: &Path,
129    config_dir: Option<&Path>,
130) -> Result<PathBuf> {
131    let seed = match config_dir {
132        Some(dir) => dir.to_path_buf(),
133        None => request_file
134            .parent()
135            .map(PathBuf::from)
136            .unwrap_or_else(|| PathBuf::from(".")),
137    };
138
139    SetupDiscovery {
140        cwd,
141        seed,
142        config_dir_explicit: config_dir.is_some(),
143        files: &[
144            "endpoints.env",
145            "tokens.env",
146            "endpoints.local.env",
147            "tokens.local.env",
148        ],
149        seed_fallback: FallbackMode::Upwards("setup/rest"),
150        invocation_dir: Some(invocation_dir),
151        invocation_fallback: FallbackMode::Upwards("setup/rest"),
152    }
153    .resolve()
154}
155
156pub fn resolve_rest_setup_dir_for_history(
157    cwd: &Path,
158    config_dir: Option<&Path>,
159) -> Result<PathBuf> {
160    let seed = config_dir.unwrap_or_else(|| Path::new("."));
161
162    SetupDiscovery {
163        cwd,
164        seed: seed.to_path_buf(),
165        config_dir_explicit: config_dir.is_some(),
166        files: &[
167            ".rest_history",
168            "endpoints.env",
169            "tokens.env",
170            "tokens.local.env",
171        ],
172        seed_fallback: FallbackMode::Upwards("setup/rest"),
173        invocation_dir: None,
174        invocation_fallback: FallbackMode::None,
175    }
176    .resolve()
177}
178
179pub fn resolve_gql_setup_dir_for_call(
180    cwd: &Path,
181    invocation_dir: &Path,
182    operation_file: Option<&Path>,
183    config_dir: Option<&Path>,
184) -> Result<PathBuf> {
185    let seed = match config_dir {
186        Some(dir) => dir.to_path_buf(),
187        None => operation_file
188            .and_then(|p| p.parent())
189            .map(PathBuf::from)
190            .unwrap_or_else(|| PathBuf::from(".")),
191    };
192
193    SetupDiscovery {
194        cwd,
195        seed,
196        config_dir_explicit: config_dir.is_some(),
197        files: &["endpoints.env", "jwts.env", "jwts.local.env"],
198        seed_fallback: FallbackMode::None,
199        invocation_dir: Some(invocation_dir),
200        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
201    }
202    .resolve()
203}
204
205pub fn resolve_gql_setup_dir_for_history(
206    cwd: &Path,
207    invocation_dir: &Path,
208    config_dir: Option<&Path>,
209) -> Result<PathBuf> {
210    let seed = config_dir.unwrap_or_else(|| Path::new("."));
211
212    SetupDiscovery {
213        cwd,
214        seed: seed.to_path_buf(),
215        config_dir_explicit: config_dir.is_some(),
216        files: &[
217            ".gql_history",
218            "endpoints.env",
219            "jwts.env",
220            "jwts.local.env",
221        ],
222        seed_fallback: FallbackMode::None,
223        invocation_dir: Some(invocation_dir),
224        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
225    }
226    .resolve()
227}
228
229pub fn resolve_gql_setup_dir_for_schema(
230    cwd: &Path,
231    invocation_dir: &Path,
232    config_dir: Option<&Path>,
233) -> Result<PathBuf> {
234    let seed = config_dir.unwrap_or_else(|| Path::new("."));
235
236    SetupDiscovery {
237        cwd,
238        seed: seed.to_path_buf(),
239        config_dir_explicit: config_dir.is_some(),
240        files: &[
241            "schema.env",
242            "schema.local.env",
243            "endpoints.env",
244            "jwts.env",
245            "jwts.local.env",
246        ],
247        seed_fallback: FallbackMode::None,
248        invocation_dir: Some(invocation_dir),
249        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
250    }
251    .resolve()
252}
253
254#[derive(Debug, Clone)]
255pub struct ResolvedSetup {
256    pub setup_dir: PathBuf,
257    pub history_file: PathBuf,
258    pub endpoints_env: PathBuf,
259    pub endpoints_local_env: PathBuf,
260    pub tokens_env: Option<PathBuf>,
261    pub tokens_local_env: Option<PathBuf>,
262    pub jwts_env: Option<PathBuf>,
263    pub jwts_local_env: Option<PathBuf>,
264}
265
266impl ResolvedSetup {
267    pub fn rest(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
268        let history_file =
269            crate::history::resolve_history_file(&setup_dir, history_override, ".rest_history");
270        let endpoints_env = setup_dir.join("endpoints.env");
271        let endpoints_local_env = setup_dir.join("endpoints.local.env");
272        let tokens_env = setup_dir.join("tokens.env");
273        let tokens_local_env = setup_dir.join("tokens.local.env");
274        Self {
275            setup_dir,
276            history_file,
277            endpoints_env,
278            endpoints_local_env,
279            tokens_env: Some(tokens_env),
280            tokens_local_env: Some(tokens_local_env),
281            jwts_env: None,
282            jwts_local_env: None,
283        }
284    }
285
286    pub fn graphql(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
287        let history_file =
288            crate::history::resolve_history_file(&setup_dir, history_override, ".gql_history");
289        let endpoints_env = setup_dir.join("endpoints.env");
290        let endpoints_local_env = setup_dir.join("endpoints.local.env");
291        let jwts_env = setup_dir.join("jwts.env");
292        let jwts_local_env = setup_dir.join("jwts.local.env");
293        Self {
294            setup_dir,
295            history_file,
296            endpoints_env,
297            endpoints_local_env,
298            tokens_env: None,
299            tokens_local_env: None,
300            jwts_env: Some(jwts_env),
301            jwts_local_env: Some(jwts_local_env),
302        }
303    }
304
305    pub fn endpoints_files(&self) -> Vec<&Path> {
306        if self.endpoints_env.is_file() || self.endpoints_local_env.is_file() {
307            vec![&self.endpoints_env, &self.endpoints_local_env]
308        } else {
309            Vec::new()
310        }
311    }
312
313    pub fn tokens_files(&self) -> Vec<&Path> {
314        let Some(tokens_env) = self.tokens_env.as_ref() else {
315            return Vec::new();
316        };
317        let tokens_local = self.tokens_local_env.as_ref().expect("tokens_local_env");
318        if tokens_env.is_file() || tokens_local.is_file() {
319            vec![tokens_env, tokens_local]
320        } else {
321            Vec::new()
322        }
323    }
324
325    pub fn jwts_files(&self) -> Vec<&Path> {
326        let Some(jwts_env) = self.jwts_env.as_ref() else {
327            return Vec::new();
328        };
329        let jwts_local = self.jwts_local_env.as_ref().expect("jwts_local_env");
330        if jwts_env.is_file() || jwts_local.is_file() {
331            vec![jwts_env, jwts_local]
332        } else {
333            Vec::new()
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use pretty_assertions::assert_eq;
342
343    use tempfile::TempDir;
344
345    fn write_file(path: &Path, contents: &str) {
346        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
347        std::fs::write(path, contents).expect("write");
348    }
349
350    #[test]
351    fn config_rest_call_falls_back_to_upwards_setup_rest() {
352        let tmp = TempDir::new().expect("tmp");
353        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
354
355        write_file(
356            &root.join("setup/rest/endpoints.env"),
357            "export REST_URL_LOCAL=http://x\n",
358        );
359        write_file(
360            &root.join("tests/requests/health.request.json"),
361            r#"{"method":"GET","path":"/health"}"#,
362        );
363
364        let setup_dir = resolve_rest_setup_dir_for_call(
365            &root,
366            &root,
367            &root.join("tests/requests/health.request.json"),
368            None,
369        )
370        .expect("resolve");
371
372        assert_eq!(setup_dir, root.join("setup/rest"));
373    }
374
375    #[test]
376    fn config_rest_call_explicit_config_dir_wins() {
377        let tmp_root = TempDir::new().expect("tmp root");
378        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
379
380        let tmp_cfg = TempDir::new().expect("tmp cfg");
381        let cfg_root = std::fs::canonicalize(tmp_cfg.path()).expect("cfg abs");
382        std::fs::create_dir_all(cfg_root.join("custom/rest")).expect("mkdir");
383
384        write_file(
385            &cfg_root.join("req/health.request.json"),
386            r#"{"method":"GET","path":"/health"}"#,
387        );
388
389        let setup_dir = resolve_rest_setup_dir_for_call(
390            &root,
391            &root,
392            &cfg_root.join("req/health.request.json"),
393            Some(&cfg_root.join("custom/rest")),
394        )
395        .expect("resolve");
396
397        assert_eq!(setup_dir, cfg_root.join("custom/rest"));
398    }
399
400    #[test]
401    fn endpoints_files_includes_local_when_env_missing() {
402        let tmp = TempDir::new().expect("tmp");
403        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
404        let setup = root.join("setup/rest");
405
406        write_file(
407            &setup.join("endpoints.local.env"),
408            "export REST_URL_LOCAL=http://localhost:1234\n",
409        );
410
411        let resolved = ResolvedSetup::rest(setup, None);
412        let files = resolved.endpoints_files();
413
414        assert_eq!(
415            files,
416            vec![
417                resolved.endpoints_env.as_path(),
418                resolved.endpoints_local_env.as_path()
419            ]
420        );
421    }
422
423    #[test]
424    fn config_rest_call_falls_back_to_invocation_dir() {
425        let tmp_root = TempDir::new().expect("tmp root");
426        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
427
428        std::fs::create_dir_all(root.join("setup/rest")).expect("mkdir");
429
430        let tmp_other = TempDir::new().expect("tmp other");
431        let other = std::fs::canonicalize(tmp_other.path()).expect("other abs");
432        write_file(
433            &other.join("place/health.request.json"),
434            r#"{"method":"GET","path":"/health"}"#,
435        );
436
437        let setup_dir = resolve_rest_setup_dir_for_call(
438            &root,
439            &root,
440            &other.join("place/health.request.json"),
441            None,
442        )
443        .expect("resolve");
444
445        assert_eq!(setup_dir, root.join("setup/rest"));
446    }
447
448    #[test]
449    fn config_gql_call_falls_back_to_setup_graphql_in_invocation_dir() {
450        let tmp = TempDir::new().expect("tmp");
451        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
452
453        write_file(
454            &root.join("setup/graphql/endpoints.env"),
455            "export GQL_URL_LOCAL=http://x\n",
456        );
457        write_file(
458            &root.join("operations/countries.graphql"),
459            "query { __typename }\n",
460        );
461
462        let setup_dir = resolve_gql_setup_dir_for_call(
463            &root,
464            &root,
465            Some(&root.join("operations/countries.graphql")),
466            None,
467        )
468        .expect("resolve");
469
470        assert_eq!(setup_dir, root.join("setup/graphql"));
471    }
472
473    #[test]
474    fn config_gql_schema_discovers_schema_env_upwards() {
475        let tmp = TempDir::new().expect("tmp");
476        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
477
478        write_file(
479            &root.join("setup/graphql/schema.env"),
480            "export GQL_SCHEMA_FILE=schema.gql\n",
481        );
482        std::fs::create_dir_all(root.join("setup/graphql/ops")).expect("mkdir");
483
484        let setup_dir =
485            resolve_gql_setup_dir_for_schema(&root, &root, Some(&root.join("setup/graphql/ops")))
486                .expect("resolve");
487
488        assert_eq!(setup_dir, root.join("setup/graphql"));
489    }
490}