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 mut files: Vec<&Path> = Vec::new();
315        if let Some(tokens_env) = self.tokens_env.as_deref() {
316            files.push(tokens_env);
317        }
318        if let Some(tokens_local) = self.tokens_local_env.as_deref() {
319            files.push(tokens_local);
320        }
321
322        if files.iter().any(|path| path.is_file()) {
323            files
324        } else {
325            Vec::new()
326        }
327    }
328
329    pub fn jwts_files(&self) -> Vec<&Path> {
330        let mut files: Vec<&Path> = Vec::new();
331        if let Some(jwts_env) = self.jwts_env.as_deref() {
332            files.push(jwts_env);
333        }
334        if let Some(jwts_local) = self.jwts_local_env.as_deref() {
335            files.push(jwts_local);
336        }
337
338        if files.iter().any(|path| path.is_file()) {
339            files
340        } else {
341            Vec::new()
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use pretty_assertions::assert_eq;
350
351    use tempfile::TempDir;
352
353    fn write_file(path: &Path, contents: &str) {
354        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
355        std::fs::write(path, contents).expect("write");
356    }
357
358    #[test]
359    fn config_rest_call_falls_back_to_upwards_setup_rest() {
360        let tmp = TempDir::new().expect("tmp");
361        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
362
363        write_file(
364            &root.join("setup/rest/endpoints.env"),
365            "export REST_URL_LOCAL=http://x\n",
366        );
367        write_file(
368            &root.join("tests/requests/health.request.json"),
369            r#"{"method":"GET","path":"/health"}"#,
370        );
371
372        let setup_dir = resolve_rest_setup_dir_for_call(
373            &root,
374            &root,
375            &root.join("tests/requests/health.request.json"),
376            None,
377        )
378        .expect("resolve");
379
380        assert_eq!(setup_dir, root.join("setup/rest"));
381    }
382
383    #[test]
384    fn config_rest_call_explicit_config_dir_wins() {
385        let tmp_root = TempDir::new().expect("tmp root");
386        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
387
388        let tmp_cfg = TempDir::new().expect("tmp cfg");
389        let cfg_root = std::fs::canonicalize(tmp_cfg.path()).expect("cfg abs");
390        std::fs::create_dir_all(cfg_root.join("custom/rest")).expect("mkdir");
391
392        write_file(
393            &cfg_root.join("req/health.request.json"),
394            r#"{"method":"GET","path":"/health"}"#,
395        );
396
397        let setup_dir = resolve_rest_setup_dir_for_call(
398            &root,
399            &root,
400            &cfg_root.join("req/health.request.json"),
401            Some(&cfg_root.join("custom/rest")),
402        )
403        .expect("resolve");
404
405        assert_eq!(setup_dir, cfg_root.join("custom/rest"));
406    }
407
408    #[test]
409    fn endpoints_files_includes_local_when_env_missing() {
410        let tmp = TempDir::new().expect("tmp");
411        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
412        let setup = root.join("setup/rest");
413
414        write_file(
415            &setup.join("endpoints.local.env"),
416            "export REST_URL_LOCAL=http://localhost:1234\n",
417        );
418
419        let resolved = ResolvedSetup::rest(setup, None);
420        let files = resolved.endpoints_files();
421
422        assert_eq!(
423            files,
424            vec![
425                resolved.endpoints_env.as_path(),
426                resolved.endpoints_local_env.as_path()
427            ]
428        );
429    }
430
431    #[test]
432    fn tokens_files_handles_missing_local_path_without_panicking() {
433        let tmp = TempDir::new().expect("tmp");
434        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
435        let setup = root.join("setup/rest");
436
437        let mut resolved = ResolvedSetup::rest(setup, None);
438        let tokens_env = resolved.tokens_env.as_ref().expect("tokens env").clone();
439        write_file(&tokens_env, "REST_TOKEN_DEFAULT=abc\n");
440        resolved.tokens_local_env = None;
441
442        let files: Vec<PathBuf> = resolved
443            .tokens_files()
444            .into_iter()
445            .map(Path::to_path_buf)
446            .collect();
447
448        assert_eq!(files, vec![tokens_env]);
449    }
450
451    #[test]
452    fn jwts_files_handles_missing_local_path_without_panicking() {
453        let tmp = TempDir::new().expect("tmp");
454        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
455        let setup = root.join("setup/graphql");
456
457        let mut resolved = ResolvedSetup::graphql(setup, None);
458        let jwts_env = resolved.jwts_env.as_ref().expect("jwts env").clone();
459        write_file(&jwts_env, "GQL_JWT_DEFAULT=abc\n");
460        resolved.jwts_local_env = None;
461
462        let files: Vec<PathBuf> = resolved
463            .jwts_files()
464            .into_iter()
465            .map(Path::to_path_buf)
466            .collect();
467
468        assert_eq!(files, vec![jwts_env]);
469    }
470
471    #[test]
472    fn config_rest_call_falls_back_to_invocation_dir() {
473        let tmp_root = TempDir::new().expect("tmp root");
474        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
475
476        std::fs::create_dir_all(root.join("setup/rest")).expect("mkdir");
477
478        let tmp_other = TempDir::new().expect("tmp other");
479        let other = std::fs::canonicalize(tmp_other.path()).expect("other abs");
480        write_file(
481            &other.join("place/health.request.json"),
482            r#"{"method":"GET","path":"/health"}"#,
483        );
484
485        let setup_dir = resolve_rest_setup_dir_for_call(
486            &root,
487            &root,
488            &other.join("place/health.request.json"),
489            None,
490        )
491        .expect("resolve");
492
493        assert_eq!(setup_dir, root.join("setup/rest"));
494    }
495
496    #[test]
497    fn config_gql_call_falls_back_to_setup_graphql_in_invocation_dir() {
498        let tmp = TempDir::new().expect("tmp");
499        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
500
501        write_file(
502            &root.join("setup/graphql/endpoints.env"),
503            "export GQL_URL_LOCAL=http://x\n",
504        );
505        write_file(
506            &root.join("operations/countries.graphql"),
507            "query { __typename }\n",
508        );
509
510        let setup_dir = resolve_gql_setup_dir_for_call(
511            &root,
512            &root,
513            Some(&root.join("operations/countries.graphql")),
514            None,
515        )
516        .expect("resolve");
517
518        assert_eq!(setup_dir, root.join("setup/graphql"));
519    }
520
521    #[test]
522    fn config_gql_schema_discovers_schema_env_upwards() {
523        let tmp = TempDir::new().expect("tmp");
524        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
525
526        write_file(
527            &root.join("setup/graphql/schema.env"),
528            "export GQL_SCHEMA_FILE=schema.gql\n",
529        );
530        std::fs::create_dir_all(root.join("setup/graphql/ops")).expect("mkdir");
531
532        let setup_dir =
533            resolve_gql_setup_dir_for_schema(&root, &root, Some(&root.join("setup/graphql/ops")))
534                .expect("resolve");
535
536        assert_eq!(setup_dir, root.join("setup/graphql"));
537    }
538}