Skip to main content

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}