fraiseql_cli/config/discovery.rs
1//! Which `fraiseql.toml` applies to a schema input.
2//!
3//! One answer for every command that pairs a schema with its project config, so
4//! `compile` and `run` can never read two different files for one schema (#1387).
5//! `compile` used to read `fraiseql.toml` from the working directory while `run`
6//! read the one beside the schema: compiled from a sibling subgraph's directory,
7//! the artifact silently took the other subgraph's settings.
8//!
9//! The rule, first match wins:
10//!
11//! 1. the input **is** a `.toml` file — it is the project config;
12//! 2. an explicit `--config <path>`, which must exist;
13//! 3. the nearest `fraiseql.toml` in the input's directory or an ancestor of it, stopping at the
14//! repository root (the first directory holding `.git`), so a stray file above the project is
15//! never picked up;
16//! 4. none: defaults apply.
17//!
18//! The working directory plays no part: the same command, run from anywhere,
19//! compiles a schema with the same config.
20
21use std::{
22 fmt,
23 path::{Path, PathBuf},
24};
25
26use anyhow::{Context, Result, bail};
27
28/// The file name a project config is discovered by.
29pub const PROJECT_CONFIG_FILE: &str = "fraiseql.toml";
30
31/// Where a schema input's project config comes from.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum ConfigSource {
34 /// The input is itself a TOML project file.
35 Input(PathBuf),
36 /// Named on the command line with `--config`.
37 Explicit(PathBuf),
38 /// The nearest `fraiseql.toml` beside or above the input.
39 Discovered(PathBuf),
40 /// No config applies; `searched_from` is the directory the search started in.
41 Absent {
42 /// The input's directory, where discovery began.
43 searched_from: PathBuf,
44 },
45}
46
47impl ConfigSource {
48 /// Resolve the config for `input`, honouring an explicit `--config` path.
49 ///
50 /// # Errors
51 ///
52 /// Returns an error when `explicit` is given and does not name a file, when
53 /// `explicit` is given for an input that is itself a TOML project file, or when
54 /// the input's absolute path cannot be determined.
55 pub fn resolve(input: &Path, explicit: Option<&Path>) -> Result<Self> {
56 let is_toml = input
57 .extension()
58 .and_then(|ext| ext.to_str())
59 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
60 if is_toml {
61 if let Some(explicit) = explicit {
62 bail!(
63 "--config {} cannot be combined with the TOML input {}: that file is \
64 already the project config. Pass one or the other.",
65 explicit.display(),
66 input.display()
67 );
68 }
69 return Ok(Self::Input(input.to_path_buf()));
70 }
71 if let Some(explicit) = explicit {
72 if !explicit.is_file() {
73 bail!("--config {}: no such file", explicit.display());
74 }
75 return Ok(Self::Explicit(explicit.to_path_buf()));
76 }
77
78 let absolute = std::path::absolute(input)
79 .with_context(|| format!("Cannot resolve the directory of {}", input.display()))?;
80 let start = absolute.parent().unwrap_or(&absolute).to_path_buf();
81 Ok(discover_from(&start).map_or(
82 Self::Absent {
83 searched_from: start,
84 },
85 Self::Discovered,
86 ))
87 }
88
89 /// The project config file to read, if any.
90 ///
91 /// `None` both when no config applies and when the input is itself the
92 /// project file (which the TOML workflow reads as a schema, not as a
93 /// [`TomlProjectConfig`](super::TomlProjectConfig)).
94 #[must_use]
95 pub fn project_config(&self) -> Option<&Path> {
96 match self {
97 Self::Explicit(path) | Self::Discovered(path) => Some(path),
98 Self::Input(_) | Self::Absent { .. } => None,
99 }
100 }
101}
102
103impl fmt::Display for ConfigSource {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 Self::Input(path) => write!(f, "{} (the input)", path.display()),
107 Self::Explicit(path) => write!(f, "{} (--config)", path.display()),
108 Self::Discovered(path) => write!(f, "{}", path.display()),
109 Self::Absent { searched_from } => write!(
110 f,
111 "none — no {PROJECT_CONFIG_FILE} in {} or above it; defaults apply",
112 searched_from.display()
113 ),
114 }
115 }
116}
117
118/// The nearest `fraiseql.toml` in `start` or its ancestors, up to the repository root.
119fn discover_from(start: &Path) -> Option<PathBuf> {
120 for dir in start.ancestors() {
121 let candidate = dir.join(PROJECT_CONFIG_FILE);
122 if candidate.is_file() {
123 return Some(candidate);
124 }
125 if dir.join(".git").exists() {
126 return None;
127 }
128 }
129 None
130}
131
132#[cfg(test)]
133mod tests;