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