Update botlib

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-21 23:40:41 -03:00
parent 7928c0ef14
commit 2de330dbe9
9 changed files with 368 additions and 331 deletions

View file

@ -6,6 +6,8 @@ description = "Shared library for General Bots - common types, utilities, and HT
license = "AGPL-3.0" license = "AGPL-3.0"
authors = ["Pragmatismo.com.br", "General Bots Community"] authors = ["Pragmatismo.com.br", "General Bots Community"]
repository = "https://github.com/GeneralBots/BotServer" repository = "https://github.com/GeneralBots/BotServer"
keywords = ["bot", "chatbot", "ai", "conversational", "library"]
categories = ["api-bindings", "web-programming"]
[features] [features]
default = [] default = []
@ -36,3 +38,24 @@ validator = { version = "0.18", features = ["derive"], optional = true }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.41", features = ["rt", "macros"] } tokio = { version = "1.41", features = ["rt", "macros"] }
[lints.rust]
unused_imports = "warn"
unused_variables = "warn"
unused_mut = "warn"
unsafe_code = "deny"
missing_debug_implementations = "warn"
[lints.clippy]
all = "warn"
pedantic = "warn"
nursery = "warn"
cargo = "warn"
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"
todo = "warn"
# Disabled: has false positives for functions with mut self, heap types (Vec, String)
missing_const_for_fn = "allow"
# Disabled: transitive dependencies we cannot control
multiple_crate_versions = "allow"

360
PROMPT.md
View file

