Skip to main content

nextest_runner/config/core/
paths.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! File locations and invocation-relative diagnostics for repository configuration.
5
6use super::NextestConfig;
7use crate::errors::ConfigPathsCaptureError;
8use camino::{Utf8Path, Utf8PathBuf};
9use camino_anchored::{
10    AbsUtf8PathBuf, AnchoredPath, CurrentDirError, DisplayPath, PathAnchor, RelUtf8PathBuf,
11    ResolvePathError,
12};
13use std::{
14    hash::{Hash, Hasher},
15    sync::Arc,
16};
17use thiserror::Error;
18
19/// Tracks the directory from which nextest was invoked.
20#[derive(Clone, Debug)]
21pub struct InvocationDir(PathAnchor);
22
23impl InvocationDir {
24    /// Creates a new `InvocationDir`, capturing the process's current directory.
25    pub fn capture() -> Result<Self, CurrentDirError> {
26        PathAnchor::current_dir().map(Self)
27    }
28
29    /// Creates a new `InvocationDir` from a provided directory.
30    pub fn new(directory: AbsUtf8PathBuf) -> Self {
31        Self(PathAnchor::new(directory))
32    }
33
34    /// Resolves a user-provided configuration path.
35    pub fn resolve_input(&self, path: &Utf8Path) -> Result<ConfigPath, ConfigPathResolveError> {
36        let resolved = self
37            .0
38            .resolve_input(path)
39            .map_err(|error| ConfigPathResolveError::new(path, error))?;
40        Ok(ConfigPath(Arc::new(resolved)))
41    }
42
43    fn resolve_absolute(&self, path: AbsUtf8PathBuf) -> ConfigPath {
44        ConfigPath(Arc::new(self.0.resolve_absolute(path)))
45    }
46}
47
48/// Tracks the workspace directory used for config discovery, including
49/// workspace remapping.
50#[derive(Clone, Debug)]
51pub struct WorkspaceRoot(AbsUtf8PathBuf);
52
53impl WorkspaceRoot {
54    /// Uses an absolute workspace directory.
55    pub fn new(path: AbsUtf8PathBuf) -> Self {
56        Self(path)
57    }
58
59    /// Returns the absolute workspace directory.
60    pub fn as_path(&self) -> &Utf8Path {
61        self.0.as_path()
62    }
63}
64
65/// Separate invocation and workspace directories shared by both config loaders.
66#[derive(Clone, Debug)]
67pub struct ConfigPaths {
68    invocation: InvocationDir,
69    workspace_root: WorkspaceRoot,
70}
71
72impl ConfigPaths {
73    /// Captures the invocation directory and resolves the workspace directory.
74    pub fn capture(
75        workspace_root: impl Into<Utf8PathBuf>,
76    ) -> Result<Self, ConfigPathsCaptureError> {
77        let invocation = InvocationDir::capture().map_err(ConfigPathsCaptureError::CurrentDir)?;
78        let workspace_root = invocation
79            .0
80            .resolve_input(workspace_root.into())
81            .map_err(ConfigPathsCaptureError::WorkspaceRoot)?;
82        Ok(Self::new(
83            invocation,
84            WorkspaceRoot::new(workspace_root.into_absolute()),
85        ))
86    }
87
88    /// Uses explicit invocation and workspace directories.
89    pub fn new(invocation: InvocationDir, workspace_root: WorkspaceRoot) -> Self {
90        Self {
91            invocation,
92            workspace_root,
93        }
94    }
95
96    /// Returns the workspace directory for discovery.
97    pub fn workspace_root(&self) -> &WorkspaceRoot {
98        &self.workspace_root
99    }
100
101    /// Resolves an explicit config input against the invocation directory.
102    pub fn resolve_input(&self, path: &Utf8Path) -> Result<ConfigPath, ConfigPathResolveError> {
103        self.invocation.resolve_input(path)
104    }
105
106    /// Locates a repository config file relative to the workspace directory.
107    pub fn repository_config(&self, relative: &RelUtf8PathBuf) -> ConfigPath {
108        self.invocation
109            .resolve_absolute(self.workspace_root.0.join(relative))
110    }
111
112    /// Locates the shared repository config file.
113    pub fn shared_config(&self) -> ConfigPath {
114        self.repository_config(
115            &RelUtf8PathBuf::new(NextestConfig::CONFIG_PATH)
116                .expect("the shared config path is relative"),
117        )
118    }
119}
120
121/// A config file's absolute location, as well as invocation-specific diagnostic
122/// spelling.
123#[derive(Clone, Debug)]
124pub struct ConfigPath(Arc<AnchoredPath>);
125
126impl ConfigPath {
127    /// Returns the absolute file location for I/O.
128    pub fn absolute_path(&self) -> &Utf8Path {
129        self.0.absolute().as_path()
130    }
131
132    /// Displays the path relative to the invocation directory when possible.
133    pub fn display(&self) -> DisplayPath<'_> {
134        self.0.display()
135    }
136}
137
138impl PartialEq for ConfigPath {
139    fn eq(&self, other: &Self) -> bool {
140        self.0.absolute() == other.0.absolute()
141    }
142}
143
144impl Eq for ConfigPath {}
145
146impl Hash for ConfigPath {
147    fn hash<H: Hasher>(&self, state: &mut H) {
148        self.0.absolute().hash(state);
149    }
150}
151
152/// An error establishing a configuration file's absolute location.
153#[derive(Debug, Error)]
154#[error("could not resolve configuration path `{path}`")]
155pub struct ConfigPathResolveError {
156    /// The input that could not be resolved.
157    pub path: Utf8PathBuf,
158    /// The reason resolution failed.
159    #[source]
160    pub error: ResolvePathError,
161}
162
163impl ConfigPathResolveError {
164    fn new(path: impl Into<Utf8PathBuf>, error: ResolvePathError) -> Self {
165        Self {
166            path: path.into(),
167            error,
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::{
176        config::core::{ConfigFileSelection, VersionOnlyConfig},
177        errors::ConfigParseErrorKind,
178    };
179    use camino_tempfile::tempdir;
180    use std::fs;
181
182    #[test]
183    fn invocation_and_workspace_are_independent() {
184        let temp = tempdir().unwrap();
185        let workspace = temp.path().join("workspace");
186        for cwd in [
187            workspace.clone(),
188            workspace.join("member"),
189            temp.path().to_owned(),
190        ] {
191            let paths = ConfigPaths::new(
192                InvocationDir::new(AbsUtf8PathBuf::new(cwd.clone()).unwrap()),
193                WorkspaceRoot::new(AbsUtf8PathBuf::new(workspace.clone()).unwrap()),
194            );
195            let repository =
196                paths.repository_config(&RelUtf8PathBuf::new(".config/nextest.toml").unwrap());
197            assert_eq!(
198                repository.absolute_path(),
199                workspace.join(".config/nextest.toml")
200            );
201            let expected = repository
202                .absolute_path()
203                .strip_prefix(&cwd)
204                .unwrap_or(repository.absolute_path());
205            assert_eq!(repository.display().to_string(), expected.as_str());
206            let explicit = paths.resolve_input(Utf8Path::new("./custom.toml")).unwrap();
207            assert_eq!(explicit.absolute_path(), cwd.join("./custom.toml"));
208            assert_eq!(explicit.display().to_string(), "./custom.toml");
209        }
210    }
211
212    #[test]
213    fn parse_error_displays_invocation_relative_path() {
214        let temp = tempdir().unwrap();
215        let invocation = temp.path().join("invocation");
216        let workspace = temp.path().join("workspace");
217        fs::create_dir(&invocation).expect("created the invocation directory");
218        fs::write(invocation.join("custom.toml"), "nextest-version = [")
219            .expect("wrote the malformed config");
220        let paths = ConfigPaths::new(
221            InvocationDir::new(AbsUtf8PathBuf::new(invocation.clone()).unwrap()),
222            WorkspaceRoot::new(AbsUtf8PathBuf::new(workspace).unwrap()),
223        );
224
225        let error = VersionOnlyConfig::from_sources_with_paths(
226            &paths,
227            ConfigFileSelection::new(Some(Utf8Path::new("custom.toml"))),
228            &[][..],
229        )
230        .expect_err("the malformed config is rejected");
231
232        assert_eq!(error.config_file(), invocation.join("custom.toml"));
233        assert_eq!(error.display_config_file().to_string(), "custom.toml");
234        match error.kind() {
235            ConfigParseErrorKind::TomlParseError(_) => {}
236            other => panic!("expected a TOML parse error, found {other:?}"),
237        }
238    }
239}