Skip to main content

onetaskgraph_core/
registry.rs

1//! The compile-time registry of plugin kinds.
2//!
3//! Every plugin this build compiled is named here. The two that reach a network —
4//! `github-projects` and `linear` — are each behind a cargo feature of this crate that no
5//! default enables, because a host that wants only a local store must not compile an HTTP
6//! and TLS stack it never runs. A kind this crate could register but this build left out is
7//! still known by name, so a configuration naming it is refused with the feature that
8//! enables it rather than as a plugin nobody has heard of.
9
10use std::{fmt, str::FromStr};
11
12use onetaskgraph_plugin_api::SourcePlugin;
13use serde::{Deserialize, Serialize};
14
15/// One of the plugin kinds this build has.
16///
17/// A [`SourceConfig`](crate::SourceConfig) holds one of these rather than the string a
18/// document spelled, so a configuration naming a plugin nothing answers to cannot exist
19/// past [`Config::from_document`](crate::Config::from_document). Resolution therefore has
20/// no "what if the registry does not have it" branch left to get wrong, and the refusal
21/// happens at the one place that can name the offending key.
22///
23/// Non-exhaustive because which variants exist is a matter of this crate's features, and
24/// cargo unifies features across a build: a match in another crate that is exhaustive
25/// without `linear` would stop compiling the moment anything else in the graph enabled it.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
27#[serde(into = "String", try_from = "String")]
28#[non_exhaustive]
29pub enum PluginKind {
30    /// GitHub Projects.
31    #[cfg(feature = "github-projects")]
32    GithubProjects,
33    /// The in-memory source the journeys are written against.
34    InMemory,
35    /// Linear.
36    #[cfg(feature = "linear")]
37    Linear,
38    /// A folder of Markdown files.
39    LocalMd,
40    /// A program of its own, speaking `docs/plugin-protocol.md` over stdio.
41    Subprocess,
42}
43
44impl PluginKind {
45    /// Every kind this build compiled, in the stable order [`registry`] reports them in.
46    pub const ALL: [Self; KIND_COUNT] = [
47        #[cfg(feature = "github-projects")]
48        Self::GithubProjects,
49        Self::InMemory,
50        #[cfg(feature = "linear")]
51        Self::Linear,
52        Self::LocalMd,
53        Self::Subprocess,
54    ];
55
56    /// The name a configuration document's `plugin:` field names this kind by.
57    ///
58    /// Spelled here rather than read from the plugin so that matching a name costs no
59    /// allocation. `every_plugin_kind_names_the_kind_its_own_plugin_reports` is what
60    /// keeps the two from drifting.
61    #[must_use]
62    pub fn as_str(self) -> &'static str {
63        match self {
64            #[cfg(feature = "github-projects")]
65            Self::GithubProjects => "github-projects",
66            Self::InMemory => "in-memory",
67            #[cfg(feature = "linear")]
68            Self::Linear => "linear",
69            Self::LocalMd => "local-md",
70            Self::Subprocess => "subprocess",
71        }
72    }
73
74    /// The kind called `name`, or `None` when nothing in this build answers to it.
75    #[must_use]
76    pub fn parse(name: &str) -> Option<Self> {
77        Self::ALL.into_iter().find(|kind| kind.as_str() == name)
78    }
79
80    /// This kind's factory.
81    ///
82    /// Total, which is the point of the type: a `PluginKind` is one of the kinds this build
83    /// compiled, so there is no absent-plugin case for a caller to handle or forget.
84    #[must_use]
85    pub fn plugin(self) -> Box<dyn SourcePlugin> {
86        match self {
87            #[cfg(feature = "github-projects")]
88            Self::GithubProjects => Box::new(onetaskgraph_github_projects::Plugin),
89            Self::InMemory => Box::new(onetaskgraph_in_memory::Plugin),
90            #[cfg(feature = "linear")]
91            Self::Linear => Box::new(onetaskgraph_linear::Plugin),
92            Self::LocalMd => Box::new(onetaskgraph_local_md::Plugin),
93            Self::Subprocess => Box::new(crate::subprocess::SubprocessPlugin),
94        }
95    }
96}
97
98impl TryFrom<String> for PluginKind {
99    type Error = String;
100
101    /// Read a kind this build has, and refuse one it does not — naming what it does have.
102    ///
103    /// Where a document or a protocol message carries a plugin kind, this is what keeps
104    /// "a kind" and "a kind this binary can build" the same thing: an unknown name stops
105    /// being representable at the field rather than at a lookup somewhere later.
106    fn try_from(value: String) -> Result<Self, Self::Error> {
107        Self::parse(&value).ok_or_else(|| match omitted_feature(&value) {
108            Some(feature) => format!(
109                "the {value:?} plugin is not compiled into this build; enable the \
110                 `{feature}` feature of onetaskgraph-core to register it"
111            ),
112            None => format!(
113                "no plugin of this build is called {value:?}; it knows {}",
114                plugin_kinds().join(", ")
115            ),
116        })
117    }
118}
119
120impl From<PluginKind> for String {
121    fn from(value: PluginKind) -> Self {
122        value.as_str().to_owned()
123    }
124}
125
126impl fmt::Display for PluginKind {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(self.as_str())
129    }
130}
131
132impl FromStr for PluginKind {
133    type Err = String;
134
135    fn from_str(value: &str) -> Result<Self, Self::Err> {
136        value.to_owned().try_into()
137    }
138}
139
140/// Sizes [`PluginKind::ALL`] from the same `cfg`s that gate its entries, so that table
141/// compiles under every feature combination rather than the one a literal length would fit.
142const KIND_COUNT: usize =
143    3 + cfg!(feature = "github-projects") as usize + cfg!(feature = "linear") as usize;
144
145/// The kinds this crate can register that this build's features left out, each beside the
146/// feature that compiles it.
147///
148/// Spelled as the kind's name rather than as a [`PluginKind`], because a kind that was not
149/// compiled has no variant to name it by — which is what keeps [`PluginKind::plugin`] total.
150const OMITTED: &[(&str, &str)] = &[
151    #[cfg(not(feature = "github-projects"))]
152    ("github-projects", "github-projects"),
153    #[cfg(not(feature = "linear"))]
154    ("linear", "linear"),
155];
156
157/// The cargo feature of this crate that would compile the plugin called `kind`, when this
158/// build left it out; `None` for a kind this build has or a name no feature answers to.
159#[must_use]
160pub(crate) fn omitted_feature(kind: &str) -> Option<&'static str> {
161    OMITTED
162        .iter()
163        .find_map(|&(name, feature)| (name == kind).then_some(feature))
164}
165
166/// Every plugin kind this build knows, in a stable order.
167#[must_use]
168pub fn registry() -> Vec<Box<dyn SourcePlugin>> {
169    PluginKind::ALL.map(PluginKind::plugin).into()
170}
171
172/// The kind names in [`registry`], for help text and error messages.
173#[must_use]
174pub fn plugin_kinds() -> Vec<&'static str> {
175    registry().iter().map(|plugin| plugin.kind()).collect()
176}
177
178/// The plugin registered for `kind`, or `None` when nothing answers to that name.
179#[must_use]
180pub fn plugin_for(kind: &str) -> Option<Box<dyn SourcePlugin>> {
181    PluginKind::parse(kind).map(PluginKind::plugin)
182}