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";
10const SETUP_GRPC_ERROR: &str = "failed to resolve setup/grpc";
11
12#[derive(Debug, Clone, Copy)]
13enum FallbackMode {
14    None,
15    Upwards(&'static str),
16    Direct(&'static str, &'static str),
17}
18
19#[derive(Debug)]
20struct SetupDiscovery<'a> {
21    cwd: &'a Path,
22    seed: PathBuf,
23    config_dir_explicit: bool,
24    files: &'static [&'static str],
25    seed_fallback: FallbackMode,
26    invocation_dir: Option<&'a Path>,
27    invocation_fallback: FallbackMode,
28}
29
30impl<'a> SetupDiscovery<'a> {
31    fn resolve(&self) -> Result<PathBuf> {
32        let seed_abs = abs_dir(self.cwd, &self.seed).context(SETUP_DIR_ERROR)?;
33
34        if let Some(dir) = find_upwards_for_files(&seed_abs, self.files) {
35            return Ok(dir);
36        }
37
38        if let FallbackMode::Upwards(subdir) = self.seed_fallback
39            && let Some(found_setup) = find_upwards_for_setup_subdir(&seed_abs, subdir)
40        {
41            return Ok(found_setup);
42        }
43
44        if self.config_dir_explicit {
45            return Ok(seed_abs);
46        }
47
48        match self.invocation_fallback {
49            FallbackMode::None => {}
50            FallbackMode::Upwards(subdir) => {
51                let invocation_dir = self
52                    .invocation_dir
53                    .expect("invocation_dir required for upwards fallback");
54                let invocation_abs =
55                    abs_dir(self.cwd, invocation_dir).context(INVOCATION_DIR_ERROR)?;
56                if let Some(found_setup) = find_upwards_for_setup_subdir(&invocation_abs, subdir) {
57                    return Ok(found_setup);
58                }
59            }
60            FallbackMode::Direct(subdir, ctx) => {
61                let invocation_dir = self
62                    .invocation_dir
63                    .expect("invocation_dir required for direct fallback");
64                let invocation_abs =
65                    abs_dir(self.cwd, invocation_dir).context(INVOCATION_DIR_ERROR)?;
66                let fallback = invocation_abs.join(subdir);
67                if fallback.is_dir() {
68                    return abs_dir(self.cwd, &fallback).context(ctx);
69                }
70            }
71        }
72
73        Ok(seed_abs)
74    }
75}
76
77fn abs_dir(base_dir: &Path, path: &Path) -> Result<PathBuf> {
78    let joined = if path.is_absolute() {
79        path.to_path_buf()
80    } else {
81        base_dir.join(path)
82    };
83
84    std::fs::canonicalize(&joined)
85        .with_context(|| format!("failed to resolve directory path: {}", joined.display()))
86}
87
88fn find_upwards_for_file(start_dir: &Path, filename: &str) -> Option<PathBuf> {
89    let mut dir = start_dir;
90    loop {
91        if dir.join(filename).is_file() {
92            return Some(dir.to_path_buf());
93        }
94
95        match dir.parent() {
96            Some(parent) if parent != dir => dir = parent,
97            _ => return None,
98        }
99    }
100}
101
102fn find_upwards_for_files(start_dir: &Path, filenames: &[&str]) -> Option<PathBuf> {
103    for filename in filenames {
104        if let Some(found) = find_upwards_for_file(start_dir, filename) {
105            return Some(found);
106        }
107    }
108    None
109}
110
111fn find_upwards_for_setup_subdir(start_dir: &Path, rel_subdir: &str) -> Option<PathBuf> {
112    let mut dir = start_dir;
113    loop {
114        let candidate = dir.join(rel_subdir);
115        if candidate.is_dir() {
116            return Some(candidate);
117        }
118
119        match dir.parent() {
120            Some(parent) if parent != dir => dir = parent,
121            _ => return None,
122        }
123    }
124}
125
126pub fn resolve_rest_setup_dir_for_call(
127    cwd: &Path,
128    invocation_dir: &Path,
129    request_file: &Path,
130    config_dir: Option<&Path>,
131) -> Result<PathBuf> {
132    let seed = match config_dir {
133        Some(dir) => dir.to_path_buf(),
134        None => request_file
135            .parent()
136            .map(PathBuf::from)
137            .unwrap_or_else(|| PathBuf::from(".")),
138    };
139
140    SetupDiscovery {
141        cwd,
142        seed,
143        config_dir_explicit: config_dir.is_some(),
144        files: &[
145            "endpoints.env",
146            "tokens.env",
147            "endpoints.local.env",
148            "tokens.local.env",
149        ],
150        seed_fallback: FallbackMode::Upwards("setup/rest"),
151        invocation_dir: Some(invocation_dir),
152        invocation_fallback: FallbackMode::Upwards("setup/rest"),
153    }
154    .resolve()
155}
156
157pub fn resolve_rest_setup_dir_for_history(
158    cwd: &Path,
159    config_dir: Option<&Path>,
160) -> Result<PathBuf> {
161    let seed = config_dir.unwrap_or_else(|| Path::new("."));
162
163    SetupDiscovery {
164        cwd,
165        seed: seed.to_path_buf(),
166        config_dir_explicit: config_dir.is_some(),
167        files: &[
168            ".rest_history",
169            "endpoints.env",
170            "tokens.env",
171            "tokens.local.env",
172        ],
173        seed_fallback: FallbackMode::Upwards("setup/rest"),
174        invocation_dir: None,
175        invocation_fallback: FallbackMode::None,
176    }
177    .resolve()
178}
179
180pub fn resolve_gql_setup_dir_for_call(
181    cwd: &Path,
182    invocation_dir: &Path,
183    operation_file: Option<&Path>,
184    config_dir: Option<&Path>,
185) -> Result<PathBuf> {
186    let seed = match config_dir {
187        Some(dir) => dir.to_path_buf(),
188        None => operation_file
189            .and_then(|p| p.parent())
190            .map(PathBuf::from)
191            .unwrap_or_else(|| PathBuf::from(".")),
192    };
193
194    SetupDiscovery {
195        cwd,
196        seed,
197        config_dir_explicit: config_dir.is_some(),
198        files: &["endpoints.env", "jwts.env", "jwts.local.env"],
199        seed_fallback: FallbackMode::None,
200        invocation_dir: Some(invocation_dir),
201        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
202    }
203    .resolve()
204}
205
206pub fn resolve_gql_setup_dir_for_history(
207    cwd: &Path,
208    invocation_dir: &Path,
209    config_dir: Option<&Path>,
210) -> Result<PathBuf> {
211    let seed = config_dir.unwrap_or_else(|| Path::new("."));
212
213    SetupDiscovery {
214        cwd,
215        seed: seed.to_path_buf(),
216        config_dir_explicit: config_dir.is_some(),
217        files: &[
218            ".gql_history",
219            "endpoints.env",
220            "jwts.env",
221            "jwts.local.env",
222        ],
223        seed_fallback: FallbackMode::None,
224        invocation_dir: Some(invocation_dir),
225        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
226    }
227    .resolve()
228}
229
230pub fn resolve_gql_setup_dir_for_schema(
231    cwd: &Path,
232    invocation_dir: &Path,
233    config_dir: Option<&Path>,
234) -> Result<PathBuf> {
235    let seed = config_dir.unwrap_or_else(|| Path::new("."));
236
237    SetupDiscovery {
238        cwd,
239        seed: seed.to_path_buf(),
240        config_dir_explicit: config_dir.is_some(),
241        files: &[
242            "schema.env",
243            "schema.local.env",
244            "endpoints.env",
245            "jwts.env",
246            "jwts.local.env",
247        ],
248        seed_fallback: FallbackMode::None,
249        invocation_dir: Some(invocation_dir),
250        invocation_fallback: FallbackMode::Direct("setup/graphql", SETUP_GRAPHQL_ERROR),
251    }
252    .resolve()
253}
254
255pub fn resolve_grpc_setup_dir_for_call(
256    cwd: &Path,
257    invocation_dir: &Path,
258    request_file: &Path,
259    config_dir: Option<&Path>,
260) -> Result<PathBuf> {
261    let seed = match config_dir {
262        Some(dir) => dir.to_path_buf(),
263        None => request_file
264            .parent()
265            .map(PathBuf::from)
266            .unwrap_or_else(|| PathBuf::from(".")),
267    };
268
269    SetupDiscovery {
270        cwd,
271        seed,
272        config_dir_explicit: config_dir.is_some(),
273        files: &[
274            "endpoints.env",
275            "tokens.env",
276            "endpoints.local.env",
277            "tokens.local.env",
278        ],
279        seed_fallback: FallbackMode::None,
280        invocation_dir: Some(invocation_dir),
281        invocation_fallback: FallbackMode::Direct("setup/grpc", SETUP_GRPC_ERROR),
282    }
283    .resolve()
284}
285
286pub fn resolve_grpc_setup_dir_for_history(
287    cwd: &Path,
288    invocation_dir: &Path,
289    config_dir: Option<&Path>,
290) -> Result<PathBuf> {
291    let seed = config_dir.unwrap_or_else(|| Path::new("."));
292
293    SetupDiscovery {
294        cwd,
295        seed: seed.to_path_buf(),
296        config_dir_explicit: config_dir.is_some(),
297        files: &[
298            ".grpc_history",
299            "endpoints.env",
300            "tokens.env",
301            "endpoints.local.env",
302            "tokens.local.env",
303        ],
304        seed_fallback: FallbackMode::None,
305        invocation_dir: Some(invocation_dir),
306        invocation_fallback: FallbackMode::Direct("setup/grpc", SETUP_GRPC_ERROR),
307    }
308    .resolve()
309}
310
311#[derive(Debug, Clone)]
312pub struct ResolvedSetup {
313    pub setup_dir: PathBuf,
314    pub history_file: PathBuf,
315    pub endpoints_env: PathBuf,
316    pub endpoints_local_env: PathBuf,
317    pub tokens_env: Option<PathBuf>,
318    pub tokens_local_env: Option<PathBuf>,
319    pub jwts_env: Option<PathBuf>,
320    pub jwts_local_env: Option<PathBuf>,
321}
322
323impl ResolvedSetup {
324    pub fn rest(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
325        let history_file =
326            crate::history::resolve_history_file(&setup_dir, history_override, ".rest_history");
327        let endpoints_env = setup_dir.join("endpoints.env");
328        let endpoints_local_env = setup_dir.join("endpoints.local.env");
329        let tokens_env = setup_dir.join("tokens.env");
330        let tokens_local_env = setup_dir.join("tokens.local.env");
331        Self {
332            setup_dir,
333            history_file,
334            endpoints_env,
335            endpoints_local_env,
336            tokens_env: Some(tokens_env),
337            tokens_local_env: Some(tokens_local_env),
338            jwts_env: None,
339            jwts_local_env: None,
340        }
341    }
342
343    pub fn graphql(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
344        let history_file =
345            crate::history::resolve_history_file(&setup_dir, history_override, ".gql_history");
346        let endpoints_env = setup_dir.join("endpoints.env");
347        let endpoints_local_env = setup_dir.join("endpoints.local.env");
348        let jwts_env = setup_dir.join("jwts.env");
349        let jwts_local_env = setup_dir.join("jwts.local.env");
350        Self {
351            setup_dir,
352            history_file,
353            endpoints_env,
354            endpoints_local_env,
355            tokens_env: None,
356            tokens_local_env: None,
357            jwts_env: Some(jwts_env),
358            jwts_local_env: Some(jwts_local_env),
359        }
360    }
361
362    pub fn grpc(setup_dir: PathBuf, history_override: Option<&Path>) -> Self {
363        let history_file =
364            crate::history::resolve_history_file(&setup_dir, history_override, ".grpc_history");
365        let endpoints_env = setup_dir.join("endpoints.env");
366        let endpoints_local_env = setup_dir.join("endpoints.local.env");
367        let tokens_env = setup_dir.join("tokens.env");
368        let tokens_local_env = setup_dir.join("tokens.local.env");
369        Self {
370            setup_dir,
371            history_file,
372            endpoints_env,
373            endpoints_local_env,
374            tokens_env: Some(tokens_env),
375            tokens_local_env: Some(tokens_local_env),
376            jwts_env: None,
377            jwts_local_env: None,
378        }
379    }
380
381    pub fn endpoints_files(&self) -> Vec<&Path> {
382        if self.endpoints_env.is_file() || self.endpoints_local_env.is_file() {
383            vec![&self.endpoints_env, &self.endpoints_local_env]
384        } else {
385            Vec::new()
386        }
387    }
388
389    pub fn tokens_files(&self) -> Vec<&Path> {
390        let mut files: Vec<&Path> = Vec::new();
391        if let Some(tokens_env) = self.tokens_env.as_deref() {
392            files.push(tokens_env);
393        }
394        if let Some(tokens_local) = self.tokens_local_env.as_deref() {
395            files.push(tokens_local);
396        }
397
398        if files.iter().any(|path| path.is_file()) {
399            files
400        } else {
401            Vec::new()
402        }
403    }
404
405    pub fn jwts_files(&self) -> Vec<&Path> {
406        let mut files: Vec<&Path> = Vec::new();
407        if let Some(jwts_env) = self.jwts_env.as_deref() {
408            files.push(jwts_env);
409        }
410        if let Some(jwts_local) = self.jwts_local_env.as_deref() {
411            files.push(jwts_local);
412        }
413
414        if files.iter().any(|path| path.is_file()) {
415            files
416        } else {
417            Vec::new()
418        }
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use pretty_assertions::assert_eq;
426
427    use tempfile::TempDir;
428
429    fn write_file(path: &Path, contents: &str) {
430        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
431        std::fs::write(path, contents).expect("write");
432    }
433
434    #[test]
435    fn config_rest_call_falls_back_to_upwards_setup_rest() {
436        let tmp = TempDir::new().expect("tmp");
437        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
438
439        write_file(
440            &root.join("setup/rest/endpoints.env"),
441            "export REST_URL_LOCAL=http://x\n",
442        );
443        write_file(
444            &root.join("tests/requests/health.request.json"),
445            r#"{"method":"GET","path":"/health"}"#,
446        );
447
448        let setup_dir = resolve_rest_setup_dir_for_call(
449            &root,
450            &root,
451            &root.join("tests/requests/health.request.json"),
452            None,
453        )
454        .expect("resolve");
455
456        assert_eq!(setup_dir, root.join("setup/rest"));
457    }
458
459    #[test]
460    fn config_rest_call_explicit_config_dir_wins() {
461        let tmp_root = TempDir::new().expect("tmp root");
462        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
463
464        let tmp_cfg = TempDir::new().expect("tmp cfg");
465        let cfg_root = std::fs::canonicalize(tmp_cfg.path()).expect("cfg abs");
466        std::fs::create_dir_all(cfg_root.join("custom/rest")).expect("mkdir");
467
468        write_file(
469            &cfg_root.join("req/health.request.json"),
470            r#"{"method":"GET","path":"/health"}"#,
471        );
472
473        let setup_dir = resolve_rest_setup_dir_for_call(
474            &root,
475            &root,
476            &cfg_root.join("req/health.request.json"),
477            Some(&cfg_root.join("custom/rest")),
478        )
479        .expect("resolve");
480
481        assert_eq!(setup_dir, cfg_root.join("custom/rest"));
482    }
483
484    #[test]
485    fn endpoints_files_includes_local_when_env_missing() {
486        let tmp = TempDir::new().expect("tmp");
487        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
488        let setup = root.join("setup/rest");
489
490        write_file(
491            &setup.join("endpoints.local.env"),
492            "export REST_URL_LOCAL=http://localhost:1234\n",
493        );
494
495        let resolved = ResolvedSetup::rest(setup, None);
496        let files = resolved.endpoints_files();
497
498        assert_eq!(
499            files,
500            vec![
501                resolved.endpoints_env.as_path(),
502                resolved.endpoints_local_env.as_path()
503            ]
504        );
505    }
506
507    #[test]
508    fn tokens_files_handles_missing_local_path_without_panicking() {
509        let tmp = TempDir::new().expect("tmp");
510        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
511        let setup = root.join("setup/rest");
512
513        let mut resolved = ResolvedSetup::rest(setup, None);
514        let tokens_env = resolved.tokens_env.as_ref().expect("tokens env").clone();
515        write_file(&tokens_env, "REST_TOKEN_DEFAULT=abc\n");
516        resolved.tokens_local_env = None;
517
518        let files: Vec<PathBuf> = resolved
519            .tokens_files()
520            .into_iter()
521            .map(Path::to_path_buf)
522            .collect();
523
524        assert_eq!(files, vec![tokens_env]);
525    }
526
527    #[test]
528    fn jwts_files_handles_missing_local_path_without_panicking() {
529        let tmp = TempDir::new().expect("tmp");
530        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
531        let setup = root.join("setup/graphql");
532
533        let mut resolved = ResolvedSetup::graphql(setup, None);
534        let jwts_env = resolved.jwts_env.as_ref().expect("jwts env").clone();
535        write_file(&jwts_env, "GQL_JWT_DEFAULT=abc\n");
536        resolved.jwts_local_env = None;
537
538        let files: Vec<PathBuf> = resolved
539            .jwts_files()
540            .into_iter()
541            .map(Path::to_path_buf)
542            .collect();
543
544        assert_eq!(files, vec![jwts_env]);
545    }
546
547    #[test]
548    fn config_rest_call_falls_back_to_invocation_dir() {
549        let tmp_root = TempDir::new().expect("tmp root");
550        let root = std::fs::canonicalize(tmp_root.path()).expect("root abs");
551
552        std::fs::create_dir_all(root.join("setup/rest")).expect("mkdir");
553
554        let tmp_other = TempDir::new().expect("tmp other");
555        let other = std::fs::canonicalize(tmp_other.path()).expect("other abs");
556        write_file(
557            &other.join("place/health.request.json"),
558            r#"{"method":"GET","path":"/health"}"#,
559        );
560
561        let setup_dir = resolve_rest_setup_dir_for_call(
562            &root,
563            &root,
564            &other.join("place/health.request.json"),
565            None,
566        )
567        .expect("resolve");
568
569        assert_eq!(setup_dir, root.join("setup/rest"));
570    }
571
572    #[test]
573    fn config_gql_call_falls_back_to_setup_graphql_in_invocation_dir() {
574        let tmp = TempDir::new().expect("tmp");
575        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
576
577        write_file(
578            &root.join("setup/graphql/endpoints.env"),
579            "export GQL_URL_LOCAL=http://x\n",
580        );
581        write_file(
582            &root.join("operations/countries.graphql"),
583            "query { __typename }\n",
584        );
585
586        let setup_dir = resolve_gql_setup_dir_for_call(
587            &root,
588            &root,
589            Some(&root.join("operations/countries.graphql")),
590            None,
591        )
592        .expect("resolve");
593
594        assert_eq!(setup_dir, root.join("setup/graphql"));
595    }
596
597    #[test]
598    fn config_gql_schema_discovers_schema_env_upwards() {
599        let tmp = TempDir::new().expect("tmp");
600        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
601
602        write_file(
603            &root.join("setup/graphql/schema.env"),
604            "export GQL_SCHEMA_FILE=schema.gql\n",
605        );
606        std::fs::create_dir_all(root.join("setup/graphql/ops")).expect("mkdir");
607
608        let setup_dir =
609            resolve_gql_setup_dir_for_schema(&root, &root, Some(&root.join("setup/graphql/ops")))
610                .expect("resolve");
611
612        assert_eq!(setup_dir, root.join("setup/graphql"));
613    }
614
615    #[test]
616    fn config_grpc_call_falls_back_to_setup_grpc_in_invocation_dir() {
617        let tmp = TempDir::new().expect("tmp");
618        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
619
620        write_file(
621            &root.join("setup/grpc/endpoints.env"),
622            "export GRPC_URL_LOCAL=127.0.0.1:50051\n",
623        );
624        write_file(
625            &root.join("requests/health.grpc.json"),
626            r#"{"method":"health.HealthService/Check","body":{}}"#,
627        );
628
629        let setup_dir = resolve_grpc_setup_dir_for_call(
630            &root,
631            &root,
632            &root.join("requests/health.grpc.json"),
633            None,
634        )
635        .expect("resolve");
636
637        assert_eq!(setup_dir, root.join("setup/grpc"));
638    }
639
640    #[test]
641    fn resolved_setup_grpc_uses_grpc_history_default() {
642        let tmp = TempDir::new().expect("tmp");
643        let root = std::fs::canonicalize(tmp.path()).expect("root abs");
644        let setup = root.join("setup/grpc");
645        std::fs::create_dir_all(&setup).expect("mkdir");
646
647        let resolved = ResolvedSetup::grpc(setup.clone(), None);
648        assert_eq!(resolved.history_file, setup.join(".grpc_history"));
649        assert!(resolved.tokens_env.is_some());
650        assert!(resolved.tokens_local_env.is_some());
651    }
652}