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}