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/// The documents [`documents`] would return that can still be read, each on its own.
64///
65/// For a run whose configuration already failed to load, and so is past stopping: a
66/// document that cannot be read — or a walk upward that cannot finish — drops that one
67/// document, never the readable one beside it. `working_directory` is `None` when there is
68/// none to search from, and then only the user-level document is looked for. Never a
69/// substitute for [`documents`], which is right to stop on exactly those failures.
70#[must_use]
71pub fn readable_documents(
72 working_directory: Option<&Path>,
73 environment: &Environment,
74) -> Vec<Document> {
75 let project =
76 working_directory.and_then(|directory| nearest_project_document(directory).ok().flatten());
77 [user_document_path(environment), project]
78 .into_iter()
79 .flatten()
80 .filter_map(|path| {
81 let text = read_optional(&path).ok().flatten()?;
82 Some(Document { path, text })
83 })
84 .collect()
85}
86
87/// Where the user-level document lives, when this host says where that is.
88#[must_use]
89pub fn user_document_path(environment: &Environment) -> Option<PathBuf> {
90 Some(configuration_home(environment)?.join(USER_DOCUMENT_RELATIVE_PATH))
91}
92
93/// Where the credentials file lives, honouring the override variable.
94#[must_use]
95pub fn secrets_path(environment: &Environment) -> Option<PathBuf> {
96 if let Some(override_path) = environment.non_empty(super::SECRETS_FILE_VARIABLE) {
97 return Some(PathBuf::from(override_path));
98 }
99 Some(configuration_home(environment)?.join(SECRETS_RELATIVE_PATH))
100}
101
102/// `$XDG_CONFIG_HOME`, or `$HOME/.config`, or nothing when neither is set.
103fn configuration_home(environment: &Environment) -> Option<PathBuf> {
104 if let Some(xdg) = environment.non_empty("XDG_CONFIG_HOME") {
105 return Some(PathBuf::from(xdg));
106 }
107 Some(PathBuf::from(environment.non_empty("HOME")?).join(".config"))
108}
109
110/// The nearest `onetaskgraph.yaml` at or above `working_directory`.
111///
112/// The walk stops at the first candidate that exists, whatever it is. Asking whether
113/// each one is a *file* would fold "there is nothing here" together with "there is a
114/// directory here" and "this user may not look" — and a document obstructed either of
115/// those ways would be walked straight past, leaving the run reading a configuration
116/// from further up the tree than the user believes. Existence is the question; what a
117/// candidate turns out to be is [`read_optional`]'s to report.
118///
119/// # Errors
120///
121/// Returns [`ConfigError::Read`] when a candidate cannot be examined at all.
122fn nearest_project_document(working_directory: &Path) -> Result<Option<PathBuf>, ConfigError> {
123 for directory in working_directory.ancestors() {
124 let candidate = directory.join(PROJECT_DOCUMENT_NAME);
125 match candidate.try_exists() {
126 Ok(true) => return Ok(Some(candidate)),
127 Ok(false) => {}
128 Err(error) => return Err(ConfigError::read(&candidate, &error)),
129 }
130 }
131 Ok(None)
132}
133
134/// Read `path`, treating "there is no such file" as "there is nothing here".
135///
136/// # Errors
137///
138/// Returns [`ConfigError::Read`] for every other way a read can fail — a directory
139/// in the way, or a file this user may not open. Those are worth stopping for:
140/// silently continuing would run against a configuration the user believes is loaded.
141pub fn read_optional(path: &Path) -> Result<Option<String>, ConfigError> {
142 match std::fs::read_to_string(path) {
143 Ok(text) => Ok(Some(text)),
144 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
145 Err(error) => Err(ConfigError::read(path, &error)),
146 }
147}