Skip to main content

bake/
registry.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use crate::{Arguments, Context, Error, Format, Result, Task, output};
5use std::collections::{BTreeMap, BTreeSet};
6use std::io::{self, Write};
7use std::path::PathBuf;
8use std::sync::Arc;
9
10/// Task registry with deterministic listing and collision errors.
11pub struct Registry {
12    pub(crate) tasks: BTreeMap<String, Task>,
13}
14
15pub(crate) struct Invocation {
16    pub(crate) name: String,
17    pub(crate) arguments: Arguments,
18}
19
20/// A task descriptor registered by `#[bake::task]` in a linked crate.
21#[doc(hidden)]
22pub struct TaskRegistration {
23    pub factory: fn() -> Task,
24    pub module_path: &'static str,
25    pub builtin: bool,
26}
27
28#[linkme::distributed_slice]
29pub static TASK_REGISTRATIONS: [TaskRegistration];
30
31fn valid_name(name: &str) -> bool {
32    !name.is_empty()
33        && name.split(':').all(|part| {
34            part.chars()
35                .next()
36                .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_')
37                && part.chars().all(|character| {
38                    character.is_ascii_alphanumeric() || character == '_' || character == '-'
39                })
40        })
41}
42
43impl Registry {
44    pub fn new() -> Self {
45        let mut registry = Self {
46            tasks: BTreeMap::new(),
47        };
48        for task in output::builtins() {
49            registry.tasks.insert(task.name.clone(), task);
50        }
51        registry
52    }
53
54    /// Discover task functions registered by `#[bake::task]` in this executable
55    /// and its linked dependencies. Nested Rust modules become task namespaces.
56    ///
57    /// A dependency that contributes tasks must be referenced by the executable
58    /// (for example, `use bake_releases as _;`) so the linker includes it.
59    pub fn discover() -> Result<Self> {
60        let mut registry = Self::new();
61        for registration in TASK_REGISTRATIONS {
62            if registration.builtin {
63                continue;
64            }
65            let mut task = (registration.factory)();
66            namespace_from_module(&mut task, registration.module_path);
67            registry.register(task)?;
68        }
69        Ok(registry)
70    }
71
72    pub fn register(&mut self, task: Task) -> Result<&mut Self> {
73        validate_task(&task)?;
74        if self.tasks.contains_key(&task.name) {
75            return Err(Error::new(format!("duplicate task {:?}", task.name)));
76        }
77        self.tasks.insert(task.name.clone(), task);
78        Ok(self)
79    }
80
81    /// Replace an existing task explicitly, most often the default `output` task.
82    pub fn replace(&mut self, name: &str, task: Task) -> Result<&mut Self> {
83        validate_task(&task)?;
84        if task.name != name {
85            return Err(Error::new(format!(
86                "replacement task name {:?} does not match {name:?}",
87                task.name
88            )));
89        }
90        if !self.tasks.contains_key(name) {
91            return Err(Error::new(format!("cannot replace unknown task {name:?}")));
92        }
93        let task = if name == "output" {
94            task.handles_output()
95        } else {
96            task
97        };
98        self.tasks.insert(name.to_owned(), task);
99        Ok(self)
100    }
101
102    pub fn include(&mut self, namespace: &str, other: Registry) -> Result<&mut Self> {
103        if !namespace.is_empty() && !valid_name(namespace) {
104            return Err(Error::new("invalid task namespace"));
105        }
106        let tasks: Vec<_> = other
107            .tasks
108            .into_values()
109            .filter(|task| !task.builtin)
110            .map(|mut task| {
111                if !namespace.is_empty() {
112                    task.name = format!("{namespace}:{}", task.name);
113                }
114                task
115            })
116            .collect();
117        for task in &tasks {
118            if self.tasks.contains_key(&task.name) {
119                return Err(Error::new(format!("duplicate task {:?}", task.name)));
120            }
121        }
122        for task in tasks {
123            self.tasks.insert(task.name.clone(), task);
124        }
125        Ok(self)
126    }
127
128    pub fn tasks(&self) -> impl Iterator<Item = &Task> {
129        self.tasks.values()
130    }
131
132    /// Create an isolated execution context with this registry and a project root.
133    pub fn context(self, root: impl Into<PathBuf>) -> Context {
134        Context::new(root.into(), Arc::new(self))
135    }
136
137    pub(crate) fn plan(&self, tokens: &[String]) -> Result<Vec<Invocation>> {
138        let mut invocations = Vec::new();
139        let mut position = 0;
140        while let Some(name) = tokens.get(position) {
141            if name == "::" {
142                return Err(Error::new("expected a task name, found a chain separator"));
143            }
144            let task = self.tasks.get(name).ok_or_else(|| {
145                Error::new(format!(
146                    "unknown task {name:?}; use --list to see available tasks"
147                ))
148            })?;
149            let (arguments, consumed) =
150                Arguments::extract(&task.parameters, &tokens[position + 1..])
151                    .map_err(|error| Error::new(format!("{name}: {error}")))?;
152            invocations.push(Invocation {
153                name: name.clone(),
154                arguments,
155            });
156            position += 1 + consumed;
157            if tokens.get(position).is_some_and(|token| token == "::") {
158                position += 1;
159                if position == tokens.len() {
160                    return Err(Error::new("expected a task after ::"));
161                }
162            }
163        }
164        Ok(invocations)
165    }
166
167    pub fn help(&self, name: Option<&str>) -> Result<String> {
168        if let Some(name) = name {
169            let task = self
170                .tasks
171                .get(name)
172                .ok_or_else(|| Error::new(format!("unknown task {name:?}")))?;
173            let mut text = format!("{}\n\n{}\n\nArguments:\n", task.name, task.description);
174            for parameter in &task.parameters {
175                let kind = if parameter.positional {
176                    "positional"
177                } else {
178                    "named"
179                };
180                let display_name = if parameter.positional {
181                    parameter.name.clone()
182                } else {
183                    format!("--{} value", parameter.name.replace('_', "-"))
184                };
185                let requirement = if parameter.required {
186                    "required"
187                } else {
188                    "optional"
189                };
190                let default = parameter
191                    .default
192                    .as_ref()
193                    .map(|value| format!(", default: {value}"))
194                    .unwrap_or_default();
195                let repeated = if parameter.repeated {
196                    ", repeatable"
197                } else {
198                    ""
199                };
200                text.push_str(&format!(
201                    "  {}: {} ({kind}, {requirement}{default}{repeated})\n",
202                    display_name, parameter.type_name
203                ));
204                if !parameter.description.is_empty() {
205                    text.push_str(&format!("    {}\n", parameter.description));
206                }
207            }
208            Ok(text)
209        } else {
210            let mut text = String::from("Tasks:\n");
211            for task in self.tasks.values() {
212                text.push_str(&format!(
213                    "  {:24} {}\n",
214                    task.name,
215                    task.description.lines().next().unwrap_or_default()
216                ));
217            }
218            text.push_str("\nUse TASK --help for arguments. Chain tasks with ::.\n");
219            Ok(text)
220        }
221    }
222
223    /// Run the process command line and print its final result through `output`.
224    pub fn run(self) -> Result<()> {
225        let tokens: Vec<_> = std::env::args_os()
226            .skip(1)
227            .map(|token| {
228                token
229                    .into_string()
230                    .map_err(|_| Error::new("task arguments must be UTF-8"))
231            })
232            .collect::<Result<_>>()?;
233        let root = std::env::var_os("BAKE_PROJECT_ROOT")
234            .map(PathBuf::from)
235            .map_or_else(std::env::current_dir, Ok)?;
236        let output = self.run_arguments(root, &tokens)?;
237        io::stdout().lock().write_all(output.as_bytes())?;
238        Ok(())
239    }
240
241    /// Execute a command line without writing directly to stdout. Parsing and
242    /// type validation finish before any task starts. Tasks may write an output
243    /// file or return captured output.
244    pub fn run_arguments(self, root: impl Into<PathBuf>, tokens: &[String]) -> Result<String> {
245        let (format, tokens) = if tokens.first().is_some_and(|token| token == "--json") {
246            (Some(Format::Json), &tokens[1..])
247        } else {
248            (None, tokens)
249        };
250        if tokens.is_empty() || matches!(tokens, [flag] if flag == "--list" || flag == "--help") {
251            return self.help(None);
252        }
253        if let [name, flag] = tokens
254            && flag == "--help"
255        {
256            return self.help(Some(name));
257        }
258        let invocations = self.plan(tokens)?;
259        let final_task_handles_output = invocations
260            .last()
261            .and_then(|invocation| self.tasks.get(&invocation.name))
262            .is_some_and(Task::produces_output);
263        let mut context = self.context(root);
264        context.set_default_format(format);
265        for invocation in invocations {
266            context.invoke(invocation)?;
267        }
268        if !final_task_handles_output {
269            context.invoke(Invocation {
270                name: "output".into(),
271                arguments: Arguments::default(),
272            })?;
273        }
274        Ok(context.take_output())
275    }
276}
277
278fn namespace_from_module(task: &mut Task, module_path: &str) {
279    if task.name.contains(':') {
280        return;
281    }
282
283    let namespace = module_path
284        .split("::")
285        .skip(1)
286        .map(|component| component.replace('_', "-"))
287        .collect::<Vec<_>>()
288        .join(":");
289
290    if !namespace.is_empty() {
291        task.name = format!("{namespace}:{}", task.name);
292    }
293}
294
295impl Default for Registry {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301fn validate_task(task: &Task) -> Result<()> {
302    if !valid_name(&task.name) {
303        return Err(Error::new(format!("invalid task name {:?}", task.name)));
304    }
305    let mut parameters = BTreeSet::new();
306    for parameter in &task.parameters {
307        if parameter.name.is_empty()
308            || !parameter
309                .name
310                .chars()
311                .all(|character| character.is_ascii_alphanumeric() || character == '_')
312        {
313            return Err(Error::new(format!(
314                "invalid parameter name {:?}",
315                parameter.name
316            )));
317        }
318        if !parameters.insert(&parameter.name) {
319            return Err(Error::new(format!(
320                "duplicate parameter {:?}",
321                parameter.name
322            )));
323        }
324    }
325    Ok(())
326}