usage_config/source.rs
1//! Where a value came from.
2//!
3//! Provenance is not an extra pass here: it is the output of the only merge there is. hk grew
4//! a second parallel merge function purely to answer "where did this come from", and the two
5//! could disagree — so `hk config explain` could describe a resolution that never happened.
6//! Recording the origin as the value is chosen makes that class of bug unreachable.
7
8/// A kind of place a value can come from.
9///
10/// Deliberately open: usage knows about the command line, the environment, files and declared
11/// defaults, and every CLI in the fleet has at least one kind it reads itself — a git config,
12/// a pkl file, an `.npmrc`. Those declare a `source` in the spec and pass their own kind here.
13#[derive(Debug, Copy, Clone, PartialEq, Eq)]
14pub struct SourceKind(&'static str);
15
16impl SourceKind {
17 /// The command line.
18 pub const CLI: Self = Self("cli");
19 /// The environment.
20 pub const ENV: Self = Self("env");
21 /// A configuration file usage read itself.
22 pub const FILE: Self = Self("file");
23 /// The default the spec declares.
24 pub const DEFAULTS: Self = Self("defaults");
25 /// A value the CLI rewrote after merging — mise's `raw` implying `jobs = 1`.
26 ///
27 /// Its own kind so `explain` never claims a file said something it did not. A rewrite
28 /// that looked like it came from wherever the original value did is how a user ends up
29 /// editing a file that has nothing to do with the value they are seeing.
30 pub const COERCED: Self = Self("coerced");
31
32 pub const fn new(name: &'static str) -> Self {
33 Self(name)
34 }
35
36 pub const fn name(self) -> &'static str {
37 self.0
38 }
39}
40
41/// Which class of file a value came from, when it came from one.
42///
43/// Mirrors `scope=` on a spec's `file` node, and decides the origin's [`Trust`].
44#[derive(Debug, Copy, Clone, PartialEq, Eq)]
45pub enum FileScope {
46 /// Somewhere a repository can carry — the least trusted.
47 Project,
48 /// The user's own configuration.
49 Global,
50 /// Installed by whoever administers the machine.
51 System,
52}
53
54/// How much a place is trusted, which is what a setting's scope is about.
55///
56/// The distinction is not "was it a file": a pkl file, a git config or an `.npmrc` inside a
57/// repository is every bit as much a thing a checkout can carry as `hk.toml` is. Asking about
58/// files let every custom source — the natural use of [`Origin::new`] — walk straight past a
59/// check the spec calls a security property.
60///
61/// So the question is trust, every origin carries an answer, and the default for a kind usage
62/// does not recognize is the *least* trusting one. A layer that knows better says so with
63/// [`Origin::trusted_as`]; a layer that says nothing cannot accidentally be believed.
64#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
65pub enum Trust {
66 /// Somewhere a repository can carry — a project file, a git config in the checkout.
67 Project,
68 /// The user's own configuration, or the machine's.
69 Operator,
70 /// This invocation itself: the command line, the environment, a declared default.
71 Invocation,
72}
73
74/// The exact place a value came from.
75///
76/// Not just the kind: the *identifier*, because "from the environment" is not an answer a
77/// user can act on and `HK_JOBS` is. This is what makes `config explain` worth having.
78#[derive(Debug, Clone, PartialEq)]
79pub struct Origin {
80 pub kind: SourceKind,
81 /// The environment variable's name, the file's path, the git key — whatever a user would
82 /// have to go and edit.
83 pub identifier: String,
84 /// How much this place is trusted, which is what the scope check reads.
85 pub trust: Trust,
86}
87
88impl Origin {
89 /// An origin of the given kind.
90 ///
91 /// The trust follows the kind: this invocation for the command line, the environment and
92 /// the built-ins, and [`Trust::Project`] for anything else — because a kind usage does not
93 /// recognize is one it cannot vouch for, and a check that has to be remembered by each new
94 /// layer is one a new layer will forget. Say otherwise with [`Origin::trusted_as`].
95 pub fn new(kind: SourceKind, identifier: impl Into<String>) -> Self {
96 let trust = match kind {
97 SourceKind::CLI | SourceKind::ENV | SourceKind::DEFAULTS | SourceKind::COERCED => {
98 Trust::Invocation
99 }
100 _ => Trust::Project,
101 };
102 Self {
103 kind,
104 identifier: identifier.into(),
105 trust,
106 }
107 }
108
109 /// The same origin, trusted as stated.
110 ///
111 /// For a custom layer that knows where it read from: a git config in `$HOME` is the
112 /// user's own, while one in the checkout is not.
113 pub fn trusted_as(mut self, trust: Trust) -> Self {
114 self.trust = trust;
115 self
116 }
117
118 /// An origin in a config file of the given class.
119 pub fn file(identifier: impl Into<String>, scope: FileScope) -> Self {
120 Self {
121 kind: SourceKind::FILE,
122 identifier: identifier.into(),
123 trust: match scope {
124 FileScope::Project => Trust::Project,
125 FileScope::Global | FileScope::System => Trust::Operator,
126 },
127 }
128 }
129
130 /// The declared default.
131 ///
132 /// Named for what it *is* rather than spelled `Default::default`, because an `Origin` has
133 /// no sensible zero — every one of them names a real place.
134 pub fn declared_default() -> Self {
135 Self::new(SourceKind::DEFAULTS, "the default")
136 }
137
138 /// How to describe this in one phrase: `HK_JOBS`, `hk.toml`, `the default`.
139 pub fn describe(&self) -> &str {
140 &self.identifier
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn a_kind_usage_does_not_know_is_still_a_kind() {
150 // hk's git config, aube's .npmrc: the reason this is not a closed enum.
151 let git = SourceKind::new("git");
152 assert_eq!(git.name(), "git");
153 assert_ne!(git, SourceKind::FILE);
154 // And the built-ins are distinguishable from each other, which the scope check and
155 // `explain` both depend on.
156 assert_ne!(SourceKind::CLI, SourceKind::ENV);
157 assert_ne!(SourceKind::DEFAULTS, SourceKind::COERCED);
158 }
159
160 #[test]
161 fn a_kind_usage_cannot_vouch_for_is_trusted_least() {
162 // The hole this closes: the scope check used to ask whether an origin was a *file*, so
163 // every custom source — a pkl file, a git config, an `.npmrc`, all built with
164 // `Origin::new` — walked straight past it. A pkl file in a checkout is exactly as much
165 // a thing a repository can carry as `hk.toml` is.
166 assert_eq!(
167 Origin::new(SourceKind::new("pkl"), "jobs").trust,
168 Trust::Project
169 );
170 assert_eq!(
171 Origin::new(SourceKind::new("git"), "hk.jobs").trust,
172 Trust::Project
173 );
174 // The kinds usage does know are the invocation itself.
175 for kind in [SourceKind::CLI, SourceKind::ENV, SourceKind::COERCED] {
176 assert_eq!(Origin::new(kind, "x").trust, Trust::Invocation, "{kind:?}");
177 }
178 assert_eq!(Origin::declared_default().trust, Trust::Invocation);
179 // A layer that knows better says so, rather than being believed by default.
180 assert_eq!(
181 Origin::new(SourceKind::new("git"), "hk.jobs")
182 .trusted_as(Trust::Operator)
183 .trust,
184 Trust::Operator
185 );
186 // And a file's class decides its trust.
187 assert_eq!(
188 Origin::file("hk.toml", FileScope::Project).trust,
189 Trust::Project
190 );
191 for scope in [FileScope::Global, FileScope::System] {
192 assert_eq!(Origin::file("x", scope).trust, Trust::Operator, "{scope:?}");
193 }
194 }
195}