onetaskgraph_core/resolve.rs
1//! Turning a configuration into live sources.
2//!
3//! Two steps, deliberately separable. [`validate_sources`] runs at load, for every
4//! verb, and refuses a source whose `config:` block does not match the schema its
5//! plugin declares — the plugin itself is already one this build has, because
6//! [`SourceConfig::plugin`] is a [`PluginKind`](crate::PluginKind) and no other kind
7//! can be represented. [`resolve`] then
8//! builds the sources a command actually needs. The order is the point: a typo in a
9//! per-source field is refused while the user is still looking at the file that
10//! caused it, rather than surfacing as a confusing failure inside the first HTTP
11//! call that source makes.
12
13use std::fmt;
14
15use jsonschema::error::ValidationErrorKind;
16use onetaskgraph_plugin_api::{SecretResolver, SourceError, SourceName, SourcePlugin, TaskSource};
17use serde_json::Value;
18
19use crate::config::{Config, ConfigError, SourceConfig};
20use crate::plan::SourceFailure;
21
22/// One configured source, built and ready to answer.
23///
24/// Held behind its accessors, and constructible only by [`resolve`], because `kind` is a
25/// claim about `source` rather than a value beside it: a caller that could write the two
26/// independently could say `linear` over a source that reports `local-md`, and the plan a
27/// query reports names the kind. Building it where the plugin builds the source is what
28/// makes the pair an invariant instead of something every reader has to re-check.
29pub struct ResolvedSource {
30 name: SourceName,
31 source: Box<dyn TaskSource>,
32}
33
34impl ResolvedSource {
35 /// Adopt a source under `name`.
36 ///
37 /// The kind is not a second field a caller could set: it is read back off the source
38 /// through [`TaskSource::kind`], so the pair cannot disagree and the plan a query
39 /// reports names the kind the source itself claims. That also makes this the seam a
40 /// source built outside the registry arrives through — the engine's own tests today,
41 /// the subprocess-hosted plugins the protocol document describes later.
42 #[must_use]
43 pub fn adopt(name: SourceName, source: Box<dyn TaskSource>) -> Self {
44 Self { name, source }
45 }
46
47 /// The name the configuration gave it, which qualifies every id it returns.
48 #[must_use]
49 pub fn name(&self) -> &SourceName {
50 &self.name
51 }
52
53 /// The plugin kind that built it, as the source itself reports it.
54 ///
55 /// A `&str` rather than a [`PluginKind`](crate::PluginKind): a subprocess-hosted
56 /// plugin reports a kind no compile-time enumeration can hold, which is also why
57 /// [`SourcePlan::kind`](crate::SourcePlan::kind) is a `String`.
58 #[must_use]
59 pub fn kind(&self) -> &str {
60 self.source.kind()
61 }
62
63 /// The source itself.
64 #[must_use]
65 pub fn source(&self) -> &dyn TaskSource {
66 self.source.as_ref()
67 }
68}
69
70/// One configured source that could not be built at all.
71///
72/// A missing credential, a plugin whose implementation has not landed, a `config:` block
73/// its own plugin refuses at build time: none of them is a reason to answer nothing for
74/// the *other* sources, so this is carried beside the ones that built and reported as a
75/// [`SourceFailure`] in every response.
76#[derive(Debug, Clone, PartialEq)]
77pub struct UnavailableSource {
78 name: SourceName,
79 /// The plugin kind that was asked to build it.
80 ///
81 /// The kind a plugin reports, which is an open vocabulary rather than an
82 /// under-modelled one: a subprocess-hosted plugin reports a kind arriving over the
83 /// wire from a binary this workspace never compiled, so no compile-time type can
84 /// enumerate it, and a newtype over the same string would only move where an
85 /// unrelated value is accepted. This is the same field, and the same reason, as
86 /// `SourcePlan.kind` and `SourceListing.kind`, where the contract fixes it as a
87 /// string outright.
88 // llmlint: ignore[invalid_states_unrepresentable] the reason above, recorded a third
89 // time because this is the third site the same field appears at: `kind` is what
90 // `SourcePlan.kind` (plan.rs) and `SourceListing.kind` (engine/mod.rs) already carry
91 // as approved contract text, and narrowing it here alone would only make this crate
92 // disagree with the two documents it renders into.
93 kind: &'static str,
94 error: SourceError,
95}
96
97impl UnavailableSource {
98 /// The name the configuration gave it.
99 #[must_use]
100 pub fn name(&self) -> &SourceName {
101 &self.name
102 }
103
104 /// The plugin kind that was asked to build it.
105 #[must_use]
106 pub fn kind(&self) -> &str {
107 self.kind
108 }
109
110 /// Why it did not build.
111 #[must_use]
112 pub fn error(&self) -> &SourceError {
113 &self.error
114 }
115
116 /// The same thing, as a response carries it.
117 #[must_use]
118 pub fn failure(&self) -> SourceFailure {
119 SourceFailure {
120 source: self.name.clone(),
121 error: self.error.clone(),
122 }
123 }
124}
125
126impl fmt::Debug for ResolvedSource {
127 /// Name and kind, the kind spelled the way a configuration spells it rather than
128 /// the way Rust spells the variant. A live source has no meaningful `Debug` of its
129 /// own, and one that did would be a rendering of a user's work — which nothing
130 /// outside the plugin may hold.
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.debug_struct("ResolvedSource")
133 .field("name", &self.name)
134 .field("kind", &self.kind())
135 .finish_non_exhaustive()
136 }
137}
138
139/// Check every configured source without building any of them.
140///
141/// # Errors
142///
143/// Returns [`ConfigError::Setting`] naming `sources.<name>.config...` for a block that
144/// does not match its plugin's declared schema.
145pub fn validate_sources(config: &Config) -> Result<(), ConfigError> {
146 for (name, source) in config.sources() {
147 checked_plugin(name, source)?;
148 }
149 Ok(())
150}
151
152/// Build every configured source, in name order.
153///
154/// The order is the map's, so two runs over one configuration produce the same
155/// sources in the same sequence — which is what makes a multi-source result stable
156/// enough to page through.
157///
158/// # Errors
159///
160/// Returns what [`validate_sources`] returns, and [`ConfigError::Setting`] naming
161/// `sources.<name>` when the plugin itself refuses to build the source.
162pub fn resolve(
163 config: &Config,
164 secrets: &dyn SecretResolver,
165) -> Result<Vec<ResolvedSource>, ConfigError> {
166 validate_sources(config)?;
167 let (built, unavailable) = resolve_available(config, secrets);
168 match unavailable.first() {
169 None => Ok(built),
170 Some(failed) => Err(ConfigError::setting(
171 format!("sources.{}", failed.name()),
172 failed.error().to_string(),
173 format!(
174 "correct that source's configuration, or remove it — `onetaskgraph \
175 config show` reports every setting under `sources.{}` and the layer it \
176 came from.",
177 failed.name()
178 ),
179 )),
180 }
181}
182
183/// Build every configured source, keeping the ones that refused beside the ones that
184/// built.
185///
186/// This is what the engine resolves through, and the difference from [`resolve`] is the
187/// whole point: one source with an expired token must not stop the other two from
188/// answering. A refusal here is reported per source, exactly as a source that fails
189/// mid-query is.
190///
191/// The `config:` blocks are not re-checked, because a [`Config`] cannot exist holding one
192/// its own plugin would refuse — [`Config::from_document`](crate::Config::from_document)
193/// checks every block against its plugin's declared schema on the way in.
194#[must_use]
195pub fn resolve_available(
196 config: &Config,
197 secrets: &dyn SecretResolver,
198) -> (Vec<ResolvedSource>, Vec<UnavailableSource>) {
199 let mut built = Vec::new();
200 let mut unavailable = Vec::new();
201 for (name, source) in config.sources() {
202 let plugin = source.plugin().plugin();
203 match plugin.build(name, source.config(), secrets) {
204 Ok(source) => built.push(ResolvedSource::adopt(name.clone(), source)),
205 Err(error) => unavailable.push(UnavailableSource {
206 name: name.clone(),
207 kind: plugin.kind(),
208 error,
209 }),
210 }
211 }
212 (built, unavailable)
213}
214
215/// The plugin this source names, with its `config:` block already checked.
216fn checked_plugin(
217 name: &SourceName,
218 source: &SourceConfig,
219) -> Result<Box<dyn SourcePlugin>, ConfigError> {
220 let plugin = source.plugin().plugin();
221 check_block(name, source.config(), plugin.as_ref())?;
222 Ok(plugin)
223}
224
225/// Check one source's `config:` block against the schema its plugin declares.
226fn check_block(
227 name: &SourceName,
228 block: &Value,
229 plugin: &dyn SourcePlugin,
230) -> Result<(), ConfigError> {
231 // A plugin's own schema is this build's, not a user's, so a schema that will not
232 // compile is a defect in this binary rather than something a user did. It is still
233 // reported rather than panicked on: a user whose one broken source is a plugin they
234 // do not use can drop that source and carry on, which a panic would not let them do.
235 // `every_registered_plugin_declares_a_schema_that_compiles_and_accepts_a_valid_block`
236 // is what keeps it from reaching anybody in the first place.
237 let schema = plugin.config_schema();
238 let validator = jsonschema::validator_for(schema.as_value()).map_err(|error| {
239 ConfigError::setting(
240 format!("sources.{name}.plugin"),
241 format!(
242 "the `{}` plugin declares a configuration schema this build cannot \
243 compile: {error}",
244 plugin.kind()
245 ),
246 "that is a defect in this binary rather than in your configuration — please \
247 report it, naming the plugin above. Removing that source lets the rest of \
248 this configuration run in the meantime.",
249 )
250 })?;
251
252 let Some(problem) = validator.iter_errors(block).next() else {
253 return Ok(());
254 };
255
256 // A plugin whose source is not written yet declares a schema with no properties at
257 // all, which forbids every field — and a validator has nothing to say about that
258 // beyond "false schema does not allow 7", which names neither the field nor the
259 // reason. Both are worth saying plainly.
260 if schema.as_value().get("properties").is_none()
261 && let Some(fields) = block.as_object()
262 && let Some(first) = fields.keys().next()
263 {
264 return Err(ConfigError::setting(
265 format!("sources.{name}.config.{first}"),
266 format!(
267 "the `{}` plugin declares no configuration fields, so its `config:` block \
268 must be empty or absent; this one sets {}",
269 plugin.kind(),
270 fields.keys().cloned().collect::<Vec<_>>().join(", ")
271 ),
272 format!(
273 "remove those fields — `onetaskgraph schema` prints what this plugin \
274 accepts under `plugin_config.{}`.",
275 plugin.kind()
276 ),
277 ));
278 }
279
280 // A validator reports an unexpected field against the *object* that holds it, so
281 // the path alone would name the block and leave the user to find the field inside
282 // the message. The field is the whole of what they have to go and fix, so it is
283 // lifted into the key.
284 let pointer = problem.instance_path().to_string().replace('/', ".");
285 let unexpected = match problem.kind() {
286 ValidationErrorKind::AdditionalProperties { unexpected } => unexpected.first(),
287 _ => None,
288 };
289 let key = match unexpected {
290 Some(field) => format!("sources.{name}.config{pointer}.{field}"),
291 None => format!("sources.{name}.config{pointer}"),
292 };
293 Err(ConfigError::setting(
294 key,
295 problem.to_string(),
296 format!(
297 "check that field against the `{}` plugin's schema — `onetaskgraph schema` \
298 prints it under `plugin_config.{}`.",
299 plugin.kind(),
300 plugin.kind()
301 ),
302 ))
303}