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}