Merge remote-tracking branch 'cli/main'

This commit is contained in:
DecDuck
2026-03-30 19:01:50 +11:00
24 changed files with 4033 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+4
View File
@@ -0,0 +1,4 @@
/target
logs/
.vscode
.direnv
+3188
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "downpour"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
chrono = "0.4.43"
clap = { version = "4.5.54", features = ["derive"] }
console = "0.16.2"
dialoguer = "0.12.0"
dirs = "6.0.0"
droplet-rs = { path = "../droplet-rs", version = "0.14" }
fern = { version = "0.7.1", features = ["colored"] }
futures = "0.3.31"
indicatif = "0.18.3"
log = "0.4.29"
opendal = { version = "0.55.0", features = ["services-s3"] }
rand = "0.9.2"
reqwest = { version = "0.13.1", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.148"
tokio = { version = "1.48.0", features = ["fs", "macros"] }
tokio-util = { version = "0.7.18", features = ["compat"] }
url = "2.5.8"
webbrowser = "1.0.6"
+2
View File
@@ -0,0 +1,2 @@
# downpour
A cli tool to upload local files to Drop's depot infrastructure.
+96
View File
@@ -0,0 +1,96 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768564909,
"narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e4bae1bd10c9c57b2cf517953ab70060a828ee6f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1768704795,
"narHash": "sha256-Y33TAp2BHEcuspYvcmBXXD0qdvjftv73PwyKTDOjoSY=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "4b7472a78857ac789fb26616040f55cfcbd36c6e",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+52
View File
@@ -0,0 +1,52 @@
{
description = "Drop-OSS app development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs =
{
self,
nixpkgs,
flake-utils,
rust-overlay,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
libraries = with pkgs; [
glib
glibc
openssl
];
in
{
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
pkg-config
git
rust-bin.nightly.latest.default
rust-analyzer
cargo-expand
];
buildInputs = libraries;
shellHook = ''
export LD_LIBRARY_PATH="${
pkgs.lib.makeLibraryPath libraries
}:$LD_LIBRARY_PATH"
echo "Downpour development environment loaded"
'';
};
}
);
}
+2
View File
@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"
+10
View File
@@ -0,0 +1,10 @@
# Downpour CLI spec
`downpour [command] --opts`
## Commands:
- new <path/s3 name> <public endpoint> - creates/initalizes a depot at the endpoint. Creates manifest.json and speedtest
- connect <s3 endpoint> <key> <secret> [name] - connects to an s3 endpoint and saves the endpoint to some sort of credentials file. Name is either as provided or the hostname of the endpoint
- upload <game id> <localpath> <path/s3 name> - uploads game as described before. Should fail if depot isn't initialized with new from above
- copy <game id> <version id> <src path/s3 name> <dest path/s3 name> - copies between two depots
- mark [exists/absent] <game id> <version id> <path/s3 name> - modifies depot's manifest.json to show content exists or is absent without copying (for third party copies)
- rename <public endpoint> <new public endpoint> - renames an endpoint [NEEDS API ROUTES - can't do yet]
- delete <public endpoint> - delete an endpoint [NEEDS API ROUTES - can't do yet]
+69
View File
@@ -0,0 +1,69 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::{commands::connect::config_option::ConfigOptionCli, interactive_variable};
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
/// Specify data file path
#[arg(short, long)]
pub data: Option<String>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Configures downpour endpoints
Connect {
#[arg(short, long)]
name: Option<String>,
#[command(subcommand)]
option: ConfigOptionCli,
},
/// Uploads new game version to depot
Upload {
#[clap(flatten)]
info: UploadInfoCli,
#[arg(short, long)]
/// Alias of a given connection
name: Option<String>,
},
}
#[derive(Args)]
pub struct UploadInfo {
pub path: String,
pub game_id: String,
pub version_id: String,
}
#[derive(Args)]
pub struct UploadInfoCli {
/// Relative path to new version files
#[arg(short, long, default_value_t = String::from("."))]
pub path: String,
/// ID of game to attach to
#[arg(short, long)]
pub game_id: Option<String>,
/// Version ID to attach to
#[arg(short, long)]
pub version_id: Option<String>,
}
impl UploadInfoCli {
pub fn interactive_configure(self) -> UploadInfo {
let path = self.path;
interactive_variable!(self, game_id, "Game ID");
interactive_variable!(self, version_id, "Version ID");
UploadInfo {
path,
game_id,
version_id,
}
}
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum UploadStyle {
S3,
}
+152
View File
@@ -0,0 +1,152 @@
use crate::{
commands::connect::{
config_option::{ConfigOption, ConfigOptionCli},
configurable::Configure,
speedtest::{SPEEDTEST_PATH, Speedtest},
},
manifest::DepotManifest,
};
use dialoguer::{Confirm, theme::ColorfulTheme};
use futures::AsyncWriteExt;
use indicatif::{ProgressBar, ProgressStyle};
use log::{debug, info};
use opendal::Operator;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, ops::Not};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
const CONFIG_DIR: &str = "downpour/config.json";
#[derive(Serialize, Deserialize)]
pub struct Config {
configurations: HashMap<String, ConfigOption>,
active: Option<String>,
}
impl Config {
pub fn new() -> Self {
Self {
configurations: HashMap::new(),
active: None,
}
}
pub fn exists(&self, name: &String) -> bool {
self.configurations.contains_key(name)
}
pub fn save(&self) -> anyhow::Result<()> {
let json = serde_json::to_string(self)?;
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
fs::create_dir_all(save_path.parent().unwrap())?;
fs::write(save_path, json)?;
Ok(())
}
pub fn read() -> Self {
let save_path = dirs::config_dir()
.expect("Apparently your home directory doesn't exist") // Should probably formalise that error
.join(CONFIG_DIR);
if fs::exists(&save_path)
.unwrap_or_else(|_| panic!("Could not read save path {:#?}", &save_path))
{
serde_json::from_str(&fs::read_to_string(save_path).unwrap()).unwrap()
} else {
Config::new()
}
}
pub fn add_item(&mut self, name: String, object: ConfigOption) {
if matches!(object, ConfigOption::S3(..)) {
self.active = Some(name.clone())
}
self.configurations.insert(name, object);
self.save().expect("Failed to save config");
}
pub fn get_active(&self) -> Option<&ConfigOption> {
if let Some(active) = &self.active {
self.configurations.get(active)
} else {
None
}
}
pub fn get<T: AsRef<str>>(&self, name: T) -> Option<&ConfigOption> {
self.configurations.get(name.as_ref())
}
}
pub async fn manage_configuration(
config: &mut Config,
name: Option<String>,
option: ConfigOptionCli,
) -> anyhow::Result<()> {
let mut name = name;
if let Some(name) = &name
&& config.exists(name)
{
let confirm = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"An entry already exists with the name \"{}\". Would you like to overwrite it?",
name
))
.interact()?;
if !confirm {
return Err(anyhow::anyhow!("User cancelled action"));
}
}
let config_option = match option {
ConfigOptionCli::S3(s3_config_cli) => s3_config_cli.clone().configure(&mut name).await?,
};
let name = name.expect("Default name was not provided by ConfigOption. This is a bug");
config.add_item(name, config_option.clone());
let operator = config_option.build()?;
generate_manifest(&operator).await?;
info!("Finished uploading manifest");
generate_speedtest(&operator).await?;
info!("Finished uploading speedtest");
Ok(())
}
async fn generate_speedtest(operator: &Operator) -> anyhow::Result<()> {
// Workaround to operator.exists("...") also logging a 404 warning
let lister = operator.list_with(SPEEDTEST_PATH).limit(1).await?;
if lister.is_empty().not() {
info!("Speedtest already exists on Depot. Skipping speedtest upload...");
return Ok(());
}
let mut writer = operator
.writer(SPEEDTEST_PATH)
.await?
.into_futures_async_write()
.compat_write();
let progress_bar = ProgressBar::new(10_000).with_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] [ETA {eta}] {bar} {percent_precise}%")
.unwrap(),
);
let mut reader = Speedtest::new(|progress| {
let progress_int = (progress * 100f32).round() as u64;
progress_bar.set_position(progress_int);
});
let written = tokio::io::copy(&mut reader, &mut writer).await?;
progress_bar.finish();
debug!("Wrote {} bytes to {:?}", written, operator.info());
writer.into_inner().close().await?;
debug!("Closed writer");
Ok(())
}
async fn generate_manifest(operator: &Operator) -> anyhow::Result<()> {
let lister = operator.list_with("manifest.json").limit(1).await?;
if lister.is_empty().not() {
info!("Manifest already exists on Depot. Skipping manifest upload...");
return Ok(());
}
let data = DepotManifest::new();
operator
.write("manifest.json", serde_json::to_string(&data)?)
.await?;
Ok(())
}
+27
View File
@@ -0,0 +1,27 @@
use clap::Subcommand;
use opendal::{Operator, layers::LoggingLayer};
use serde::{Deserialize, Serialize};
use crate::{
commands::connect::s3::{S3Config, S3ConfigCli},
operator_builder::OperatorBuilder,
};
#[derive(Subcommand, Clone)]
pub enum ConfigOptionCli {
// Connect to any S3-compatible endpoint
S3(S3ConfigCli),
}
#[derive(Serialize, Deserialize, Clone)]
pub enum ConfigOption {
S3(S3Config),
}
impl ConfigOption {
pub fn build(&self) -> anyhow::Result<Operator> {
Ok(match self {
ConfigOption::S3(s3_config) => s3_config.build()?,
}
.layer(LoggingLayer::default()))
}
}
+5
View File
@@ -0,0 +1,5 @@
use crate::commands::connect::config_option::ConfigOption;
pub trait Configure {
async fn configure(self, name: &mut Option<String>) -> anyhow::Result<ConfigOption>;
}
+47
View File
@@ -0,0 +1,47 @@
use std::str::FromStr;
use dialoguer::{Input, theme::ColorfulTheme};
#[macro_export]
macro_rules! interactive_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
$var
} else {
$crate::commands::connect::interactive::query_variable($prompt).unwrap()
};
};
}
#[macro_export]
macro_rules! interactive_optional_variable {
($value:ident, $var:ident, $prompt:expr) => {
let $var = if let Some($var) = $value.$var {
Some($var)
} else {
$crate::commands::connect::interactive::query_optional_variable($prompt).unwrap()
};
};
}
pub fn query_variable<T: Clone + FromStr + ToString>(prompt: impl ToString) -> dialoguer::Result<T>
where
<T as FromStr>::Err: ToString,
{
Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.interact_text()
}
pub fn query_optional_variable<T: Clone + FromStr + ToString>(
prompt: impl ToString,
) -> dialoguer::Result<Option<T>>
where
<T as FromStr>::Err: ToString,
{
let input: T = Input::with_theme(&ColorfulTheme::default())
.with_prompt(prompt.to_string())
.allow_empty(true)
.interact_text()?;
if input.to_string().is_empty() {
return Ok(None);
}
Ok(Some(input))
}
+7
View File
@@ -0,0 +1,7 @@
pub mod config;
pub mod configurable;
pub mod s3;
#[macro_use]
pub mod interactive;
pub mod config_option;
pub mod speedtest;
+67
View File
@@ -0,0 +1,67 @@
use clap::Args;
use opendal::Operator;
use serde::{Deserialize, Serialize};
use crate::{
commands::connect::{config_option::ConfigOption, configurable::Configure},
interactive_variable,
operator_builder::OperatorBuilder,
};
#[derive(Args, Clone)]
pub struct S3ConfigCli {
key_id: Option<String>,
secret_key: Option<String>,
endpoint: Option<String>,
region: Option<String>,
bucket_name: Option<String>,
root: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Config {
key_id: String,
secret_key: String,
endpoint: String,
region: String,
bucket_name: String,
root: Option<String>,
}
impl Configure for S3ConfigCli {
async fn configure(self, name: &mut Option<String>) -> anyhow::Result<ConfigOption> {
interactive_variable!(self, key_id, "S3 Key ID");
interactive_variable!(self, secret_key, "S3 Secret Key");
interactive_variable!(self, region, "S3 Region");
interactive_variable!(self, bucket_name, "S3 Bucket Name");
interactive_variable!(self, endpoint, "S3 Endpoint");
if let None = name {
*name = Some(endpoint.clone());
}
Ok(ConfigOption::S3(S3Config {
secret_key,
key_id,
region,
bucket_name,
endpoint,
root: self.root,
}))
}
}
impl OperatorBuilder for S3Config {
fn build(&self) -> anyhow::Result<Operator> {
let builder = opendal::services::S3::default()
.access_key_id(&self.key_id)
.secret_access_key(&self.secret_key)
.region(&self.region)
.endpoint(&self.endpoint)
.root(self.root.as_deref().unwrap_or("/"))
.bucket(&self.bucket_name)
.disable_config_load();
let op: Operator = Operator::new(builder)?.finish();
Ok(op)
}
}
+41
View File
@@ -0,0 +1,41 @@
use rand::{RngCore, SeedableRng, rng, rngs::StdRng};
use tokio::io::AsyncRead;
#[derive(Clone, Debug)]
pub struct Speedtest<F: Fn(f32)> {
core: rand::rngs::StdRng,
to_write: usize,
callback: Box<F>,
}
pub const SPEEDTEST_BYTES: usize = 64 * 1024 * 1024;
pub const SPEEDTEST_PATH: &str = "speedtest";
impl<F: Fn(f32)> AsyncRead for Speedtest<F> {
fn poll_read(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let mut s = self;
let to_write = buf.remaining().min(s.to_write);
let filled = {
let fill_slice = buf.initialize_unfilled_to(to_write);
s.core.fill_bytes(fill_slice);
fill_slice.len()
};
s.to_write = s.to_write.saturating_sub(filled);
(s.callback)((1f32 - (s.to_write as f32 / SPEEDTEST_BYTES as f32)) * 100f32);
buf.advance(filled);
std::task::Poll::Ready(Ok(()))
}
}
impl<F: Fn(f32)> Speedtest<F> {
pub fn new(callback: F) -> Self {
Self {
core: StdRng::from_rng(&mut rng()),
to_write: SPEEDTEST_BYTES,
callback: Box::new(callback),
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod connect;
pub mod upload;
+77
View File
@@ -0,0 +1,77 @@
use std::path::Path;
use crate::{
cli::UploadInfo,
commands::connect::{config::Config, config_option::ConfigOption},
manifest::{CompressionOption, DepotManifest, generate_v2_manifest},
operator_builder::OperatorBuilder,
};
use futures::AsyncWriteExt;
use log::info;
use opendal::{FuturesAsyncWriter, Operator};
use tokio_util::compat::{Compat, FuturesAsyncWriteCompatExt};
pub async fn upload(
info: &UploadInfo,
config: Config,
name: &Option<String>,
) -> anyhow::Result<()> {
let game_id = &info.game_id;
let path = &info.path;
let version_id = &info.version_id;
let operator = get_operator(config, name)?;
let mut existing_depot_manifest = get_depot_manifest(&operator).await?;
info!("Uploading chunks");
let v2_manifest = generate_v2_manifest(
Path::new(path),
async |id: String| {
info!("Uploading chunk id {id}");
let writer = operator
.writer(&format!("{game_id}/{version_id}/{id}"))
.await
.unwrap()
.into_futures_async_write()
.compat_write();
writer
},
|writer: Compat<FuturesAsyncWriter>| async {
writer.into_inner().close().await.unwrap();
},
)
.await?;
info!("Finished uploading chunks");
existing_depot_manifest.append(
game_id.to_string(),
version_id.to_string(),
CompressionOption::None,
);
Ok(())
}
async fn get_depot_manifest(operator: &Operator) -> Result<DepotManifest, anyhow::Error> {
let existing_depot_manifest = operator.read("manifest.json").await?.to_bytes();
let existing_depot_manifest: DepotManifest =
serde_json::from_slice(existing_depot_manifest.as_ref())?;
Ok(existing_depot_manifest)
}
fn get_operator(config: Config, name: &Option<String>) -> anyhow::Result<Operator> {
let operator = match if let Some(name) = name {
config
.get(name)
.ok_or(anyhow::anyhow!("Name does not exist"))?
} else {
config.get_active().ok_or(anyhow::anyhow!(
"No active connection set. Please specify with --name"
))?
} {
ConfigOption::S3(s3_config) => s3_config.build()?,
};
Ok(operator)
}
+1
View File
@@ -0,0 +1 @@
pub mod interface;
+53
View File
@@ -0,0 +1,53 @@
use fern::colors::{Color, ColoredLevelConfig};
use log::LevelFilter;
use std::env;
use std::fs;
use std::io;
pub fn configure_logging() -> anyhow::Result<()> {
let log_level = env::var("RUST_LOG")
.unwrap_or_else(|_| "info".to_string())
.parse::<LevelFilter>()?;
let log_dir = env::var("LOG_FILE_DIR").unwrap_or_else(|_| "logs".to_string());
fs::create_dir_all(&log_dir)?;
let colors = ColoredLevelConfig::new()
.error(Color::Red)
.warn(Color::Yellow)
.info(Color::Blue)
.debug(Color::Green)
.trace(Color::Magenta);
fern::Dispatch::new()
.chain(
fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(format_args!(
"[{}] {}: {}",
chrono::Local::now().format("%H:%M:%S%.3f"),
colors.color(record.level()),
message
))
})
.chain(io::stdout()),
)
.chain(
fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"[{}] {} {} - {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"),
record.level(),
record.target(),
message
))
})
.chain(fern::log_file(format!("{}/app.log", log_dir))?),
)
.level(log_level)
.apply()?;
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
use crate::commands::connect::config::manage_configuration;
use crate::{
cli::{Cli, Commands},
commands::connect::config::Config,
commands::upload,
};
use clap::Parser;
mod cli;
mod commands;
mod logging;
mod manifest;
mod operator_builder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
crate::logging::configure_logging()?;
let cli = Cli::parse();
let mut config = Config::read();
match cli.command {
Commands::Connect { name, option } => {
manage_configuration(&mut config, name, option).await?
}
Commands::Upload { info, name } => {
let info = info.interactive_configure();
upload::interface::upload(&info, config, &name).await?;
}
};
Ok(())
}
+66
View File
@@ -0,0 +1,66 @@
use std::{collections::HashMap, path::Path};
use droplet_rs::manifest::{
Manifest, generate_manifest_rusty, generate_manifest_rusty_v2,
};
use indicatif::{ProgressBar, ProgressStyle};
use log::info;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWrite;
#[derive(Serialize, Deserialize)]
pub struct DepotManifest {
content: HashMap<String, DepotManifestGameData>,
}
#[derive(Serialize, Deserialize)]
struct DepotManifestGameData {
version_id: String,
compression: CompressionOption,
}
#[derive(Serialize, Deserialize)]
pub enum CompressionOption {
None,
Gzip,
Zstd,
}
impl DepotManifest {
pub fn new() -> Self {
Self {
content: HashMap::new(),
}
}
pub fn append(&mut self, game_id: String, version_id: String, compression: CompressionOption) {
self.content.insert(
game_id,
DepotManifestGameData {
version_id,
compression,
},
);
}
}
pub async fn generate_v2_manifest<W, F, CloseF>(dir: &Path, factory: F, closer: CloseF) -> anyhow::Result<Manifest>
where
W: AsyncWrite + Unpin,
F: AsyncFn(String) -> W,
CloseF: AsyncFn(W)
{
let progress_bar = ProgressBar::new(10_000).with_style(
ProgressStyle::default_bar()
.template("[{elapsed_precise}] [ETA {eta}] {bar} {percent_precise}%")
.unwrap(),
);
generate_manifest_rusty_v2(
dir,
|progress| {
let progress_int = (progress * 100f32).round() as u64;
progress_bar.set_position(progress_int);
},
|log| progress_bar.suspend(|| info!("{}", log)),
factory,
closer
)
.await
}
+5
View File
@@ -0,0 +1,5 @@
use opendal::Operator;
pub trait OperatorBuilder {
fn build(&self) -> anyhow::Result<Operator>;
}