Skip to main content

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;