#command-line #system #applications #developer #framework #logging

starbase

Framework for building performant command line applications and developer tools

35 releases

new 0.6.0 May 4, 2024
0.5.2 Mar 29, 2024
0.5.0 Feb 5, 2024
0.2.12 Dec 26, 2023
0.2.0 Jul 26, 2023

#82 in Development tools

Download history 439/week @ 2024-01-18 374/week @ 2024-01-25 788/week @ 2024-02-01 341/week @ 2024-02-08 241/week @ 2024-02-15 371/week @ 2024-02-22 700/week @ 2024-02-29 454/week @ 2024-03-07 603/week @ 2024-03-14 599/week @ 2024-03-21 442/week @ 2024-03-28 522/week @ 2024-04-04 318/week @ 2024-04-11 297/week @ 2024-04-18 162/week @ 2024-04-25 468/week @ 2024-05-02

1,330 downloads per month
Used in 2 crates

MIT license

71KB
1K SLoC

Starbase

Crates.io Crates.io

Starbase is a framework for building performant command line applications and developer tools. A starbase is built with the following modules:

  • Reactor core - Async-first powered by the tokio runtime.
  • Fusion cells - Thread-safe concurrent systems for easy processing.
  • Communication array - Event-driven architecture with starbase_events.
  • Shield generator - Native diagnostics and reports with miette.
  • Navigation sensors - Span based instrumentation and logging with tracing.
  • Engineering bay - Ergonomic utilities with starbase_utils.
  • Command center - Terminal styling and theming with starbase_styles.
  • Cargo hold - Archive packing and unpacking with starbase_archive.

Core

Phases

An application is divided into phases, where systems in each phase will be processed and completed before moving onto the next phase. The following phases are available:

  • Startup - Register and or load components into the application instance.
    • Example: load configuration, detect workspace root, load plugins
  • Analyze - Analyze the current application environment, update components, and prepare for execution.
    • Example: generate project graph, load cache, signin to service
  • Execute - Execute primary business logic.
    • Example: process dependency graph, run generator, check for new version
  • Shutdown - Shutdown whether a success or failure.
    • Example: cleanup temporary files

The startup phase processes systems serially in the main thread, as the order of initializations must be deterministic, and running in parallel may cause race conditions or unwanted side-effects.

The other 3 phases process systems concurrently by spawning a new thread for each system. Active systems are constrained using a semaphore and available CPU count. If a system fails, the application will abort and subsequent systems will not run (excluding shutdown systems).

Systems

Systems are async functions that implement the System trait, are added to an application phase, and are processed (only once) during the applications run cycle. Systems receive each component type as a distinct parameter.

use starbase::{App, States, Resources, Emitters, MainResult, SystemResult};

async fn load_config(states: States, resources: Resources, emitters: Emitters) -> SystemResult {
  let config: AppConfig = do_load_config().await;
  states.set::<AppConfig>(config);

  Ok(())
}

#[tokio::main]
async fn main() -> MainResult {
  let mut app = App::new();
  app.startup(load_config);
  app.run().await?;

  Ok(())
}

Each system parameter type (States, Resources, Emitters) is a type alias that wraps the underlying component manager in a Arc<T>, which uses interior mutability under the hood. Separating components across params simplifies borrow semantics.

Furthermore, for better ergonomics and developer experience, we provide a #[system] function attribute that provides "magic" parameters similar to Axum and Bevy, which we call system parameters. For example, the above system can be rewritten as:

#[system]
async fn load_config(states: States) {
  let config: AppConfig = do_load_config().await;
  states.set::<AppConfig>(config);
}

Additional benefits of #[system] are:

  • Return type and return statement are both optional, as these are always the same.
  • Parameters can be mixed and matched to suit the system's requirements.
  • Parameters can be entirely ommitted if not required.
  • Avoids importing all necessary types/structs/etc. We compile to fully qualified paths.
  • Functions are automatically wrapped for instrumentation.

