Skip to main content

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}