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}