Jump to the components section for a full list of supported system parameters.

Startup systems

In this phase, components are created and registered into their appropriate manager instance.

app.startup(system_func);
app.add_system(Phase::Startup, system_instance);

Analyze systems

In this phase, registered components are optionally updated based on the results of an analysis.

app.analyze(system_func);
app.add_system(Phase::Analyze, system_instance);

Execute systems

In this phase, systems are processed using components to drive business logic. Ideally by this phase, all components are accessed immutably, but not a hard requirement.

app.execute(system_func);
app.add_system(Phase::Execute, system_instance);

Arguments

Additionally, execute systems can be associated with arguments. This is useful for functionality like CLI commands.

struct MyArgs {
  flag: bool,
  option: String,
}

app.execute_with_args(system_func, MyArgs {
  flag: false,
  option: "value".into(),
});

To access the arguments within the system itself, you can use the #[system] macro, coupled with the ArgsRef<T> system parameter.

#[system]
async fn system_func(args: ArgsRef<MyArgs>) {
  args.flag; // false
  args.option; // "value"
}

If not using the macro, you can access the arguments like so: states.get::<starbase::ExecuteArgs>().extract::<T>();

Shutdown systems

Shutdown runs on successful execution, or on a failure from any phase, and can be used to clean or reset the current environment, dump error logs or reports, so on and so forth.

app.shutdown(system_func);
app.add_system(Phase::Shutdown, system_instance);

Components

