Skip to main content

onetaskgraph_core/config/
discovery.rs

1//! The only module here that touches the filesystem.
2//!
3//! Finding the documents and reading them is all this does; parsing them, merging
4//! them and deciding what they mean happens above, on values. Keeping the boundary
5//! at one module is what lets every rule this layer has be tested against text
6//! rather than against a directory somebody had to build first — and it is why the
7//! secrets file is read here too, beside the documents, rather than by the module
8//! that parses it.
9
10use std::path::{Path, PathBuf};
11
12use crate::Environment;
13
14use super::ConfigError;
15
16/// The document discovered upward from the working directory.
17pub const PROJECT_DOCUMENT_NAME: &str = "onetaskgraph.yaml";
18
19/// The user-level document, under the configuration home.
20pub const USER_DOCUMENT_RELATIVE_PATH: &str = "onetaskgraph/config.yaml";
21
22/// The credentials file, under the configuration home.
23pub const SECRETS_RELATIVE_PATH: &str = "onetaskgraph/secrets.env";
24
25/// One configuration document, as read.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Document {
28    /// Where it was read from.
29    pub path: PathBuf,
30    /// What it holds.
31    pub text: String,
32}
33
34/// Every configuration document that applies, **lowest precedence first**.
35///
36/// That is the user-level document, then the nearest `onetaskgraph.yaml` at or above
37/// `working_directory`. The nearest one alone: a project's document layers over the
38/// user's, and stacking every ancestor as well would make what a command reads depend
39/// on how deep in a tree it was run from.
40///
41/// # Errors
42///
43/// Returns [`ConfigError::Read`] when a document exists but cannot be read. A
44/// document that is not there is not an error — it is the ordinary case.
45pub fn documents(
46    working_directory: &Path,
47    environment: &Environment,
48) -> Result<Vec<Document>, ConfigError> {
49    let mut found = Vec::new();
50    if let Some(path) = user_document_path(environment)
51        && let Some(text) = read_optional(&path)?
52    {
53        found.push(Document { path, text });
54    }
55    if let Some(path) = nearest_project_document(working_directory)?
56        && let Some(text) = read_optional(&path)?
57    {
58        found.push(Document { path, text });
59    }
60    Ok(found)
61}
62
63/// Where the user-level document lives, when this host says where that is.
64#[must_use]
65pub fn user_document_path(environment: &Environment) -> Option<PathBuf> {
66    Some(configuration_home(environment)?.join(USER_DOCUMENT_RELATIVE_PATH))
67}
68
69/// Where the credentials file lives, honouring the override variable.
70#[must_use]
71pub fn secrets_path(environment: &Environment) -> Option<PathBuf> {
72    if let Some(override_path) = environment.non_empty(super::SECRETS_FILE_VARIABLE) {
73        return Some(PathBuf::from(override_path));
74    }
75    Some(configuration_home(environment)?.join(SECRETS_RELATIVE_PATH))
76}
77
78/// `$XDG_CONFIG_HOME`, or `$HOME/.config`, or nothing when neither is set.
79fn configuration_home(environment: &Environment) -> Option<PathBuf> {
80    if let Some(xdg) = environment.non_empty("XDG_CONFIG_HOME") {
81        return Some(PathBuf::from(xdg));
82    }
83    Some(PathBuf::from(environment.non_empty("HOME")?).join(".config"))
84}
85
86/// The nearest `onetaskgraph.yaml` at or above `working_directory`.
87///
88/// The walk stops at the first candidate that exists, whatever it is. Asking whether
89/// each one is a *file* would fold "there is nothing here" together with "there is a
90/// directory here" and "this user may not look" — and a document obstructed either of
91/// those ways would be walked straight past, leaving the run reading a configuration
92/// from further up the tree than the user believes. Existence is the question; what a
93/// candidate turns out to be is [`read_optional`]'s to report.
94///
95/// # Errors
96///
97/// Returns [`ConfigError::Read`] when a candidate cannot be examined at all.
98fn nearest_project_document(working_directory: &Path) -> Result<Option<PathBuf>, ConfigError> {
99    for directory in working_directory.ancestors() {
100        let candidate = directory.join(PROJECT_DOCUMENT_NAME);
101        match candidate.try_exists() {
102            Ok(true) => return Ok(Some(candidate)),
103            Ok(false) => {}
104            Err(error) => return Err(ConfigError::read(&candidate, &error)),
105        }
106    }
107    Ok(None)
108}
109
110/// Read `path`, treating "there is no such file" as "there is nothing here".
111///
112/// # Errors
113///
114/// Returns [`ConfigError::Read`] for every other way a read can fail — a directory
115/// in the way, or a file this user may not open. Those are worth stopping for:
116/// silently continuing would run against a configuration the user believes is loaded.
117pub fn read_optional(path: &Path) -> Result<Option<String>, ConfigError> {
118    match std::fs::read_to_string(path) {
119        Ok(text) => Ok(Some(text)),
120        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
121        Err(error) => Err(ConfigError::read(path, &error)),
122    }
123}