@ -5,135 +5,137 @@
--- ---
## Version Management - CRITICAL ## ZERO TOLERANCE POLICY
**Current version is 6.1.0 - DO NOT CHANGE without explicit approval!** **This project has the strictest code quality requirements possible.**
### Rules **EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
1. **Version is 6.1.0 across ALL workspace crates**
2. **NEVER change version without explicit user approval**
3. **All workspace crates share version 6.1.0**
4. **BotLib does not have migrations - all migrations are in botserver/**
--- ---
## Official Icons - Reference ## ABSOLUTE PROHIBITIONS
**BotLib does not contain icons.** Icons are managed in: ```
- `botui/ui/suite/assets/icons/` - Runtime UI icons ❌ NEVER use #![allow()] or #[allow()] in source code to silence warnings
- `botbook/src/assets/icons/` - Documentation icons ❌ NEVER use _ prefix for unused variables - DELETE the variable or USE it
❌ NEVER use .unwrap() - use ? or proper error handling
When documenting or referencing UI elements in BotLib: ❌ NEVER use .expect() - use ? or proper error handling
- Reference icons by name (e.g., `gb-chat.svg`, `gb-drive.svg`) ❌ NEVER use panic!() or unreachable!() - handle all cases
- Never generate or embed icon content ❌ NEVER use todo!() or unimplemented!() - write real code
- See `botui/PROMPT.md` for the complete icon list ❌ NEVER leave unused imports - DELETE them
❌ NEVER leave dead code - DELETE it or IMPLEMENT it
❌ NEVER use approximate constants (3.14159) - use std::f64::consts::PI
❌ NEVER silence clippy in code - FIX THE CODE or configure in Cargo.toml
❌ NEVER add comments explaining what code does - code must be self-documenting
```
--- ---
## Weekly Maintenance - EVERY MONDAY ## CARGO.TOML LINT EXCEPTIONS
### Package Review Checklist When a clippy lint has **technical false positives** that cannot be fixed in code,
disable it in `Cargo.toml` with a comment explaining why:
**Every Monday, review the following:** ```toml
[lints.clippy]
# Disabled: has false positives for functions with mut self, heap types (Vec, String)
missing_const_for_fn = "allow"
# Disabled: Tauri commands require owned types (Window) that cannot be passed by reference
needless_pass_by_value = "allow"
# Disabled: transitive dependencies we cannot control
multiple_crate_versions = "allow"
```
1. **Dependency Updates** **Approved exceptions:**
```bash - `missing_const_for_fn` - false positives for `mut self`, heap types
cargo outdated - `needless_pass_by_value` - Tauri/framework requirements
cargo audit - `multiple_crate_versions` - transitive dependencies
``` - `future_not_send` - when async traits require non-Send futures
2. **Package Consolidation Opportunities** ---
- Check if new crates can replace custom code
- Look for crates that combine multiple dependencies
- Review `Cargo.toml` for redundant dependencies
3. **Code Reduction Candidates** ## MANDATORY CODE PATTERNS
- Custom implementations that now have crate equivalents
- Boilerplate that can be replaced with derive macros
- Re-exports that can simplify downstream usage
4. **Feature Flag Review** ### Error Handling - Use `?` Operator
- Check if optional features are still needed
- Consolidate similar features
- Remove unused feature gates
### Packages to Watch ```rust
// ❌ WRONG
let value = something.unwrap();
let value = something.expect("msg");
| Area | Potential Packages | Purpose | // ✅ CORRECT
|------|-------------------|---------| let value = something?;
| Error Handling | `anyhow`, `thiserror` | Consolidate error types | let value = something.ok_or_else(|| Error::NotFound)?;
| Validation | `validator` | Replace manual validation | ```
| Serialization | `serde` derives | Reduce boilerplate |
| UUID | `uuid` | Consistent ID generation | ### Self Usage in Impl Blocks
```rust
// ❌ WRONG
impl MyStruct {
fn new() -> MyStruct { MyStruct { } }
}
// ✅ CORRECT
impl MyStruct {
fn new() -> Self { Self { } }
}
```
### Format Strings - Inline Variables
```rust
// ❌ WRONG
format!("Hello {}", name)
// ✅ CORRECT
format!("Hello {name}")
```
### Display vs ToString
```rust
// ❌ WRONG
impl ToString for MyType { }
// ✅ CORRECT
impl std::fmt::Display for MyType { }
```
### Derive Eq with PartialEq
```rust
// ❌ WRONG
#[derive(PartialEq)]
struct MyStruct { }
// ✅ CORRECT
#[derive(PartialEq, Eq)]
struct MyStruct { }
```
---
## Version Management
**Version is 6.1.0 - NEVER CHANGE without explicit approval**
--- ---
## Project Overview ## Project Overview
BotLib is the shared foundation library for the General Bots workspace. It provides common types, utilities, error handling, and optional integrations that are consumed by botserver, botui, and botapp. BotLib is the shared foundation library for the General Bots workspace.
### Workspace Position
``` ```
botlib/ # THIS PROJECT - Shared library botlib/ # THIS PROJECT - Shared library
botserver/ # Main server (depends on botlib) botserver/ # Main server (depends on botlib)
botui/ # Web/Desktop UI (depends on botlib) botui/ # Web/Desktop UI (depends on botlib)
botapp/ # Desktop app (depends on botlib) botapp/ # Desktop app (depends on botlib)
botbook/ # Documentation
```
### What BotLib Provides
- **Error Types**: Common error handling with anyhow/thiserror
- **Models**: Shared data structures and types
- **HTTP Client**: Optional reqwest wrapper
- **Database**: Optional diesel integration
- **Validation**: Optional input validation
- **Branding**: Version and branding constants
---
## Feature Flags
```toml
[features]
default = []
full = ["database", "http-client", "validation"]
database = ["dep:diesel"]
http-client = ["dep:reqwest"]
validation = ["dep:validator"]
```
### Usage in Dependent Crates
```toml
# botserver/Cargo.toml
[dependencies.botlib]
path = "../botlib"
features = ["database"]
# botui/Cargo.toml
[dependencies.botlib]
path = "../botlib"
features = ["http-client"]
``` ```
--- ---
## Code Generation Rules ## Module Structure
### CRITICAL REQUIREMENTS
```
- Library code must be generic and reusable
- No hardcoded values or project-specific logic
- All public APIs must be well-documented
- Feature gates for optional dependencies
- Zero warnings - clean compilation required
```
### Module Structure
``` ```
src/ src/
@ -148,153 +150,31 @@ src/
--- ---
## Adding New Features
### Adding a Shared Type
```rust
// src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedEntity {
pub id: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
}
```
### Adding a Feature-Gated Module
```rust
// src/lib.rs
#[cfg(feature = "my-feature")]
pub mod my_module;
#[cfg(feature = "my-feature")]
pub use my_module::MyType;
```
```toml
# Cargo.toml
[features]
my-feature = ["dep:some-crate"]
[dependencies]
some-crate = { version = "1.0", optional = true }
```
### Adding Error Types
```rust
// src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BotLibError {
#[error("Configuration error: {0}")]
Config(String),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Database error: {0}")]
Database(String),
}
pub type Result<T> = std::result::Result<T, BotLibError>;
```
---
## Re-exports Strategy
BotLib should re-export common dependencies to ensure version consistency:
```rust
// src/lib.rs
pub use anyhow;
pub use chrono;
pub use serde;
pub use serde_json;
pub use thiserror;
pub use uuid;
#[cfg(feature = "database")]
pub use diesel;
#[cfg(feature = "http-client")]
pub use reqwest;
```
Consumers then use:
```rust
use botlib::uuid::Uuid;
use botlib::chrono::Utc;
```
---
## Dependencies ## Dependencies
| Library | Version | Purpose | Optional | | Library | Version | Purpose |
|---------|---------|---------|----------| |---------|---------|---------|
| anyhow | 1.0 | Error handling | No | | anyhow | 1.0 | Error handling |
| thiserror | 2.0 | Error derive | No | | thiserror | 2.0 | Error derive |
| log | 0.4 | Logging facade | No | | chrono | 0.4 | Date/time |
| chrono | 0.4 | Date/time | No | | serde | 1.0 | Serialization |
| serde | 1.0 | Serialization | No | | uuid | 1.11 | UUIDs |
| serde_json | 1.0 | JSON | No | | diesel | 2.1 | Database ORM |
| uuid | 1.11 | UUIDs | No | | reqwest | 0.12 | HTTP client |
| toml | 0.8 | Config parsing | No |
| diesel | 2.1 | Database ORM | Yes |
| reqwest | 0.12 | HTTP client | Yes |
| validator | 0.18 | Validation | Yes |
--- ---
## Testing ## Remember
```bash - **ZERO WARNINGS** - Every clippy warning must be fixed
# Test all features - **NO ALLOW IN CODE** - Never use #[allow()] in source files
cargo test --all-features - **CARGO.TOML EXCEPTIONS OK** - Disable lints with false positives in Cargo.toml with comment
- **NO DEAD CODE** - Delete unused code, never prefix with _
# Test specific feature - **NO UNWRAP/EXPECT** - Use ? operator or proper error handling
cargo test --features database - **INLINE FORMAT ARGS** - format!("{name}") not format!("{}", name)
- **USE SELF** - In impl blocks, use Self not the type name
# Test without optional features - **DERIVE EQ** - Always derive Eq with PartialEq
cargo test - **DISPLAY NOT TOSTRING** - Implement Display, not ToString
``` - **USE DIAGNOSTICS** - Use IDE diagnostics tool, never call cargo clippy directly
---
## Final Checks Before Commit
```bash
# Verify version is 6.1.0
grep "^version" Cargo.toml | grep "6.1.0"
# Build with all features
cargo build --all-features
# Check for warnings
cargo check --all-features 2>&1 | grep warning
# Run tests
cargo test --all-features
```
---
## Rules
- Keep botlib minimal and focused
- No business logic - only utilities and types
- Feature gate all optional dependencies
- Maintain backward compatibility
- Document all public APIs
- Target zero warnings
- **Version**: Always 6.1.0 - do not change without approval - **Version**: Always 6.1.0 - do not change without approval
- **Session Continuation**: When running out of context, create detailed summary: (1) what was done, (2) what remains, (3) specific files and line numbers, (4) exact next steps.

View file

@ -17,10 +17,10 @@ const DEFAULT_PLATFORM_NAME: &str = "General Bots";
const DEFAULT_PLATFORM_SHORT: &str = "GB"; const DEFAULT_PLATFORM_SHORT: &str = "GB";
const DEFAULT_PLATFORM_DOMAIN: &str = "generalbots.com"; const DEFAULT_PLATFORM_DOMAIN: &str = "generalbots.com";
/// Branding configuration loaded from .product file /// Branding configuration loaded from `.product` file
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrandingConfig { pub struct BrandingConfig {
/// Platform name (e.g., "MyCustomPlatform") /// Platform name (e.g., "`MyCustomPlatform`")
pub name: String, pub name: String,
/// Short name for logs and compact displays (e.g., "MCP") /// Short name for logs and compact displays (e.g., "MCP")
pub short_name: String, pub short_name: String,
@ -82,6 +82,7 @@ impl Default for BrandingConfig {
impl BrandingConfig { impl BrandingConfig {
/// Load branding from .product file if it exists /// Load branding from .product file if it exists
#[must_use]
pub fn load() -> Self { pub fn load() -> Self {
let search_paths = [ let search_paths = [
".product", ".product",
@ -92,7 +93,7 @@ impl BrandingConfig {
for path in &search_paths { for path in &search_paths {
if let Ok(config) = Self::load_from_file(path) { if let Ok(config) = Self::load_from_file(path) {
info!("Loaded white-label branding from {}: {}", path, config.name); info!("Loaded white-label branding from {path}: {}", config.name);
return config; return config;
} }
} }
@ -101,8 +102,8 @@ impl BrandingConfig {
if let Ok(product_file) = std::env::var("PRODUCT_FILE") { if let Ok(product_file) = std::env::var("PRODUCT_FILE") {
if let Ok(config) = Self::load_from_file(&product_file) { if let Ok(config) = Self::load_from_file(&product_file) {
info!( info!(
"Loaded white-label branding from PRODUCT_FILE={}: {}", "Loaded white-label branding from PRODUCT_FILE={product_file}: {}",
product_file, config.name config.name
); );
return config; return config;
} }
@ -149,8 +150,10 @@ impl BrandingConfig {
} }
// Try parsing as simple key=value format // Try parsing as simple key=value format
let mut config = Self::default(); let mut config = Self {
config.is_white_label = true; is_white_label: true,
..Self::default()
};
for line in content.lines() { for line in content.lines() {
let line = line.trim(); let line = line.trim();
@ -261,26 +264,31 @@ pub fn init_branding() {
} }
/// Get the current branding configuration /// Get the current branding configuration
#[must_use]
pub fn branding() -> &'static BrandingConfig { pub fn branding() -> &'static BrandingConfig {
BRANDING.get_or_init(BrandingConfig::load) BRANDING.get_or_init(BrandingConfig::load)
} }
/// Get the platform name /// Get the platform name
#[must_use]
pub fn platform_name() -> &'static str { pub fn platform_name() -> &'static str {
&branding().name &branding().name
} }
/// Get the short platform name /// Get the short platform name
#[must_use]
pub fn platform_short() -> &'static str { pub fn platform_short() -> &'static str {
&branding().short_name &branding().short_name
} }
/// Check if this is a white-label deployment /// Check if this is a white-label deployment
#[must_use]
pub fn is_white_label() -> bool { pub fn is_white_label() -> bool {
branding().is_white_label branding().is_white_label
} }
/// Get formatted copyright text /// Get formatted copyright text
#[must_use]
pub fn copyright_text() -> String { pub fn copyright_text() -> String {
branding().copyright.clone().unwrap_or_else(|| { branding().copyright.clone().unwrap_or_else(|| {
format!( format!(
@ -292,6 +300,7 @@ pub fn copyright_text() -> String {
} }
/// Get footer text /// Get footer text
#[must_use]
pub fn footer_text() -> String { pub fn footer_text() -> String {
branding() branding()
.footer_text .footer_text
@ -300,6 +309,7 @@ pub fn footer_text() -> String {
} }
/// Format a log prefix with platform branding /// Format a log prefix with platform branding
#[must_use]
pub fn log_prefix() -> String { pub fn log_prefix() -> String {
format!("[{}]", platform_short()) format!("[{}]", platform_short())
} }

View file

@ -88,7 +88,8 @@ impl BotError {
Self::Conflict(msg.into()) Self::Conflict(msg.into())
} }
pub fn rate_limited(retry_after_secs: u64) -> Self { #[must_use]
pub const fn rate_limited(retry_after_secs: u64) -> Self {
Self::RateLimited { retry_after_secs } Self::RateLimited { retry_after_secs }
} }
@ -96,7 +97,8 @@ impl BotError {
Self::ServiceUnavailable(msg.into()) Self::ServiceUnavailable(msg.into())
} }
pub fn timeout(duration_ms: u64) -> Self { #[must_use]
pub const fn timeout(duration_ms: u64) -> Self {
Self::Timeout { duration_ms } Self::Timeout { duration_ms }
} }
@ -104,26 +106,27 @@ impl BotError {
Self::Internal(msg.into()) Self::Internal(msg.into())
} }
pub fn status_code(&self) -> u16 { #[must_use]
pub const fn status_code(&self) -> u16 {
match self { match self {
Self::Config(_) => 500,
Self::Database(_) => 500,
Self::Http { status, .. } => *status, Self::Http { status, .. } => *status,
Self::Auth(_) => 401, Self::Auth(_) => 401,
Self::Validation(_) => 400, Self::Validation(_) | Self::Json(_) => 400,
Self::NotFound { .. } => 404, Self::NotFound { .. } => 404,
Self::Conflict(_) => 409, Self::Conflict(_) => 409,
Self::RateLimited { .. } => 429, Self::RateLimited { .. } => 429,
Self::ServiceUnavailable(_) => 503, Self::ServiceUnavailable(_) => 503,
Self::Timeout { .. } => 504, Self::Timeout { .. } => 504,
Self::Internal(_) => 500, Self::Config(_)
Self::Io(_) => 500, | Self::Database(_)
Self::Json(_) => 400, | Self::Internal(_)
Self::Other(_) => 500, | Self::Io(_)
| Self::Other(_) => 500,
} }
} }
pub fn is_retryable(&self) -> bool { #[must_use]
pub const fn is_retryable(&self) -> bool {
match self { match self {
Self::RateLimited { .. } | Self::ServiceUnavailable(_) | Self::Timeout { .. } => true, Self::RateLimited { .. } | Self::ServiceUnavailable(_) | Self::Timeout { .. } => true,
Self::Http { status, .. } => *status >= 500, Self::Http { status, .. } => *status >= 500,
@ -131,12 +134,14 @@ impl BotError {
} }
} }
pub fn is_client_error(&self) -> bool { #[must_use]
pub const fn is_client_error(&self) -> bool {
let code = self.status_code(); let code = self.status_code();
(400..500).contains(&code) code >= 400 && code < 500
} }
pub fn is_server_error(&self) -> bool { #[must_use]
pub const fn is_server_error(&self) -> bool {
self.status_code() >= 500 self.status_code() >= 500
} }
} }
@ -162,7 +167,7 @@ impl From<&str> for BotError {
#[cfg(feature = "http-client")] #[cfg(feature = "http-client")]
impl From<reqwest::Error> for BotError { impl From<reqwest::Error> for BotError {
fn from(err: reqwest::Error) -> Self { fn from(err: reqwest::Error) -> Self {
let status = err.status().map(|s| s.as_u16()).unwrap_or(500); let status = err.status().map_or(500, |s| s.as_u16());
Self::Http { Self::Http {
status, status,
message: err.to_string(), message: err.to_string(),

View file

@ -1,3 +1,5 @@
//! HTTP client for botserver communication.
use crate::error::BotError; use crate::error::BotError;
use log::{debug, error}; use log::{debug, error};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
@ -7,6 +9,7 @@ use std::time::Duration;
const DEFAULT_BOTSERVER_URL: &str = "https://localhost:8088"; const DEFAULT_BOTSERVER_URL: &str = "https://localhost:8088";
const DEFAULT_TIMEOUT_SECS: u64 = 30; const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// HTTP client for communicating with the botserver.
#[derive(Clone)] #[derive(Clone)]
pub struct BotServerClient { pub struct BotServerClient {
client: Arc<reqwest::Client>, client: Arc<reqwest::Client>,
@ -14,10 +17,20 @@ pub struct BotServerClient {
} }
impl BotServerClient { impl BotServerClient {
/// Creates a new client with an optional base URL.
///
/// Uses `BOTSERVER_URL` environment variable if no URL is provided,
/// or falls back to the default localhost URL.
#[must_use]
pub fn new(base_url: Option<String>) -> Self { pub fn new(base_url: Option<String>) -> Self {
Self::with_timeout(base_url, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) Self::with_timeout(base_url, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
} }
/// Creates a new client with a custom timeout.
///
/// Uses `BOTSERVER_URL` environment variable if no URL is provided,
/// or falls back to the default localhost URL.
#[must_use]
pub fn with_timeout(base_url: Option<String>, timeout: Duration) -> Self { pub fn with_timeout(base_url: Option<String>, timeout: Duration) -> Self {
let url = base_url.unwrap_or_else(|| { let url = base_url.unwrap_or_else(|| {
std::env::var("BOTSERVER_URL").unwrap_or_else(|_| DEFAULT_BOTSERVER_URL.to_string()) std::env::var("BOTSERVER_URL").unwrap_or_else(|_| DEFAULT_BOTSERVER_URL.to_string())
@ -28,7 +41,7 @@ impl BotServerClient {
.user_agent(format!("BotLib/{}", env!("CARGO_PKG_VERSION"))) .user_agent(format!("BotLib/{}", env!("CARGO_PKG_VERSION")))
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.build() .build()
.expect("Failed to create HTTP client"); .unwrap_or_else(|_| reqwest::Client::new());
Self { Self {
client: Arc::new(client), client: Arc::new(client),
@ -36,82 +49,119 @@ impl BotServerClient {
} }
} }
/// Returns the base URL of the client.
#[must_use]
pub fn base_url(&self) -> &str { pub fn base_url(&self) -> &str {
&self.base_url &self.base_url
} }
/// Performs a GET request to the specified endpoint.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> { pub async fn get<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("GET {}", url); debug!("GET {url}");
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
self.handle_response(response).await self.handle_response(response).await
} }
pub async fn post<T: Serialize, R: DeserializeOwned>( /// Performs a POST request with a JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn post<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self, &self,
endpoint: &str, endpoint: &str,
body: &T, body: &T,
) -> Result<R, BotError> { ) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("POST {}", url); debug!("POST {url}");
let response = self.client.post(&url).json(body).send().await?; let response = self.client.post(&url).json(body).send().await?;
self.handle_response(response).await self.handle_response(response).await
} }
pub async fn put<T: Serialize, R: DeserializeOwned>( /// Performs a PUT request with a JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn put<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self, &self,
endpoint: &str, endpoint: &str,
body: &T, body: &T,
) -> Result<R, BotError> { ) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("PUT {}", url); debug!("PUT {url}");
let response = self.client.put(&url).json(body).send().await?; let response = self.client.put(&url).json(body).send().await?;
self.handle_response(response).await self.handle_response(response).await
} }
pub async fn patch<T: Serialize, R: DeserializeOwned>( /// Performs a PATCH request with a JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn patch<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self, &self,
endpoint: &str, endpoint: &str,
body: &T, body: &T,
) -> Result<R, BotError> { ) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("PATCH {}", url); debug!("PATCH {url}");
let response = self.client.patch(&url).json(body).send().await?; let response = self.client.patch(&url).json(body).send().await?;
self.handle_response(response).await self.handle_response(response).await
} }
/// Performs a DELETE request to the specified endpoint.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn delete<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> { pub async fn delete<T: DeserializeOwned>(&self, endpoint: &str) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("DELETE {}", url); debug!("DELETE {url}");
let response = self.client.delete(&url).send().await?; let response = self.client.delete(&url).send().await?;
self.handle_response(response).await self.handle_response(response).await
} }
/// Performs an authorized GET request with a bearer token.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn get_authorized<T: DeserializeOwned>( pub async fn get_authorized<T: DeserializeOwned>(
&self, &self,
endpoint: &str, endpoint: &str,
token: &str, token: &str,
) -> Result<T, BotError> { ) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("GET {} (authorized)", url); debug!("GET {url} (authorized)");
let response = self.client.get(&url).bearer_auth(token).send().await?; let response = self.client.get(&url).bearer_auth(token).send().await?;
self.handle_response(response).await self.handle_response(response).await
} }
pub async fn post_authorized<T: Serialize, R: DeserializeOwned>( /// Performs an authorized POST request with a bearer token and JSON body.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn post_authorized<T: Serialize + Send + Sync, R: DeserializeOwned>(
&self, &self,
endpoint: &str, endpoint: &str,
body: &T, body: &T,
token: &str, token: &str,
) -> Result<R, BotError> { ) -> Result<R, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("POST {} (authorized)", url); debug!("POST {url} (authorized)");
let response = self let response = self
.client .client
@ -123,23 +173,31 @@ impl BotServerClient {
self.handle_response(response).await self.handle_response(response).await
} }
/// Performs an authorized DELETE request with a bearer token.
///
/// # Errors
///
/// Returns an error if the request fails or the response cannot be parsed.
pub async fn delete_authorized<T: DeserializeOwned>( pub async fn delete_authorized<T: DeserializeOwned>(
&self, &self,
endpoint: &str, endpoint: &str,
token: &str, token: &str,
) -> Result<T, BotError> { ) -> Result<T, BotError> {
let url = format!("{}{}", self.base_url, endpoint); let url = format!("{}{endpoint}", self.base_url);
debug!("DELETE {} (authorized)", url); debug!("DELETE {url} (authorized)");
let response = self.client.delete(&url).bearer_auth(token).send().await?; let response = self.client.delete(&url).bearer_auth(token).send().await?;
self.handle_response(response).await self.handle_response(response).await
} }
/// Performs a health check against the server.
///
/// Returns `true` if the server is healthy, `false` otherwise.
pub async fn health_check(&self) -> bool { pub async fn health_check(&self) -> bool {
match self.get::<serde_json::Value>("/health").await { match self.get::<serde_json::Value>("/health").await {
Ok(_) => true, Ok(_) => true,
Err(e) => { Err(e) => {
error!("Health check failed: {}", e); error!("Health check failed: {e}");
false false
} }
} }
@ -157,13 +215,13 @@ impl BotServerClient {
.text() .text()
.await .await
.unwrap_or_else(|_| "Unknown error".to_string()); .unwrap_or_else(|_| "Unknown error".to_string());
error!("HTTP {} error: {}", status_code, error_text); error!("HTTP {status_code} error: {error_text}");
return Err(BotError::http(status_code, error_text)); return Err(BotError::http(status_code, error_text));
} }
response.json().await.map_err(|e| { response.json().await.map_err(|e| {
error!("Failed to parse response: {}", e); error!("Failed to parse response: {e}");
BotError::http(status_code, format!("Failed to parse response: {}", e)) BotError::http(status_code, format!("Failed to parse response: {e}"))
}) })
} }
} }
@ -172,7 +230,7 @@ impl std::fmt::Debug for BotServerClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BotServerClient") f.debug_struct("BotServerClient")
.field("base_url", &self.base_url) .field("base_url", &self.base_url)
.finish() .finish_non_exhaustive()
} }
} }
@ -212,7 +270,7 @@ mod tests {
#[test] #[test]
fn test_client_debug() { fn test_client_debug() {
let client = BotServerClient::new(Some("http://debug-test".to_string())); let client = BotServerClient::new(Some("http://debug-test".to_string()));
let debug_str = format!("{:?}", client); let debug_str = format!("{client:?}");
assert!(debug_str.contains("BotServerClient")); assert!(debug_str.contains("BotServerClient"));
assert!(debug_str.contains("http://debug-test")); assert!(debug_str.contains("http://debug-test"));
} }

View file

@ -1,4 +1,4 @@
//! BotLib - Shared library for General Bots //! `BotLib` - Shared library for General Bots
//! //!
//! This crate provides common types, utilities, and abstractions //! This crate provides common types, utilities, and abstractions
//! shared between botserver and botui. //! shared between botserver and botui.
@ -17,7 +17,9 @@ pub mod models;
pub mod version; pub mod version;
// Re-exports for convenience // Re-exports for convenience
pub use branding::{branding, init_branding, is_white_label, platform_name, platform_short, BrandingConfig}; pub use branding::{
branding, init_branding, is_white_label, platform_name, platform_short, BrandingConfig,
};
pub use error::{BotError, BotResult}; pub use error::{BotError, BotResult};
pub use message_types::MessageType; pub use message_types::MessageType;
pub use models::{ApiResponse, BotResponse, Session, Suggestion, UserMessage}; pub use models::{ApiResponse, BotResponse, Session, Suggestion, UserMessage};

View file

@ -10,28 +10,28 @@ use serde::{Deserialize, Serialize};
pub struct MessageType(pub i32); pub struct MessageType(pub i32);
impl MessageType { impl MessageType {
/// Regular message from external systems (WhatsApp, Instagram, etc.) /// Regular message from external systems (`WhatsApp`, Instagram, etc.)
pub const EXTERNAL: MessageType = MessageType(0); pub const EXTERNAL: Self = Self(0);
/// User message from web interface /// User message from web interface
pub const USER: MessageType = MessageType(1); pub const USER: Self = Self(1);
/// Bot response (can be regular content or event) /// Bot response (can be regular content or event)
pub const BOT_RESPONSE: MessageType = MessageType(2); pub const BOT_RESPONSE: Self = Self(2);
/// Continue interrupted response /// Continue interrupted response
pub const CONTINUE: MessageType = MessageType(3); pub const CONTINUE: Self = Self(3);
/// Suggestion or command message /// Suggestion or command message
pub const SUGGESTION: MessageType = MessageType(4); pub const SUGGESTION: Self = Self(4);
/// Context change notification /// Context change notification
pub const CONTEXT_CHANGE: MessageType = MessageType(5); pub const CONTEXT_CHANGE: Self = Self(5);
} }
impl From<i32> for MessageType { impl From<i32> for MessageType {
fn from(value: i32) -> Self { fn from(value: i32) -> Self {
MessageType(value) Self(value)
} }
} }
@ -43,7 +43,7 @@ impl From<MessageType> for i32 {
impl Default for MessageType { impl Default for MessageType {
fn default() -> Self { fn default() -> Self {
MessageType::USER Self::USER
} }
} }
@ -58,7 +58,7 @@ impl std::fmt::Display for MessageType {
5 => "CONTEXT_CHANGE", 5 => "CONTEXT_CHANGE",
_ => "UNKNOWN", _ => "UNKNOWN",
}; };
write!(f, "{}", name) write!(f, "{name}")
} }
} }

View file

@ -1,3 +1,5 @@
//! API models for bot communication.
use crate::message_types::MessageType; use crate::message_types::MessageType;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -17,7 +19,8 @@ pub struct ApiResponse<T> {
} }
impl<T> ApiResponse<T> { impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self { #[must_use]
pub const fn success(data: T) -> Self {
Self { Self {
success: true, success: true,
data: Some(data), data: Some(data),
@ -27,6 +30,7 @@ impl<T> ApiResponse<T> {
} }
} }
#[must_use]
pub fn success_with_message(data: T, message: impl Into<String>) -> Self { pub fn success_with_message(data: T, message: impl Into<String>) -> Self {
Self { Self {
success: true, success: true,
@ -37,6 +41,7 @@ impl<T> ApiResponse<T> {
} }
} }
#[must_use]
pub fn error(message: impl Into<String>) -> Self { pub fn error(message: impl Into<String>) -> Self {
Self { Self {
success: false, success: false,
@ -47,6 +52,7 @@ impl<T> ApiResponse<T> {
} }
} }
#[must_use]
pub fn error_with_code(message: impl Into<String>, code: impl Into<String>) -> Self { pub fn error_with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
Self { Self {
success: false, success: false,
@ -57,6 +63,7 @@ impl<T> ApiResponse<T> {
} }
} }
#[must_use]
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> ApiResponse<U> { pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> ApiResponse<U> {
ApiResponse { ApiResponse {
success: self.success, success: self.success,
@ -67,11 +74,13 @@ impl<T> ApiResponse<T> {
} }
} }
pub fn is_success(&self) -> bool { #[must_use]
pub const fn is_success(&self) -> bool {
self.success self.success
} }
pub fn is_error(&self) -> bool { #[must_use]
pub const fn is_error(&self) -> bool {
!self.success !self.success
} }
} }
@ -95,6 +104,7 @@ pub struct Session {
} }
impl Session { impl Session {
#[must_use]
pub fn new(user_id: Uuid, bot_id: Uuid, title: impl Into<String>) -> Self { pub fn new(user_id: Uuid, bot_id: Uuid, title: impl Into<String>) -> Self {
let now = Utc::now(); let now = Utc::now();
Self { Self {
@ -108,19 +118,23 @@ impl Session {
} }
} }
#[must_use]
pub fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self { pub fn with_expiry(mut self, expires_at: DateTime<Utc>) -> Self {
self.expires_at = Some(expires_at); self.expires_at = Some(expires_at);
self self
} }
#[must_use]
pub fn is_expired(&self) -> bool { pub fn is_expired(&self) -> bool {
self.expires_at.map(|exp| Utc::now() > exp).unwrap_or(false) self.expires_at.is_some_and(|exp| Utc::now() > exp)
} }
#[must_use]
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
!self.is_expired() !self.is_expired()
} }
#[must_use]
pub fn remaining_time(&self) -> Option<chrono::Duration> { pub fn remaining_time(&self) -> Option<chrono::Duration> {
self.expires_at.map(|exp| exp - Utc::now()) self.expires_at.map(|exp| exp - Utc::now())
} }
@ -142,6 +156,7 @@ pub struct UserMessage {
} }
impl UserMessage { impl UserMessage {
#[must_use]
pub fn text( pub fn text(
bot_id: impl Into<String>, bot_id: impl Into<String>,
user_id: impl Into<String>, user_id: impl Into<String>,
@ -162,17 +177,20 @@ impl UserMessage {
} }
} }
#[must_use]
pub fn with_media(mut self, url: impl Into<String>) -> Self { pub fn with_media(mut self, url: impl Into<String>) -> Self {
self.media_url = Some(url.into()); self.media_url = Some(url.into());
self self
} }
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self { pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context_name = Some(context.into()); self.context_name = Some(context.into());
self self
} }
pub fn has_media(&self) -> bool { #[must_use]
pub const fn has_media(&self) -> bool {
self.media_url.is_some() self.media_url.is_some()
} }
} }
@ -189,6 +207,7 @@ pub struct Suggestion {
} }
impl Suggestion { impl Suggestion {
#[must_use]
pub fn new(text: impl Into<String>) -> Self { pub fn new(text: impl Into<String>) -> Self {
Self { Self {
text: text.into(), text: text.into(),
@ -198,16 +217,19 @@ impl Suggestion {
} }
} }
#[must_use]
pub fn with_context(mut self, context: impl Into<String>) -> Self { pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into()); self.context = Some(context.into());
self self
} }
#[must_use]
pub fn with_action(mut self, action: impl Into<String>) -> Self { pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = Some(action.into()); self.action = Some(action.into());
self self
} }
#[must_use]
pub fn with_icon(mut self, icon: impl Into<String>) -> Self { pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into()); self.icon = Some(icon.into());
self self
@ -242,6 +264,7 @@ pub struct BotResponse {
} }
impl BotResponse { impl BotResponse {
#[must_use]
pub fn new( pub fn new(
bot_id: impl Into<String>, bot_id: impl Into<String>,
session_id: impl Into<String>, session_id: impl Into<String>,
@ -265,6 +288,7 @@ impl BotResponse {
} }
} }
#[must_use]
pub fn streaming( pub fn streaming(
bot_id: impl Into<String>, bot_id: impl Into<String>,
session_id: impl Into<String>, session_id: impl Into<String>,
@ -288,6 +312,7 @@ impl BotResponse {
} }
} }
#[must_use]
pub fn with_suggestions<I, S>(mut self, suggestions: I) -> Self pub fn with_suggestions<I, S>(mut self, suggestions: I) -> Self
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
@ -297,11 +322,13 @@ impl BotResponse {
self self
} }
#[must_use]
pub fn add_suggestion(mut self, suggestion: impl Into<Suggestion>) -> Self { pub fn add_suggestion(mut self, suggestion: impl Into<Suggestion>) -> Self {
self.suggestions.push(suggestion.into()); self.suggestions.push(suggestion.into());
self self
} }
#[must_use]
pub fn with_context( pub fn with_context(
mut self, mut self,
name: impl Into<String>, name: impl Into<String>,
@ -318,15 +345,18 @@ impl BotResponse {
self.content.push_str(chunk); self.content.push_str(chunk);
} }
#[must_use]
pub fn complete(mut self) -> Self { pub fn complete(mut self) -> Self {
self.is_complete = true; self.is_complete = true;
self self
} }
pub fn is_streaming(&self) -> bool { #[must_use]
pub const fn is_streaming(&self) -> bool {
self.stream_token.is_some() && !self.is_complete self.stream_token.is_some() && !self.is_complete
} }
#[must_use]
pub fn has_suggestions(&self) -> bool { pub fn has_suggestions(&self) -> bool {
!self.suggestions.is_empty() !self.suggestions.is_empty()
} }
@ -376,6 +406,7 @@ pub enum AttachmentType {
} }
impl Attachment { impl Attachment {
#[must_use]
pub fn new(attachment_type: AttachmentType, url: impl Into<String>) -> Self { pub fn new(attachment_type: AttachmentType, url: impl Into<String>) -> Self {
Self { Self {
attachment_type, attachment_type,
@ -387,51 +418,62 @@ impl Attachment {
} }
} }
#[must_use]
pub fn image(url: impl Into<String>) -> Self { pub fn image(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Image, url) Self::new(AttachmentType::Image, url)
} }
#[must_use]
pub fn audio(url: impl Into<String>) -> Self { pub fn audio(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Audio, url) Self::new(AttachmentType::Audio, url)
} }
#[must_use]
pub fn video(url: impl Into<String>) -> Self { pub fn video(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Video, url) Self::new(AttachmentType::Video, url)
} }
#[must_use]
pub fn document(url: impl Into<String>) -> Self { pub fn document(url: impl Into<String>) -> Self {
Self::new(AttachmentType::Document, url) Self::new(AttachmentType::Document, url)
} }
#[must_use]
pub fn file(url: impl Into<String>) -> Self { pub fn file(url: impl Into<String>) -> Self {
Self::new(AttachmentType::File, url) Self::new(AttachmentType::File, url)
} }
#[must_use]
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self { pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into()); self.mime_type = Some(mime_type.into());
self self
} }
#[must_use]
pub fn with_filename(mut self, filename: impl Into<String>) -> Self { pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into()); self.filename = Some(filename.into());
self self
} }
pub fn with_size(mut self, size: u64) -> Self { #[must_use]
pub const fn with_size(mut self, size: u64) -> Self {
self.size = Some(size); self.size = Some(size);
self self
} }
#[must_use]
pub fn with_thumbnail(mut self, thumbnail_url: impl Into<String>) -> Self { pub fn with_thumbnail(mut self, thumbnail_url: impl Into<String>) -> Self {
self.thumbnail_url = Some(thumbnail_url.into()); self.thumbnail_url = Some(thumbnail_url.into());
self self
} }
pub fn is_image(&self) -> bool { #[must_use]
self.attachment_type == AttachmentType::Image pub const fn is_image(&self) -> bool {
matches!(self.attachment_type, AttachmentType::Image)
} }
pub fn is_media(&self) -> bool { #[must_use]
pub const fn is_media(&self) -> bool {
matches!( matches!(
self.attachment_type, self.attachment_type,
AttachmentType::Image | AttachmentType::Audio | AttachmentType::Video AttachmentType::Image | AttachmentType::Audio | AttachmentType::Video

View file

@ -50,12 +50,12 @@ pub enum ComponentStatus {
impl std::fmt::Display for ComponentStatus { impl std::fmt::Display for ComponentStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
ComponentStatus::Running => write!(f, "[OK] Running"), Self::Running => write!(f, "[OK] Running"),
ComponentStatus::Stopped => write!(f, "[STOP] Stopped"), Self::Stopped => write!(f, "[STOP] Stopped"),
ComponentStatus::Error => write!(f, "[ERR] Error"), Self::Error => write!(f, "[ERR] Error"),
ComponentStatus::Updating => write!(f, "[UPD] Updating"), Self::Updating => write!(f, "[UPD] Updating"),
ComponentStatus::NotInstalled => write!(f, "[--] Not Installed"), Self::NotInstalled => write!(f, "[--] Not Installed"),
ComponentStatus::Unknown => write!(f, "[?] Unknown"), Self::Unknown => write!(f, "[?] Unknown"),
} }
} }
} }
@ -74,12 +74,12 @@ pub enum ComponentSource {
impl std::fmt::Display for ComponentSource { impl std::fmt::Display for ComponentSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
ComponentSource::Builtin => write!(f, "Built-in"), Self::Builtin => write!(f, "Built-in"),
ComponentSource::Docker => write!(f, "Docker"), Self::Docker => write!(f, "Docker"),
ComponentSource::Lxc => write!(f, "LXC"), Self::Lxc => write!(f, "LXC"),
ComponentSource::System => write!(f, "System"), Self::System => write!(f, "System"),
ComponentSource::Binary => write!(f, "Binary"), Self::Binary => write!(f, "Binary"),
ComponentSource::External => write!(f, "External"), Self::External => write!(f, "External"),
} }
} }
} }
@ -106,6 +106,7 @@ impl Default for VersionRegistry {
impl VersionRegistry { impl VersionRegistry {
/// Create a new version registry /// Create a new version registry
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
let mut registry = Self::default(); let mut registry = Self::default();
registry.register_builtin_components(); registry.register_builtin_components();
@ -185,16 +186,19 @@ impl VersionRegistry {
} }
/// Get component by name /// Get component by name
#[must_use]
pub fn get_component(&self, name: &str) -> Option<&ComponentVersion> { pub fn get_component(&self, name: &str) -> Option<&ComponentVersion> {
self.components.get(name) self.components.get(name)
} }
/// Get all components /// Get all components
pub fn get_all_components(&self) -> &HashMap<String, ComponentVersion> { #[must_use]
pub const fn get_all_components(&self) -> &HashMap<String, ComponentVersion> {
&self.components &self.components
} }
/// Get components with available updates /// Get components with available updates
#[must_use]
pub fn get_available_updates(&self) -> Vec<&ComponentVersion> { pub fn get_available_updates(&self) -> Vec<&ComponentVersion> {
self.components self.components
.values() .values()
@ -203,6 +207,7 @@ impl VersionRegistry {
} }
/// Get summary of all components /// Get summary of all components
#[must_use]
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
let running = self let running = self
.components .components
@ -213,12 +218,16 @@ impl VersionRegistry {
let updates = self.get_available_updates().len(); let updates = self.get_available_updates().len();
format!( format!(
"{} v{} | {}/{} components running | {} updates available", "{BOTSERVER_NAME} v{} | {running}/{total} components running | {updates} updates available",
BOTSERVER_NAME, self.core_version, running, total, updates self.core_version
) )
} }
/// Get summary as JSON /// Get summary as JSON
///
/// # Errors
///
/// Returns an error if JSON serialization fails.
pub fn to_json(&self) -> Result<String, serde_json::Error> { pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self) serde_json::to_string_pretty(self)
} }
@ -235,6 +244,7 @@ pub fn init_version_registry() {
} }
/// Get version registry (read-only) /// Get version registry (read-only)
#[must_use]
pub fn version_registry() -> Option<VersionRegistry> { pub fn version_registry() -> Option<VersionRegistry> {
VERSION_REGISTRY.read().ok()?.clone() VERSION_REGISTRY.read().ok()?.clone()
} }
@ -264,6 +274,7 @@ pub fn update_component_status(name: &str, status: ComponentStatus) {
} }
/// Get component version /// Get component version
#[must_use]
pub fn get_component_version(name: &str) -> Option<ComponentVersion> { pub fn get_component_version(name: &str) -> Option<ComponentVersion> {
VERSION_REGISTRY VERSION_REGISTRY
.read() .read()
@ -274,13 +285,15 @@ pub fn get_component_version(name: &str) -> Option<ComponentVersion> {
} }
/// Get botserver version /// Get botserver version
pub fn get_botserver_version() -> &'static str { #[must_use]
pub const fn get_botserver_version() -> &'static str {
BOTSERVER_VERSION BOTSERVER_VERSION
} }
/// Get version string for display /// Get version string for display
#[must_use]
pub fn version_string() -> String { pub fn version_string() -> String {
format!("{} v{}", BOTSERVER_NAME, BOTSERVER_VERSION) format!("{BOTSERVER_NAME} v{BOTSERVER_VERSION}")
} }
#[cfg(test)] #[cfg(test)]
@ -333,8 +346,12 @@ mod tests {
fn test_update_status() { fn test_update_status() {
let mut registry = VersionRegistry::new(); let mut registry = VersionRegistry::new();
registry.update_status("botserver", ComponentStatus::Stopped); registry.update_status("botserver", ComponentStatus::Stopped);
let component = registry.get_component("botserver").unwrap(); let component = registry.get_component("botserver");
assert_eq!(component.status, ComponentStatus::Stopped); assert!(
component.is_some(),
"botserver component should exist in registry"
);
assert_eq!(component.map(|c| c.status), Some(ComponentStatus::Stopped));
} }
#[test] #[test]