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