From 7fc9447e43686c7e264bb5e44a46ce9c91b23384 Mon Sep 17 00:00:00 2001 From: Bechma <19294519+Bechma@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:14:21 +0100 Subject: [PATCH] refactor: replace string registry with enum and update related logic Signed-off-by: Bechma <19294519+Bechma@users.noreply.github.com> --- .github/workflows/ci.yml | 4 + AGENTS.md | 10 +- README.md | 106 +++++++++++++++++- crates/cli/src/common.rs | 24 ++++- crates/cli/src/config/modules/list.rs | 37 +++---- crates/cli/src/docs/mod.rs | 47 ++++---- crates/cli/src/tools/mod.rs | 148 ++++++++++++-------------- 7 files changed, 249 insertions(+), 127 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6b9cf7..2aa901f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,10 @@ name: CI on: pull_request: branches: [ "main" ] + paths: + - '**/*.rs' + - '**/*.toml' + - 'Cargo.lock' env: CARGO_TERM_COLOR: always diff --git a/AGENTS.md b/AGENTS.md index e3367d1..62ee855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,10 @@ ## General -Always prefer `cargo clippy` over cargo check -Always format the code with `cargo fmt` and run the test suite with `cargo test` before finalizing -Always prefer `cargo add` over manually editing Cargo.toml +Always prefer `cargo clippy` over cargo check. +Always format the code with `cargo fmt` and run the test suite with `cargo test` before finalizing if any rust code was +touched. + +Always prefer `cargo add` over manually editing `Cargo.toml`. + +Always prefer enums over strings when there's a clear set of valid values. diff --git a/README.md b/README.md index 3499d0c..f214d8d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,104 @@ -# cf-cli -CyberFabric repositories automation tool +# CyberFabric CLI + +Command-line interface for development and management of CyberFabric modules. + +## Quickstart + +### Prerequisites + +- Rust toolchain with `cargo` +- A local clone of this repository + +### Install the CLI + +This workspace exposes two binaries: + +- `cyberfabric` +- `cargo-cyberfabric` for the `cargo cyberfabric ...` invocation form + +Install both from the repository root: + +```bash +cargo install --path crates/cli --bin cyberfabric --bin cargo-cyberfabric +``` + +After installation, you can use either form: + +```bash +cyberfabric --help +``` + +```bash +cargo cyberfabric --help +``` + +For local development without installing: + +```bash +cargo run -p cli -- --help +``` + +## What the CLI can do + +The current CLI surface is centered on CyberFabric workspace setup, configuration, code generation, and execution. + +### Workspace scaffolding + +- `mod init` initializes a new CyberFabric workspace from a template +- `mod add` adds module templates such as `background-worker`, `api-db-handler`, and `rest-gateway` + +### Configuration management + +- `config mod list` inspects available and configured modules +- `config mod add` and `config mod rm` manage module entries in the YAML config +- `config mod db add|edit|rm` manages module-level database settings +- `config db add|edit|rm` manages shared database server definitions + +You need to provide the path to the configuration file with the `-c` flag. `-c config/quickstart.yml` + +### Build and run generated servers + +- `build` generates a runnable Cargo project under `.cyberfabric/` and builds it based on the `-c` configuration + provided. +- `run` generates the same project and runs it. You can provide `-w` to enable watch mode and/or `--otel` to enable + OpenTelemetry. + +### Source inspection + +- `docs` resolves Rust source for crates, modules, and items from the workspace, local cache, or `crates.io` + +### Tool bootstrap + +- `tools` installs or upgrades `rustup`, `rustfmt`, and `clippy` + +### Current placeholders + +- `lint` is declared but not implemented yet +- `test` is declared but not implemented yet + +## Typical usage flow + +Create a workspace, add a module, configure it, and run it: + +```bash +cyberfabric mod init /tmp/cf-demo +cyberfabric mod add background-worker -p /tmp/cf-demo +cyberfabric config mod add background-worker -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml +cyberfabric run -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml +``` + +The `-p` is to specify the path. If you don't provide it, the default will be the current directory. + +## Command overview + +For the full command surface, arguments, and examples, check [SKILLS.md](SKILLS.md). + +## License + +This project is licensed under the Apache License, Version 2.0. + +- Full license text: `LICENSE` +- License URL: + +Unless required by applicable law or agreed to in writing, the software is distributed on an `AS IS` basis, without +warranties or conditions of any kind. diff --git a/crates/cli/src/common.rs b/crates/cli/src/common.rs index e14331e..67e363a 100644 --- a/crates/cli/src/common.rs +++ b/crates/cli/src/common.rs @@ -1,10 +1,11 @@ use anyhow::Context; -use clap::Args; +use clap::{Args, ValueEnum}; use module_parser::{ CargoToml, CargoTomlDependencies, CargoTomlDependency, Config, ConfigModuleMetadata, get_dependencies, get_module_name_from_crate, }; use std::collections::HashMap; +use std::fmt::{self, Display}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -49,6 +50,27 @@ pub struct BuildRunArgs { pub clean: bool, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum Registry { + #[default] + #[value(name = "crates.io")] + CratesIo, +} + +impl Registry { + pub const fn as_str(self) -> &'static str { + match self { + Self::CratesIo => "crates.io", + } + } +} + +impl Display for Registry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + impl BuildRunArgs { pub fn resolve_workspace_and_config(&self) -> anyhow::Result<(PathBuf, PathBuf)> { let path = self.path_config.resolve_path()?; diff --git a/crates/cli/src/config/modules/list.rs b/crates/cli/src/config/modules/list.rs index 9d56cda..6e5007d 100644 --- a/crates/cli/src/config/modules/list.rs +++ b/crates/cli/src/config/modules/list.rs @@ -1,5 +1,5 @@ use super::{SYSTEM_REGISTRY_MODULES, SystemRegistryModule, load_config, resolve_modules_context}; -use crate::common::PathConfigArgs; +use crate::common::{PathConfigArgs, Registry}; use crate::config::app_config::ModuleConfig; use anyhow::{Context, bail}; use clap::Args; @@ -27,9 +27,11 @@ pub struct ListArgs { /// Show all information related to the module. #[arg(short = 'v', long)] verbose: bool, - /// Registry to query when verbose mode is enabled. - #[arg(long, default_value = "crates.io")] - registry: String, + /// Registry to query for system-crate metadata. Only consulted when both + /// `--system` and `--verbose` are enabled; `--verbose` alone does not query + /// any registry. Defaults to `crates.io`. + #[arg(long, value_enum, default_value_t = Registry::CratesIo)] + registry: Registry, } impl ListArgs { @@ -42,19 +44,13 @@ impl ListArgs { if self.system { println!("System crates:"); if self.verbose { - if self.registry != "crates.io" { - let registry = &self.registry; - bail!( - "unsupported registry '{registry}'. Only 'crates.io' is currently supported" - ); - } - let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .context("failed to build tokio runtime for registry queries")?; - let metadata_by_crate = runtime.block_on(fetch_all_crates_io_metadata())?; + let metadata_by_crate = + runtime.block_on(fetch_all_registry_metadata(self.registry))?; for module in SYSTEM_REGISTRY_MODULES { let Some(metadata) = metadata_by_crate.get(module.crate_name) else { @@ -208,7 +204,9 @@ struct CrateVersion { features: BTreeMap>, } -async fn fetch_all_crates_io_metadata() -> anyhow::Result> { +async fn fetch_all_registry_metadata( + registry: Registry, +) -> anyhow::Result> { let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(4)); let client = Client::builder() .user_agent("cyberfabric-cli") @@ -225,7 +223,7 @@ async fn fetch_all_crates_io_metadata() -> anyhow::Result((module.crate_name, metadata)) @@ -241,11 +239,12 @@ async fn fetch_all_crates_io_metadata() -> anyhow::Result anyhow::Result { - let crate_url = format!("https://crates.io/api/v1/crates/{}", module.crate_name); + let crate_url = format!("https://{registry}/api/v1/crates/{}", module.crate_name); let crate_response = client .get(&crate_url) .send() @@ -264,7 +263,8 @@ async fn fetch_crates_io_metadata( .find(|version| version.num == latest_version) .map_or_else(Vec::new, |version| version.features.into_keys().collect()); - let module_rs_content = fetch_module_rs_content(client, module, &latest_version).await?; + let module_rs_content = + fetch_module_rs_content(client, registry, module, &latest_version).await?; let module_metadata = parse_module_rs_source(&module_rs_content) .with_context(|| format!("invalid src/module.rs for {}", module.crate_name))?; @@ -278,11 +278,12 @@ async fn fetch_crates_io_metadata( async fn fetch_module_rs_content( client: &Client, + registry: Registry, module: SystemRegistryModule, latest_version: &str, ) -> anyhow::Result { let download_url = format!( - "https://crates.io/api/v1/crates/{}/{}/download", + "https://{registry}/api/v1/crates/{}/{}/download", module.crate_name, latest_version ); let crate_archive = client diff --git a/crates/cli/src/docs/mod.rs b/crates/cli/src/docs/mod.rs index 43558b4..9ea34b6 100644 --- a/crates/cli/src/docs/mod.rs +++ b/crates/cli/src/docs/mod.rs @@ -1,3 +1,4 @@ +use crate::common::Registry; use anyhow::{Context, bail}; use clap::Args; use flate2::read::GzDecoder; @@ -23,8 +24,8 @@ pub struct DocsArgs { #[arg(short = 'p', long, default_value = ".")] path: PathBuf, /// Registry to query when the crate is not present in local metadata - #[arg(long, default_value = "crates.io")] - registry: String, + #[arg(long, value_enum, default_value_t = Registry::CratesIo)] + registry: Registry, /// Print query/package/version/source metadata before the resolved Rust source #[arg(short = 'v', long)] verbose: bool, @@ -47,7 +48,7 @@ pub struct DocsArgs { impl DocsArgs { pub fn run(&self) -> anyhow::Result<()> { if self.clean { - clean_registry_cache(&self.registry)?; + clean_registry_cache(self.registry)?; } let Some(query) = self.query.as_deref() else { @@ -71,7 +72,7 @@ impl DocsArgs { workspace_path: &workspace_path, client: &client, runtime: &runtime, - registry: &self.registry, + registry: self.registry, }; let mut visited = HashSet::new(); let final_resolution = resolve_query_recursive( @@ -206,7 +207,7 @@ struct Resolver<'a> { workspace_path: &'a Path, client: &'a Client, runtime: &'a tokio::runtime::Runtime, - registry: &'a str, + registry: Registry, } fn resolve_query_recursive( @@ -257,7 +258,7 @@ fn resolve_from_paths( preferred_path: &Path, client: &Client, runtime: &tokio::runtime::Runtime, - registry: &str, + registry: Registry, query: &str, requested_version: Option<&Version>, ) -> anyhow::Result> { @@ -283,14 +284,10 @@ fn resolve_from_paths( async fn resolve_from_registry( client: &Client, - registry: &str, + registry: Registry, query: &str, requested_version: Option<&Version>, ) -> anyhow::Result { - if registry != "crates.io" { - bail!("unsupported registry '{registry}'. Only 'crates.io' is currently supported"); - } - let crate_name = query .split("::") .next() @@ -304,10 +301,10 @@ async fn resolve_from_registry( let resolved_version = if let Some(requested_version) = requested_version { requested_version.to_string() } else { - fetch_exact_crates_io_candidate(client, registry, crate_name) + fetch_exact_registry_candidate(client, registry, crate_name) .await? .with_context(|| { - format!("could not resolve package '{crate_name}' from the crates.io registry") + format!("could not resolve package '{crate_name}' from the {registry} registry") })? .max_version }; @@ -713,9 +710,9 @@ fn normalize_dependency_alias(alias: &str) -> String { alias.replace('-', "_") } -async fn fetch_exact_crates_io_candidate( +async fn fetch_exact_registry_candidate( client: &Client, - registry: &str, + registry: Registry, crate_name: &str, ) -> anyhow::Result> { let crate_url = format!("https://{registry}/api/v1/crates/{crate_name}"); @@ -758,7 +755,7 @@ struct ExactCrate { async fn download_crate_archive( client: &Client, - registry: &str, + registry: Registry, crate_name: &str, version: &str, ) -> anyhow::Result> { @@ -779,7 +776,7 @@ async fn download_crate_archive( async fn cache_crate_source( client: &Client, - registry: &str, + registry: Registry, crate_name: &str, version: &str, ) -> anyhow::Result { @@ -801,7 +798,7 @@ async fn cache_crate_source( } } -fn registry_cache_root(registry: &str) -> anyhow::Result { +fn registry_cache_root(registry: Registry) -> anyhow::Result { let cache_root = std::env::temp_dir() .join("cyberfabric-docs-cache") .join(sanitize_registry_name(registry)); @@ -810,7 +807,7 @@ fn registry_cache_root(registry: &str) -> anyhow::Result { Ok(cache_root) } -fn package_cache_root(registry: &str, crate_name: &str) -> anyhow::Result { +fn package_cache_root(registry: Registry, crate_name: &str) -> anyhow::Result { let package_root = registry_cache_root(registry)?.join(crate_name); fs::create_dir_all(&package_root).with_context(|| { format!( @@ -822,7 +819,7 @@ fn package_cache_root(registry: &str, crate_name: &str) -> anyhow::Result, @@ -899,7 +896,7 @@ fn cached_package_versions(package_root: &Path) -> anyhow::Result>()) } -fn clean_registry_cache(registry: &str) -> anyhow::Result<()> { +fn clean_registry_cache(registry: Registry) -> anyhow::Result<()> { let cache_root = std::env::temp_dir() .join("cyberfabric-docs-cache") .join(sanitize_registry_name(registry)); @@ -910,8 +907,9 @@ fn clean_registry_cache(registry: &str) -> anyhow::Result<()> { Ok(()) } -fn sanitize_registry_name(registry: &str) -> String { +fn sanitize_registry_name(registry: Registry) -> String { registry + .as_str() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { @@ -1023,6 +1021,7 @@ mod tests { next_reexport_step, parse_dependencies, resolve_query_recursive, should_retry_registry_request, }; + use crate::common::Registry; use module_parser::resolve_source_from_metadata; use module_parser::test_utils::TempDirExt; use reqwest::{Method, StatusCode}; @@ -1120,7 +1119,7 @@ mod tests { workspace_path: project.path(), client: &client, runtime: &runtime, - registry: "crates.io", + registry: Registry::CratesIo, }; let mut visited = HashSet::new(); @@ -1290,7 +1289,7 @@ mod tests { workspace_path: project.path(), client: &client, runtime: &runtime, - registry: "crates.io", + registry: Registry::CratesIo, }; let mut visited = HashSet::new(); diff --git a/crates/cli/src/tools/mod.rs b/crates/cli/src/tools/mod.rs index fa30011..10c1a50 100644 --- a/crates/cli/src/tools/mod.rs +++ b/crates/cli/src/tools/mod.rs @@ -1,7 +1,8 @@ use anyhow::{Context, bail}; -use clap::Args; +use clap::{Args, ValueEnum}; use std::io::{self, Write}; use std::process::Command; +use std::{fmt, slice}; #[derive(Args)] pub struct ToolsArgs { @@ -12,8 +13,8 @@ pub struct ToolsArgs { #[arg(short = 'u', long)] upgrade: bool, /// Install specific tools - #[arg(long, value_delimiter = ',', conflicts_with = "all")] - install: Option>, + #[arg(long, value_delimiter = ',', value_enum, conflicts_with = "all")] + install: Option>, /// Do not ask for confirmation #[arg(short = 'y', long)] yolo: bool, @@ -22,34 +23,56 @@ pub struct ToolsArgs { verbose: bool, } -struct Tool { - name: &'static str, - check_binary: &'static str, - install: InstallMethod, +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum ToolName { + Rustup, + Rustfmt, + Clippy, } +impl ToolName { + const fn as_str(self) -> &'static str { + match self { + Self::Rustup => "rustup", + Self::Rustfmt => "rustfmt", + Self::Clippy => "clippy", + } + } + + const fn check_binary(self) -> &'static str { + match self { + Self::Rustup => "rustup", + Self::Rustfmt => "rustfmt", + Self::Clippy => "cargo-clippy", + } + } + + const fn install_method(self) -> InstallMethod { + match self { + Self::Rustup => InstallMethod::Prerequisite, + Self::Rustfmt => InstallMethod::RustupComponent("rustfmt"), + Self::Clippy => InstallMethod::RustupComponent("clippy"), + } + } + + fn all() -> slice::Iter<'static, Self> { + ALL_TOOLS.iter() + } +} + +impl fmt::Display for ToolName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy)] enum InstallMethod { RustupComponent(&'static str), Prerequisite, } -const TOOLS: &[Tool] = &[ - Tool { - name: "rustup", - check_binary: "rustup", - install: InstallMethod::Prerequisite, - }, - Tool { - name: "cargofmt", - check_binary: "rustfmt", - install: InstallMethod::RustupComponent("rustfmt"), - }, - Tool { - name: "clippy", - check_binary: "cargo-clippy", - install: InstallMethod::RustupComponent("clippy"), - }, -]; +const ALL_TOOLS: &[ToolName] = &[ToolName::Rustup, ToolName::Rustfmt, ToolName::Clippy]; impl ToolsArgs { pub fn run(&self) -> anyhow::Result<()> { @@ -62,60 +85,48 @@ impl ToolsArgs { self.install_tools(&tools) } - fn resolve_tools(&self) -> anyhow::Result> { - if let Some(names) = &self.install { - let mut tools = Vec::with_capacity(names.len()); - for name in names { - let tool = TOOLS - .iter() - .find(|t| t.name == name.as_str()) - .with_context(|| { - format!( - "unknown tool '{}'. known tools: {}", - name, - TOOLS.iter().map(|t| t.name).collect::>().join(", ") - ) - })?; - tools.push(tool); - } - return Ok(tools); + fn resolve_tools(&self) -> anyhow::Result> { + if let Some(tools) = &self.install { + return Ok(tools.clone()); } if self.all { - return Ok(TOOLS.iter().collect()); + return Ok(ToolName::all().copied().collect()); } bail!( "no tools specified. Use --all to install all tools, or --install to install specific tools. \ Known tools: {}", - TOOLS.iter().map(|t| t.name).collect::>().join(", ") + ToolName::all() + .map(ToString::to_string) + .collect::>() + .join(", ") ) } - fn install_tools(&self, tools: &[&Tool]) -> anyhow::Result<()> { + fn install_tools(&self, tools: &[ToolName]) -> anyhow::Result<()> { ensure_rustup(self.yolo)?; for tool in tools { - let installed = is_installed(tool.check_binary); + let installed = is_installed(tool.check_binary()); if installed { - println!("✓ {} is already installed", tool.name); + println!("✓ {tool} is already installed"); continue; } - match tool.install { + match tool.install_method() { InstallMethod::Prerequisite => { bail!( - "'{}' is required but not found. Please install it manually: https://rustup.rs", - tool.name + "'{tool}' is required but not found. Please install it manually: https://rustup.rs" ); } InstallMethod::RustupComponent(component) => { - if !self.yolo && !confirm(&format!("Install {} via rustup?", tool.name))? { - println!("Skipping {}", tool.name); + if !self.yolo && !confirm(&format!("Install {tool} via rustup?"))? { + println!("Skipping {tool}"); continue; } rustup_component_add(component, self.verbose)?; - println!("✓ {} installed", tool.name); + println!("✓ {tool} installed"); } } } @@ -123,10 +134,10 @@ impl ToolsArgs { Ok(()) } - fn upgrade_tools(&self, tools: &[&Tool]) -> anyhow::Result<()> { + fn upgrade_tools(&self, tools: &[ToolName]) -> anyhow::Result<()> { ensure_rustup(self.yolo)?; - let has_rustup = tools.iter().any(|t| t.name == "rustup"); + let has_rustup = tools.contains(&ToolName::Rustup); if has_rustup { if !self.yolo && !confirm("Upgrade rustup via 'rustup self update'?")? { println!("Skipping rustup upgrade"); @@ -142,7 +153,8 @@ impl ToolsArgs { let components: Vec<_> = tools .iter() - .filter(|t| matches!(t.install, InstallMethod::RustupComponent(_))) + .copied() + .filter(|tool| matches!(tool.install_method(), InstallMethod::RustupComponent(_))) .collect(); if !components.is_empty() { @@ -152,8 +164,8 @@ impl ToolsArgs { } run_verbose(Command::new("rustup").arg("update"), self.verbose) .context("failed to run rustup update")?; - for tool in &components { - println!("✓ {} upgraded", tool.name); + for tool in components { + println!("✓ {tool} upgraded"); } } @@ -175,32 +187,10 @@ fn ensure_rustup(yolo: bool) -> anyhow::Result<()> { println!("Installing rustup..."); install_rustup().context("failed to install rustup")?; - - if !is_installed("rustup") && !is_installed(&cargo_bin_path("rustup")) { - bail!( - "rustup was installed but is not available on PATH. \ - Please restart your shell or add ~/.cargo/bin to your PATH." - ); - } - println!("✓ rustup installed"); Ok(()) } -fn cargo_bin_path(binary: &str) -> String { - let home = std::env::home_dir().unwrap_or_default(); - let bin = if cfg!(target_family = "windows") { - format!("{binary}.exe") - } else { - binary.to_string() - }; - home.join(".cargo") - .join("bin") - .join(bin) - .to_string_lossy() - .into_owned() -} - fn install_rustup() -> anyhow::Result<()> { if cfg!(target_family = "unix") { let status = Command::new("sh")