Give every project its own CLI, built from tasks that are just crates.
Every project grows commands. Build this, seed that, check the other thing. A real CLI is the right home for them, and almost nobody writes one, because wiring up clap for your own repo is a chore that only happens once the pain gets bad enough.
Ritual makes it the starting point. ritual new gives a project its CLI, and
every task you create shows up in it.
Tasks are crates, so they travel like crates. Group them into a bundle
(also a crate) and a whole team shares one toolkit: add one dependency, and
every project gets every task, namespaced, updated through cargo update with
no extra thought. Need just one task from someone else? Import that one alone.
There's no plugin system to learn and no registry but the one you already use. Importing a ritual is a Cargo dependency. It's just Cargo.
cargo install --locked rituals-cliThe package is rituals-cli, plural, and it installs one binary, ritual.
The ritual and ritual-cli crates on crates.io are unrelated projects. You
need the binary only to start a project or a standalone task crate. Inside a
project, everything runs through Cargo.
ritual new demo
cd demo
cargo ritual create hello
cargo ritual hellohello has nothing to do yet
The first cargo ritual builds the project's command line, so Cargo prints
its progress before the output. Later runs reuse the build.
new made a Cargo workspace, and create put a task in it:
demo/
├── Cargo.toml the workspace
├── .gitignore
├── .cargo/config.toml the alias that makes cargo ritual run the command line
├── ritual/ the CLI crate (package demo-ritual)
│ ├── Cargo.toml says which of its dependencies are tasks
│ └── src/main.rs generated from that list, never edited by hand
└── .rituals/hello/ the task create scaffolded
├── Cargo.toml
└── src/lib.rs
Now give hello something to do. Replace .rituals/hello/src/lib.rs with:
use rituals::{Outcome, Task, clap, report};
/// What this task accepts on the command line.
#[derive(clap::Args)]
struct Arguments {
/// who to greet
who: String,
}
fn run(arguments: Arguments) -> Outcome {
report(format!("hello, {}", arguments.who));
Ok(())
}
#[must_use]
pub fn task() -> Task {
Task::new("say hello to somebody", run)
}cargo ritual hello worldhello, world
cargo ritual --help lists hello beside ritual's own commands. new
makes a project, so it will not run inside one; create works inside a project
and outside one. Every command line carries all of ritual's commands, so it looks
the same wherever it runs.
- task: a crate that marks itself as a task and exposes
task(). - bundle: a task made of named child tasks.
- CLI crate: the crate in your project that imports tasks.
newputs it inritual/. - import: a dependency of the CLI crate that its manifest lists as a task.
- command line: what
cargo ritualruns, built from the CLI crate. - generated file: the CLI crate's
src/main.rs, written from its imports.
A crate declares itself a task in its own manifest:
[package.metadata.ritual]
task = trueand exposes one function, task(), like the hello above. Its arguments
are ordinary clap, re-exported by rituals. The first argument to
Task::new is the one-line description --help shows, not the task's
name: a task never names itself. The
rituals documentation covers writing one,
including refusing with a Failure.
The CLI crate says which of its dependencies are tasks, by dependency key:
[dependencies]
lint = { path = "../.rituals/lint" }
[package.metadata.ritual]
tasks = ["lint"]cargo ritual import writes both halves, the dependency and its entry in
tasks, and regenerates the command line, so neither is edited by hand.
The dependency key is the command name. Importing a crate under another name
is the key you give import, and it writes one line of plain Cargo:
check = { package = "acme-linting", git = "https://github.com/acme/rituals" }Path, git and registry sources all work, because they are Cargo's. Moving a task from one project into a shared repository changes the source field and nothing else.
cargo ritual regenerate writes the CLI crate's src/main.rs from the
tasks list: one line per import. The file is checked in, so a fresh clone
runs cargo ritual with nothing installed but Cargo. It is never edited by
hand.
A bundle groups named child tasks under one command, built with
Task::group in place of Task::new. It is imported exactly like any other
task, and bundles nest.
create, regenerate, new, import, remove and migrate come from the
bundle rituals-core, imported under the key ritual, and so does add, the
old name of create. A new project's command line is also called ritual, so
those commands appear directly: cargo ritual create, not cargo ritual ritual create. If you name your command line something else, with
ritual new demo --cli acme, they stay grouped: your tasks run as
cargo acme <task> and ritual's as cargo acme ritual create.
The rule behind this, and what happens when two commands would share a name, is in the design.
Run this anywhere inside the project:
cargo ritual create lintcreated .rituals/lint/Cargo.toml
created .rituals/lint/src/lib.rs
updated Cargo.toml
updated ritual/Cargo.toml
updated ritual/src/main.rs (tasks: ritual, lint)
next: edit .rituals/lint/src/lib.rs, then run cargo ritual lint
It scaffolds .rituals/lint, adds it to the workspace and to the CLI crate's
manifest, and regenerates. Edit .rituals/lint/src/lib.rs, then run
cargo ritual lint. A run that is refused or fails leaves the project as it
found it.
To put the task in a subdirectory, give a path below .rituals/:
cargo ritual create .rituals/private/lintThe path is read from where you are, as a shell reads it, so
cargo ritual create private/lint from inside .rituals/ means the same
place. Its last part, lint, is the task's name. A path that does not lead
below .rituals/ is refused, and so is one that leads into a ritual already
there, such as .rituals/lint/inner: a ritual's directory is its crate, not a
subdirectory to arrange.
Where rituals live. Every ritual lives in .rituals/, whoever it is for:
one directory, in every project, with no exceptions. Subdirectories of
.rituals/, at any depth, are yours to arrange (.rituals/private/lint), and
ritual reads no meaning into their names. Who a ritual is for is Cargo's own
publish key in its manifest, the key Cargo already uses to say whether a
crate ships: publish = false keeps it private to the project, and leaving it
out shares it. create writes publish = false unless you give --public, so
a ritual is never published by accident; remove the key when you want to share
one.
One Cargo rule to know when you group rituals. A .rituals/* glob in
[workspace] members also matches a grouping directory that has no manifest
of its own, and Cargo then refuses to load the workspace. List that directory
under exclude, or list the members explicitly, as create writes them. A 0.1
project that already did this in tasks/ needs nothing more: migrate moves
the exclude entry with the group, so tasks/group becomes .rituals/group.
A project made by ritual 0.1 keeps its tasks in tasks/, and they keep
working there. cargo ritual migrate
moves them.
Run this anywhere inside the project. The crate comes from the registry by
default, and @ picks a version other than the newest:
cargo ritual import acme-linting@1.2.0 lintThe second word is the key the task answers to, so this one runs as
cargo ritual lint. Without it, the key is the crate's name.
A crate in a git repository takes --git, with at most one of --branch,
--tag or --rev:
cargo ritual import acme-linting lint --git https://github.com/acme/rituals --tag v1.2.0A crate in a directory takes --path:
cargo ritual import lint --path ../lintimport adds the dependency to the CLI crate, lists its key in tasks, and
regenerates, so the task runs as soon as it finishes. It refuses a crate that
is not a task, or a task built on another rituals than the project uses,
whether another release or the same release from another source, and whether
the crate depends on it directly or through a crate whose task it re-exports.
A refusal leaves the project as it found it.
To write a task crate that several projects can share, run this where the
crate can build on its own: outside any Cargo workspace, or inside an ordinary
package that declares no [workspace]:
ritual create lintIt takes a bare name, makes the crate in the current directory, and ends by
printing the import command to run in a project, with the crate's path
filled in. It takes --public here too. Inside a Cargo workspace that is not
your project's, a crate would not build on its own, so create refuses there
and says what to run instead.
Run this anywhere inside the project, with the task's key in
[package.metadata.ritual] tasks, or with the crate it imports:
cargo ritual remove lintremove takes the key out of tasks, regenerates the command line, and only
then removes the dependency line, the order the build needs. If the task is a
crate in your workspace, as create creates, it also removes its members
entry and deletes its directory. A path dependency outside the workspace
loses only its dependency line: its directory stays where it is. Nothing is
committed for you. Look at the change, and commit it when it is what you
meant.
It refuses, and leaves the project as it found it, Cargo.lock included, when:
- the name is neither a key nor a crate one of them imports, or the crate is imported by more than one key (remove one by its key);
- the task imports
rituals-core, the bundle of ritual's own commands, whatever its key (ritualunless you renamed it), since nothing could put it back; - the task has a directory to delete and git cannot give back everything in it. That means the project is not a git repository, the directory is or holds a git repository of its own, a submodule included, or it holds files that are uncommitted, untracked or ignored; the refusal names them. A directory that is a symbolic link is deleted as the link alone, so it is the link that has to be committed;
- the directory holds files git has been told not to look at, with
--assume-unchangedor--skip-worktree, or files stored through afilterother than Git LFS's, sincegit statuscan call those clean while git could not give back what is on disk; - the directory is in a git repository other than the project's, such as one a symbolic link leads into;
- another crate depends on the task's directory in any way, optionally or not, whether it is in the workspace or outside it, or builds from a file in it;
- the directory holds other workspace members, lies outside the workspace
(through a symbolic link included), or is the only entry in
default-members; - it is the last match of a glob in
membersordefault-members, which Cargo would then read as a path that does not exist (add an explicit member, or remove the glob); - a
[patch],[replace]or[workspace.dependencies]entry, or apathsor[patch]setting in Cargo's configuration, points into the directory, since Cargo reads those whether or not anything uses them. That is the configuration a build reads from the workspace root, from any member's directory, from where you runremove, or from any directory under the root holding a.cargo/configor.cargo/config.tomlthat git tracks or would track by that name, and every file it includes; anincludeof a file in the directory refuses too, unless it isoptional. The design lists what this does not reach.
A path points into the directory however it is spelled, as long as Cargo would reach the directory through it: in another case on a file system that ignores case, or through a symbolic link.
If anything fails while remove is writing manifests or regenerating, the
project is put back as it was. The directory's deletion comes last, after
that, and is not undone; git gives back anything it deleted, and if the
deletion fails partway the message says how, in a command that works from
anywhere in the project.
Ritual 0.1 put a project's tasks in tasks/. From 0.2 they go in .rituals/
(where rituals live), and cargo ritual migrate
moves a project that has them in tasks/. A project with its tasks in
tasks/ keeps building and running on 0.2 until you do, so there is no hurry.
To upgrade:
- Set
ritualsunder[workspace.dependencies]in the workspace'sCargo.toml, andrituals-corein the CLI crate'sCargo.toml(ritual/Cargo.toml, where it is writtenritual = { package = "rituals-core", ... }), to the new release. Both: tasks built onrituals0.1 do not fit a bundle built on 0.2, and the command line will not compile until both match. - Run
cargo build, soCargo.lockfollows, and commit everything. - Run
cargo ritual migrate. - Review the changes with
git statusandgit diff, and commit them.
A task made from 0.2 on is private unless you say otherwise: create, and
add with it, writes publish = false into the new task's manifest unless you
give --public, which 0.1's add never did. Give --public for a ritual you
mean to publish, or remove the key from its manifest later
(where rituals live).
migrate is for any release's change to the layout. It runs every migration
that applies, oldest first, and stores nothing, so there is no version to keep
in step and a project that needs nothing is told nothing to migrate. It runs
from the new version, which is why the bump comes first. Migrations so far:
from tasks/ to .rituals/.
For each workspace member under tasks/ that is a task, it:
- moves the directory to the same place under
.rituals/, ignored files included, once git would see every file there as it does now (see below); - changes the workspace's
members,default-membersandexclude, keeping a glob a glob (tasks/*becomes.rituals/*) and keeping an explicit list explicit, each entry changed where it stands. Anexcludeentry for a directory that groups tasks moves with them, sotasks/groupbecomes.rituals/group, and stays intasks/as well only while something else is left in the group; - changes the
pathof every dependency on a moved task in every manifest Cargo reads, crates outside the workspace included, not only the CLI crate's, since tasks can depend on each other and a crate Cargo reaches through an optional dependency can depend on a task:[dependencies]and the other dependency tables,[workspace.dependencies],[patch],[replace], and the other places Cargo reads a path. A path that still leads where it led, such as a task's../greetto a task that moved beside it, is left as you wrote it; - leaves
tasks/in place when something that is not a task is still in it, and says what is left.
It never writes publish: 0.1 wrote none, so who a task is for is not
something migrate can know.
It edits manifests in place, so your comments and formatting stay. It then
checks that Cargo still reads the project, and prints what it did, then every
other file in the repository that still mentions tasks/, such as a CI
workflow or a script, which it does not edit:
moved tasks/greet to .rituals/greet
moved tasks/shout to .rituals/shout
updated Cargo.toml ([workspace] members `tasks/greet` is now `.rituals/greet`)
updated Cargo.toml ([workspace] members `tasks/shout` is now `.rituals/shout`)
updated ritual/Cargo.toml ([dependencies] greet path `../tasks/greet` is now `../.rituals/greet`)
updated ritual/Cargo.toml ([dependencies] shout path `../tasks/shout` is now `../.rituals/shout`)
deleted tasks/ (empty once its tasks moved out)
.github/workflows/ci.yml still mentions tasks/
next: review the changes with git status and git diff, then commit them
Nothing is committed for you.
It only runs where git can give everything back, and refuses before it writes anything when:
- the project is not in a git repository, or the work tree has changes that are not committed or files that are not tracked (files git ignores do not count here, and move with their task). A project with nothing to migrate is told so whatever its work tree holds;
- git would see a file under a task differently at its new place. Git decides
by path whether to ignore a file, which attributes to give it and whether
the sparse checkout includes it, so a commit after the move could leave out
a file that is committed now (a rule such as
.*ignores.rituals/), add a file that is ignored now (a rule that namestasks/greet/.env), or store a file through another filter (a.gitattributesrule for Git LFS that namestasks/). The same goes for a file that would be outside a sparse checkout, and for a tracked file git has been told not to look at (assume-unchangedorskip-worktree), whose edits a commit would carry withoutgit statusshowing them. The check is for the rules your project carries, its.gitignoreand.gitattributesfiles; configuration outside the repository is yours to keep in step. An ignore file inside a task moves with it and counts at its new place. The refusal names the file and the rule, and what to change; - a task's directory holds other workspace members, or a git submodule, or
its destination in
.rituals/is taken, or.ritualsis a file; - a path in a manifest reaches a task through a symbolic link or another spelling of its directory, so there is no telling how to repoint it;
- a manifest that needs one of those edits is one git does not track, such as a crate outside the workspace in a directory git ignores, since git could not give its old text back. Commit it, or take the path out of it.
If anything fails while it is moving and editing, the project is put back as
it was, ignored files included, and the failure says so. If a move cannot be
undone, the message names the directory and, when it is safe, the mv command
that puts it back, which works from anywhere. It offers none when something
else now stands where the directory belongs, because mv would put one inside
the other. The run's last steps come after that, and cannot
undo it: deleting the empty directories and searching for files that mention
tasks/. If one of them fails, migrate says what failed and that every task
has already moved and Cargo reads the project as it should.
cargo ritual works from any directory inside the project. The global
ritual carries create, regenerate, import, remove, migrate and
add too, but they refuse in your project: use cargo ritual create.
add is create's old name: it still works in 0.2.0, says on standard
error that it is deprecated, and goes in 0.3.0.
ritualson docs.rs covers writing tasks and bundles..docs/design.mdexplains why ritual is shaped the way it is, and.docs/crates.mdshows how its crates divide the work.- Releases lists what changed in each release.
contributing.mdcovers working on ritual itself.
Contributions will be welcome, but issues and pull requests aren't open to them yet: the process for them is still being worked out.
Ritual follows Semantic Versioning, and every one of its crates is released at the same version, together.
Ritual needs a recent stable Rust. The minimum supported version is the
rust-version each crate declares. Raising it is not a breaking change, and
it happens in a minor release.
MIT. See license.md.