Rakko

Build an Action

Write a reusable maintenance action with a --fix flag and add it to your harness.

Build check-readme, an action that reports a missing README.md and creates one with --fix. You will write a Rust library crate and mount it in your harness. Mounting adds a command with the action’s name.

You need a working harness. If you do not have one, follow Get Started first.

Create the Crate

An action is an ordinary library crate that depends on the contract crate, rakko:

cargo new --lib otter-check-readme
cd otter-check-readme
cargo add rakko

The contract defines the interfaces that actions and harnesses share. The action does not need rakko-cli, which builds the command line. This keeps the action independent of a command-line framework.

Everything that this action needs comes from rakko. Put these imports at the top of src/lib.rs:

use std::fs;

use rakko::{
    Access, Action, Args, ArgsSchema, ArgsValues, Argument, ArgumentShape, Context, Documented,
    Finding, Location, Name, Outcome, ReadArgsError, Summary, action_name, argument_name,
};

Add an Argument

Arguments let callers control a run, for example with a --fix flag. Define them in a type that implements Args. Add this type to src/lib.rs:

/// The arguments that a run of check-readme reads
#[derive(Copy, Clone, Debug, Default)]
pub struct CheckReadmeArgs {
    /// Whether the run writes a README when the project has none
    fix: bool,
}

impl Args for CheckReadmeArgs {
    fn schema() -> ArgsSchema {
        ArgsSchema::new([Argument::builder()
            .name(argument_name!("fix"))
            .shape(ArgumentShape::Boolean)
            .documentation("Write a README when the project has none")
            .build()])
    }

    fn from_values(values: &ArgsValues) -> Result<Self, ReadArgsError> {
        let name = argument_name!("fix");
        let fix = match values.get(&name) {
            Some(value) => value
                .get()
                .parse()
                .map_err(|_| ReadArgsError::UnreadableValue {
                    name,
                    value: value.clone(),
                })?,
            None => false,
        };

        Ok(Self { fix })
    }
}

schema describes each argument’s name, shape, and help text. The command line turns a boolean argument into a switch, such as --fix. Other shapes become flags that take a value.

from_values converts the values for a run into your argument type. Your type decides what an omitted value means. If conversion fails, it returns ReadArgsError before the action runs.

The command line reserves names such as json, quiet, and verbose. If an action uses a reserved argument name, the harness stops at startup and identifies the action and argument.

An action that reads no arguments uses the unit type, type Args = ();.

Write the Action

An action is a type that implements Action. Add it to src/lib.rs:

/// The text that a repair writes into a new README
const README: &str = "# Project\n";

/// Checks that the project has a README
///
/// A README is the first page that a new contributor reads. The action looks
/// for `README.md` in the root of the project. With `--fix`, it writes one
/// when the project has none.
#[derive(Copy, Clone, Debug, Default, Documented)]
pub struct CheckReadme;

impl Action for CheckReadme {
    type Args = CheckReadmeArgs;

    fn name(&self) -> Name {
        action_name!("check-readme")
    }

    fn access(&self, _context: &Context, args: &Self::Args) -> Access {
        if args.fix {
            Access::Write
        } else {
            Access::Read
        }
    }

    async fn run(&self, context: &Context, args: &Self::Args) -> Outcome {
        let readme = context.root().get().join("README.md");

        match readme.try_exists() {
            Ok(true) => Outcome::Passed {
                summary: Some(Summary::new("found README.md")),
            },
            Ok(false) if args.fix => match fs::write(&readme, README) {
                Ok(()) => Outcome::Changed {
                    repairs: vec![missing_readme()],
                },
                Err(error) => Outcome::Errored {
                    source: Box::new(error),
                },
            },
            Ok(false) => Outcome::Failed {
                findings: vec![missing_readme()],
                repairs: Vec::new(),
            },
            Err(error) => Outcome::Errored {
                source: Box::new(error),
            },
        }
    }
}

/// Returns the finding that reports a project without a README
fn missing_readme() -> Finding {
    Finding::builder()
        .message("the project has no README.md")
        .location(Location::Project)
        .build()
}

The code has these parts:

  • The Documented derive uses the first paragraph of the doc comment as command help. Write one sentence without Markdown or links. A type without a doc comment does not compile.
  • The action name is also its command name. It starts with a lowercase ASCII letter and contains lowercase ASCII letters, digits, and single hyphens. action_name! rejects invalid names at compile time. The names doctor and help are reserved.
  • access declares whether a run reads or writes the project. In this action, --fix requires exclusive access. Without it, the run can share the project with other readers. This method must not read files or start tools. If the arguments cannot determine access, return Access::Write, which is also the default.
  • run examines the project and returns an Outcome. Context supplies the project root. Every result, including an error, belongs in the outcome.

Choose the Outcome

Choose an outcome that describes the run:

  • Outcome::Passed means that the action found no problems. A Summary, such as found README.md, describes what it examined.
  • Outcome::Failed carries a Finding for each remaining problem. A finding describes a problem and its location. Location identifies the project, a directory, a file, or a position or range within a file. Include any completed repairs alongside the remaining findings.
  • Outcome::Changed means that the action repaired every problem it found. Each repair has the same structure as a finding.
  • Outcome::Skipped means that the action had nothing to examine. Include a SkipReason for the user.
  • Outcome::Errored means that an error prevented the action from reaching a result.

Actions and Groups explains how the command line turns each outcome into output and an exit code.

Mount It in a Harness

Add your crate to the harness dependencies. Then mount the action in the harness’s main function:

use otter_check_readme::CheckReadme;

/// Builds the command line of the project and runs it
fn main() -> std::process::ExitCode {
    rakko_cli::builder().mount(CheckReadme).run()
}

The harness now has a check-readme command. Its help includes the doc comment and the --fix flag:

mise run rakko -- check-readme --help

Run the action, and let it repair the project:

mise run rakko -- check-readme
mise run rakko -- check-readme --fix

Next Steps