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::{
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}