Components are values that live for the duration of the application ('static) and are stored internally as Any instances, ensuring strict uniqueness. Components are dividied into 3 categories:

  • States - Granular values (newtype patterns).
  • Resources - Compound values / singleton instances.
  • Emitters - Per-event emitters.

States

States are components that represent granular pieces of data, are typically implemented with a tuple or unit struct (newtypes), and must derive State. For example, say we want to track the workspace root.

use starbase::State;
use std::path::PathBuf;

#[derive(Debug, State)]
pub struct WorkspaceRoot(PathBuf);

The State derive macro automatically implements AsRef, Deref, and DerefMut when applicable. In the future, we may implement other traits deemed necessary.

Adding state

States can be added directly to the application instance (before the run cycle has started), or through the States system parameter.

app.set_state(WorkspaceRoot(PathBuf::from("/")));
#[system]
async fn detect_root(states: States) {
  states.set(WorkspaceRoot(PathBuf::from("/")));
}

#[system]
async fn read_states(states: States) {
  let workspace_root = states.get::<WorkspaceRoot>();
}

Readable state

Alternatively, the StateRef system parameter can be used to immutably read an individual value from the states manager. Multiple StateRefs can be used together, but cannot be used with StateMut.

#[system]
async fn read_states(workspace_root: StateRef<WorkspaceRoot>, project: StateRef<Project>) {
  let project_root = workspace_root.join(project.source);
}

Writable state

Furthermore, the StateMut system parameter can be used to mutably access an individual value, allowing for the value (or its inner value) to be modified. Only 1 StateMut can be used in a system, and no other state related system parameters can be used.

#[system]
async fn write_state(touched_files: StateMut<TouchedFiles>) {
  touched_files.push(another_path);
}

Resources

Resources are components that represent compound data structures as complex structs, and are akin to instance singletons in other languages. Some examples of resources are project graphs, dependency trees, plugin registries, cache engines, etc.

Every resource must derive Resource.

use starbase::Resource;
use std::path::PathBuf;

#[derive(Debug, Resource)]
pub struct ProjectGraph {
  pub nodes; // ...
  pub edges; // ...
}

The Resource derive macro automatically implements AsRef. In the future, we may implement other traits deemed necessary.

Adding resources

Resources can be added directly to the application instance (before the run cycle has started), or through the Resources system parameter.

app.set_resource(ProjectGraph::new());
#[system]
async fn create_graph(resources: Resources) {
  resources.set(ProjectGraph::new());
}

#[system]
async fn read_resources(resources: Resources) {
  let project_graph = resources.get::<ProjectGraph>();
}

Readable resources

The ResourceRef system parameter can be used to immutably read an individual value from the resources manager. Multiple ResourceRefs can be used together, but cannot be used with ResourceMut.

#[system]
async fn read_resources(project_graph: ResourceRef<ProjectGraph>, cache: ResourceRef<CacheEngine>) {
  let projects = project_graph.load_from_cache(cache).await?;
}

Writable resources

Furthermore, the ResourceMut system parameter can be used to mutably access an individual value. Only 1 ResourceMut can be used in a system, and no other resource related system parameters can be used.

#[system]
async fn write_resource(cache: ResourceMut<CacheEngine>) {
  let item = cache.load_hash(some_hash).await?;
}

Emitters

Emitters are components that can dispatch events to all registered subscribers, allowing for non-coupled layers to interact with each other. Unlike states and resources that are implemented and registered individually, emitters are pre-built and provided by the starbase_events::Emitter struct, and instead the individual events themselves are implemented.

Events must derive Event, or implement the Event trait. Events can be any type of struct, but the major selling point is that events are mutable, allowing inner content to be modified by subscribers.

use starbase::{Event, Emitter};
use app::Project;

#[derive(Debug, Event)]
pub struct ProjectCreatedEvent(pub Project);

let emitter = Emitter::<ProjectCreatedEvent>::new();

Adding emitters

Emitters can be added directly to the application instance (before the run cycle has started), or through the Emitters system parameter.

Each emitter represents a singular event, so the event type must be explicitly declared as a generic when creating a new emitter.

app.set_emitter(Emitter::<ProjectCreatedEvent>::new());
#[system]
async fn use_emitters(emitters: Emitters) {
  // Add emitter
  emitters.set(Emitter::<ProjectCreatedEvent>::new());

  // Emit event
  emitters.get_mut::<Emitter<ProjectCreatedEvent>().emit(ProjectCreatedEvent::new()).await?;

  // Emit event shorthand
  emitters.emit(ProjectCreatedEvent::new()).await?;
}

Using emitters

Furthermore, the EmitterRef (preferred) or EmitterMut system parameters can be used to access an individual emitter. Only 1 EmitterMut can be used in a system, but multiple EmitterRef can be used. The latter is preferred as we utilize interior mutability for emitting events, which allows multiple emitters to be accessed in parallel.

#[system]
async fn emit_events(project_created: EmitterRef<ProjectCreatedEvent>) {
  project_created.emit(ProjectCreatedEvent::new()).await?;
}

How to

Error handling

Errors and diagnostics are provided by the miette crate. All layers of the application, from systems, to events, and the application itself, return the miette::Result type. This allows for errors to be easily converted to diagnostics, and for miette to automatically render to the terminal for errors and panics.

To benefit from this, update your main function to return MainResult, and call App::setup_*() to register error/panic handlers.

use starbase::{App, MainResult};

#[tokio::main]
async fn main() -> MainResult {
  App::setup_diagnostics();
  App::setup_tracing();

  let mut app = App::new();
  // ...
  app.run().await?;

  Ok(())
}

To make the most out of errors, and in turn diagnostics, it's best (also suggested) to use the thiserror crate.

use starbase::Diagnostic;
use thiserror::Error;

#[derive(Debug, Diagnostic, Error)]
pub enum AppError {
    #[error(transparent)]
    #[diagnostic(code(app::io_error))]
    IoError(#[from] std::io::Error),

    #[error("Systems offline!")]
    #[diagnostic(code(app::bad_code))]
    SystemsOffline,
}

Caveats

In systems, events, and other fallible layers, a returned Err must be converted to a diagnostic first. There are 2 approaches to achieve this:

#[system]
async fn could_fail() {
  // Convert error using into()
  Err(AppError::SystemsOffline.into())

  // OR use ? operator on Err()
  Err(AppError::SystemsOffline)?
}

Dependencies

~8–22MB
~264K SLoC