Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e926818f35 | |||
| f5496b0177 | |||
| a1ddd1cdd0 | |||
| 47a764372d | |||
| 4ce639331b | |||
| de729e9e53 | |||
| 48dd1155ba | |||
| 82f816ddcd | |||
| 5759fdcd5b | |||
| 84e8f1fe3a | |||
| 5d73abe9f8 | |||
| e0f00060cb | |||
| 30786e2cf8 | |||
|
|
38dc7c050d | ||
| 2765fa2eba | |||
| a8ed131b3b | |||
| da758206b4 | |||
| 57fcb6fef2 | |||
| 5ed6ee7988 |
10 changed files with 409 additions and 188 deletions
11
Cargo.toml
11
Cargo.toml
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "botlib"
|
name = "botlib"
|
||||||
version = "6.1.0"
|
version = "6.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Shared library for General Bots - common types, utilities, and HTTP client"
|
description = "Shared library for General Bots - common types, utilities, and HTTP client"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|
@ -10,13 +10,13 @@ keywords = ["bot", "chatbot", "ai", "conversational", "library"]
|
||||||
categories = ["api-bindings", "web-programming"]
|
categories = ["api-bindings", "web-programming"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = ["database", "i18n"]
|
||||||
full = ["database", "http-client", "validation", "resilience", "i18n"]
|
full = ["database", "http-client", "validation", "resilience", "i18n"]
|
||||||
database = []
|
database = ["i18n"]
|
||||||
http-client = ["dep:reqwest"]
|
http-client = ["dep:reqwest"]
|
||||||
validation = []
|
validation = []
|
||||||
resilience = []
|
resilience = []
|
||||||
i18n = []
|
i18n = ["dep:rust-embed"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Core
|
# Core
|
||||||
|
|
@ -34,6 +34,9 @@ tokio = { workspace = true, features = ["sync", "time"] }
|
||||||
# Optional: HTTP Client
|
# Optional: HTTP Client
|
||||||
reqwest = { workspace = true, features = ["json", "rustls-tls"], optional = true }
|
reqwest = { workspace = true, features = ["json", "rustls-tls"], optional = true }
|
||||||
|
|
||||||
|
# Optional: i18n
|
||||||
|
rust-embed = { workspace = true, optional = true, features = ["debug-embed"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
tokio = { workspace = true, features = ["rt", "macros"] }
|
||||||
|
|
||||||
|
|
|
||||||
114
PROMPT.md
114
PROMPT.md
|
|
@ -1,114 +0,0 @@
|
||||||
# BotLib Development Guide
|
|
||||||
|
|
||||||
**Version:** 6.2.0
|
|
||||||
**Purpose:** Shared library for General Bots workspace
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ZERO TOLERANCE POLICY
|
|
||||||
|
|
||||||
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❌ ABSOLUTE PROHIBITIONS
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ NEVER use #![allow()] or #[allow()] in source code
|
|
||||||
❌ NEVER use _ prefix for unused variables - DELETE or USE them
|
|
||||||
❌ NEVER use .unwrap() - use ? or proper error handling
|
|
||||||
❌ NEVER use .expect() - use ? or proper error handling
|
|
||||||
❌ NEVER use panic!() or unreachable!()
|
|
||||||
❌ NEVER use todo!() or unimplemented!()
|
|
||||||
❌ NEVER leave unused imports or dead code
|
|
||||||
❌ NEVER add comments - code must be self-documenting
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ MODULE STRUCTURE
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── lib.rs # Public exports, feature gates
|
|
||||||
├── error.rs # Error types (thiserror)
|
|
||||||
├── models.rs # Shared data models
|
|
||||||
├── message_types.rs # Message type definitions
|
|
||||||
├── http_client.rs # HTTP client wrapper (feature-gated)
|
|
||||||
├── branding.rs # Version, branding constants
|
|
||||||
└── version.rs # Version information
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ MANDATORY CODE PATTERNS
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// ❌ WRONG
|
|
||||||
let value = something.unwrap();
|
|
||||||
|
|
||||||
// ✅ CORRECT
|
|
||||||
let value = something?;
|
|
||||||
let value = something.ok_or_else(|| Error::NotFound)?;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Self Usage
|
|
||||||
|
|
||||||
```rust
|
|
||||||
impl MyStruct {
|
|
||||||
fn new() -> Self { Self { } } // ✅ Not MyStruct
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Format Strings
|
|
||||||
|
|
||||||
```rust
|
|
||||||
format!("Hello {name}") // ✅ Not format!("{}", name)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Display vs ToString
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// ❌ WRONG
|
|
||||||
impl ToString for MyType { }
|
|
||||||
|
|
||||||
// ✅ CORRECT
|
|
||||||
impl std::fmt::Display for MyType { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Derive Eq with PartialEq
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(PartialEq, Eq)] // ✅ Always both
|
|
||||||
struct MyStruct { }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 KEY DEPENDENCIES
|
|
||||||
|
|
||||||
| Library | Version | Purpose |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| anyhow | 1.0 | Error handling |
|
|
||||||
| thiserror | 2.0 | Error derive |
|
|
||||||
| chrono | 0.4 | Date/time |
|
|
||||||
| serde | 1.0 | Serialization |
|
|
||||||
| uuid | 1.11 | UUIDs |
|
|
||||||
| diesel | 2.1 | Database ORM |
|
|
||||||
| reqwest | 0.12 | HTTP client |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔑 REMEMBER
|
|
||||||
|
|
||||||
- **ZERO WARNINGS** - Every clippy warning must be fixed
|
|
||||||
- **NO ALLOW IN CODE** - Never use #[allow()] in source files
|
|
||||||
- **NO DEAD CODE** - Delete unused code
|
|
||||||
- **NO UNWRAP/EXPECT** - Use ? operator
|
|
||||||
- **INLINE FORMAT ARGS** - `format!("{name}")` not `format!("{}", name)`
|
|
||||||
- **USE SELF** - In impl blocks, use Self not the type name
|
|
||||||
- **DERIVE EQ** - Always derive Eq with PartialEq
|
|
||||||
- **DISPLAY NOT TOSTRING** - Implement Display, not ToString
|
|
||||||
- **Version 6.2.0** - do not change without approval
|
|
||||||
171
README.md
171
README.md
|
|
@ -1,3 +1,170 @@
|
||||||
General Bots® base library for building Node.js TypeScript Apps packages (.gbapp).
|
# BotLib - General Bots Shared Library
|
||||||
|
|
||||||
See: https://github.com/pragmatismo-io/botserver for main documentation.
|
**Version:** 6.2.0
|
||||||
|
**Purpose:** Shared library for General Bots workspace
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
BotLib is the foundational shared library for the General Bots workspace, providing common types, error handling, HTTP client functionality, and utilities used across all projects. It serves as the core dependency for botserver, botui, botapp, and other workspace members, ensuring consistency and reducing code duplication.
|
||||||
|
|
||||||
|
For comprehensive documentation, see **[docs.pragmatismo.com.br](https://docs.pragmatismo.com.br)** or the **[BotBook](../botbook)** for detailed guides and API references.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── lib.rs # Public exports, feature gates
|
||||||
|
├── error.rs # Error types (thiserror)
|
||||||
|
├── models.rs # Shared data models
|
||||||
|
├── message_types.rs # Message type definitions
|
||||||
|
├── http_client.rs # HTTP client wrapper (feature-gated)
|
||||||
|
├── branding.rs # Version, branding constants
|
||||||
|
└── version.rs # Version information
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ ZERO TOLERANCE POLICY
|
||||||
|
|
||||||
|
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
|
||||||
|
|
||||||
|
### Absolute Prohibitions
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ NEVER use #![allow()] or #[allow()] in source code
|
||||||
|
❌ NEVER use _ prefix for unused variables - DELETE or USE them
|
||||||
|
❌ NEVER use .unwrap() - use ? or proper error handling
|
||||||
|
❌ NEVER use .expect() - use ? or proper error handling
|
||||||
|
❌ NEVER use panic!() or unreachable!()
|
||||||
|
❌ NEVER use todo!() or unimplemented!()
|
||||||
|
❌ NEVER leave unused imports or dead code
|
||||||
|
❌ NEVER add comments - code must be self-documenting
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Key Dependencies
|
||||||
|
|
||||||
|
| Library | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| anyhow | 1.0 | Error handling |
|
||||||
|
| thiserror | 2.0 | Error derive |
|
||||||
|
| chrono | 0.4 | Date/time |
|
||||||
|
| serde | 1.0 | Serialization |
|
||||||
|
| uuid | 1.11 | UUIDs |
|
||||||
|
| diesel | 2.1 | Database ORM |
|
||||||
|
| reqwest | 0.12 | HTTP client |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Features
|
||||||
|
|
||||||
|
### Feature Gates
|
||||||
|
|
||||||
|
BotLib uses Cargo features to enable optional functionality:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
http-client = ["reqwest"] # Enable HTTP client
|
||||||
|
# Add more features as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Features
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# In dependent crate's Cargo.toml
|
||||||
|
[dependencies.botlib]
|
||||||
|
workspace = true
|
||||||
|
features = ["http-client"] # Enable HTTP client
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Mandatory Code Patterns
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ WRONG
|
||||||
|
let value = something.unwrap();
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
let value = something?;
|
||||||
|
let value = something.ok_or_else(|| Error::NotFound)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Self Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl MyStruct {
|
||||||
|
fn new() -> Self { Self { } } // ✅ Not MyStruct
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Format Strings
|
||||||
|
|
||||||
|
```rust
|
||||||
|
format!("Hello {name}") // ✅ Not format!("{}", name)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Display vs ToString
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ WRONG
|
||||||
|
impl ToString for MyType { }
|
||||||
|
|
||||||
|
// ✅ CORRECT
|
||||||
|
impl std::fmt::Display for MyType { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Derive Eq with PartialEq
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(PartialEq, Eq)] // ✅ Always both
|
||||||
|
struct MyStruct { }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
For complete documentation, guides, and API references:
|
||||||
|
|
||||||
|
- **[docs.pragmatismo.com.br](https://docs.pragmatismo.com.br)** - Full online documentation
|
||||||
|
- **[BotBook](../botbook)** - Local comprehensive guide with tutorials and examples
|
||||||
|
- **[General Bots Repository](https://github.com/GeneralBots/BotServer)** - Main project repository
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Projects
|
||||||
|
|
||||||
|
- **[botserver](https://github.com/GeneralBots/botserver)** - Main API server
|
||||||
|
- **[botui](https://github.com/GeneralBots/botui)** - Web UI interface
|
||||||
|
- **[botapp](https://github.com/GeneralBots/botapp)** - Desktop application
|
||||||
|
- **[botbook](https://github.com/GeneralBots/botbook)** - Documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Remember
|
||||||
|
|
||||||
|
- **ZERO WARNINGS** - Every clippy warning must be fixed
|
||||||
|
- **NO ALLOW IN CODE** - Never use #[allow()] in source files
|
||||||
|
- **NO DEAD CODE** - Delete unused code
|
||||||
|
- **NO UNWRAP/EXPECT** - Use ? operator
|
||||||
|
- **INLINE FORMAT ARGS** - `format!("{name}")` not `format!("{}", name)`
|
||||||
|
- **USE SELF** - In impl blocks, use Self not the type name
|
||||||
|
- **DERIVE EQ** - Always derive Eq with PartialEq
|
||||||
|
- **DISPLAY NOT TOSTRING** - Implement Display, not ToString
|
||||||
|
- **Version 6.2.0** - Do not change without approval
|
||||||
|
- **GIT WORKFLOW** - ALWAYS push to ALL repositories (github, pragmatismo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
AGPL-3.0 - See [LICENSE](LICENSE) for details.
|
||||||
|
|
@ -1636,3 +1636,16 @@ goals-dashboard = Dashboard
|
||||||
goals-objectives = Objectives
|
goals-objectives = Objectives
|
||||||
goals-alignment = Alignment
|
goals-alignment = Alignment
|
||||||
goals-ai-suggestions = AI Suggestions
|
goals-ai-suggestions = AI Suggestions
|
||||||
|
|
||||||
|
# CRM / Mail / Campaigns integration keys
|
||||||
|
crm-email = Email
|
||||||
|
crm-compose-email = Compose Email
|
||||||
|
crm-send-email = Send Email
|
||||||
|
mail-snooze = Snooze
|
||||||
|
mail-snooze-later-today = Later today (6:00 PM)
|
||||||
|
mail-snooze-tomorrow = Tomorrow (8:00 AM)
|
||||||
|
mail-snooze-next-week = Next week (Mon 8:00 AM)
|
||||||
|
mail-crm-log = Log to CRM
|
||||||
|
mail-crm-create-lead = Create Lead
|
||||||
|
mail-add-to-list = Add to List
|
||||||
|
campaign-send-email = Send Email
|
||||||
|
|
|
||||||
|
|
@ -941,3 +941,15 @@ goals-dashboard = Panel
|
||||||
goals-objectives = Objetivos
|
goals-objectives = Objetivos
|
||||||
goals-alignment = Alineación
|
goals-alignment = Alineación
|
||||||
goals-ai-suggestions = Sugerencias de IA
|
goals-ai-suggestions = Sugerencias de IA
|
||||||
|
|
||||||
|
crm-email = Correo
|
||||||
|
crm-compose-email = Redactar Correo
|
||||||
|
crm-send-email = Enviar Correo
|
||||||
|
mail-snooze = Posponer
|
||||||
|
mail-snooze-later-today = Más tarde hoy (18:00)
|
||||||
|
mail-snooze-tomorrow = Mañana (08:00)
|
||||||
|
mail-snooze-next-week = Próxima semana (Lun 08:00)
|
||||||
|
mail-crm-log = Registrar en CRM
|
||||||
|
mail-crm-create-lead = Crear Lead
|
||||||
|
mail-add-to-list = Agregar a Lista
|
||||||
|
campaign-send-email = Enviar Correo
|
||||||
|
|
|
||||||
|
|
@ -1636,3 +1636,15 @@ goals-dashboard = Painel
|
||||||
goals-objectives = Objetivos
|
goals-objectives = Objetivos
|
||||||
goals-alignment = Alinhamento
|
goals-alignment = Alinhamento
|
||||||
goals-ai-suggestions = Sugestões da IA
|
goals-ai-suggestions = Sugestões da IA
|
||||||
|
|
||||||
|
crm-email = E-mail
|
||||||
|
crm-compose-email = Redigir E-mail
|
||||||
|
crm-send-email = Enviar E-mail
|
||||||
|
mail-snooze = Adiar
|
||||||
|
mail-snooze-later-today = Mais tarde hoje (18:00)
|
||||||
|
mail-snooze-tomorrow = Amanhã (08:00)
|
||||||
|
mail-snooze-next-week = Próxima semana (Seg 08:00)
|
||||||
|
mail-crm-log = Registrar no CRM
|
||||||
|
mail-crm-create-lead = Criar Lead
|
||||||
|
mail-add-to-list = Adicionar à Lista
|
||||||
|
campaign-send-email = Enviar E-mail
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use serde::{de::DeserializeOwned, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
const DEFAULT_BOTSERVER_URL: &str = "https://localhost:8088";
|
const DEFAULT_BOTSERVER_URL: &str = "http://localhost:8080";
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -217,8 +217,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_client_creation() {
|
fn test_client_creation() {
|
||||||
let client = BotServerClient::new(Some("http://localhost:8080".to_string()));
|
let client = BotServerClient::new(Some("http://localhost:9000".to_string()));
|
||||||
assert_eq!(client.base_url(), "http://localhost:8080");
|
assert_eq!(client.base_url(), "http://localhost:9000");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
use crate::error::{BotError, BotResult};
|
use crate::error::{BotError, BotResult};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
#[cfg(not(feature = "i18n"))]
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
#[cfg(not(feature = "i18n"))]
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
use super::Locale;
|
use super::Locale;
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "locales"]
|
||||||
|
struct EmbeddedLocales;
|
||||||
|
|
||||||
pub type MessageArgs = HashMap<String, String>;
|
pub type MessageArgs = HashMap<String, String>;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -59,11 +69,18 @@ impl TranslationFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get(&self, key: &str) -> Option<&String> {
|
fn get(&self, key: &str) -> Option<&String> {
|
||||||
self.messages.get(key)
|
let result = self.messages.get(key);
|
||||||
|
if result.is_none() {
|
||||||
|
log::warn!("Translation key not found in bundle: {} (available keys: {})", key, self.messages.len());
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge(&mut self, other: Self) {
|
fn merge(&mut self, other: Self) {
|
||||||
|
let before = self.messages.len();
|
||||||
self.messages.extend(other.messages);
|
self.messages.extend(other.messages);
|
||||||
|
let after = self.messages.len();
|
||||||
|
log::debug!("Merged {} translations (total: {})", after - before, after);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +91,7 @@ struct LocaleBundle {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocaleBundle {
|
impl LocaleBundle {
|
||||||
|
#[cfg(not(feature = "i18n"))]
|
||||||
fn load(locale_dir: &Path) -> BotResult<Self> {
|
fn load(locale_dir: &Path) -> BotResult<Self> {
|
||||||
let dir_name = locale_dir
|
let dir_name = locale_dir
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|
@ -87,14 +105,12 @@ impl LocaleBundle {
|
||||||
messages: HashMap::new(),
|
messages: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let entries = fs::read_dir(locale_dir).map_err(|e| {
|
let entries = fs::read_dir(locale_dir)
|
||||||
BotError::config(format!("failed to read locale directory: {e}"))
|
.map_err(|e| BotError::config(format!("failed to read locale directory: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| {
|
let entry = entry
|
||||||
BotError::config(format!("failed to read directory entry: {e}"))
|
.map_err(|e| BotError::config(format!("failed to read directory entry: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
|
|
@ -117,6 +133,35 @@ impl LocaleBundle {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
fn load_embedded(locale_str: &str) -> BotResult<Self> {
|
||||||
|
let locale = Locale::new(locale_str)
|
||||||
|
.ok_or_else(|| BotError::config(format!("invalid locale: {locale_str}")))?;
|
||||||
|
|
||||||
|
let mut translations = TranslationFile {
|
||||||
|
messages: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("Loading embedded files for locale: {}", locale_str);
|
||||||
|
for file in EmbeddedLocales::iter() {
|
||||||
|
if file.starts_with(locale_str) && file.ends_with(".ftl") {
|
||||||
|
log::info!("Found .ftl file for locale {}: {}", locale_str, file);
|
||||||
|
if let Some(content_bytes) = EmbeddedLocales::get(&file) {
|
||||||
|
if let Ok(content) = std::str::from_utf8(content_bytes.data.as_ref()) {
|
||||||
|
let file_translations = TranslationFile::parse(content);
|
||||||
|
log::info!("Parsed {} keys from {}", file_translations.messages.len(), file);
|
||||||
|
translations.merge(file_translations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
locale,
|
||||||
|
translations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn get_message(&self, key: &str) -> Option<&String> {
|
fn get_message(&self, key: &str) -> Option<&String> {
|
||||||
self.translations.get(key)
|
self.translations.get(key)
|
||||||
}
|
}
|
||||||
|
|
@ -130,26 +175,36 @@ pub struct I18nBundle {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl I18nBundle {
|
impl I18nBundle {
|
||||||
pub fn load(base_path: &str) -> BotResult<Self> {
|
pub fn load(_base_path: &str) -> BotResult<Self> {
|
||||||
let base = Path::new(base_path);
|
// When i18n feature is enabled, locales are ALWAYS embedded via rust-embed
|
||||||
|
// Filesystem loading is deprecated - use embedded assets only
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
{
|
||||||
|
log::info!("Loading embedded locale translations (rust-embed)");
|
||||||
|
Self::load_embedded()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "i18n"))]
|
||||||
|
{
|
||||||
|
// let _base_path = base_path; // Suppress unused warning when i18n is enabled
|
||||||
|
|
||||||
|
let base = Path::new(_base_path);
|
||||||
|
|
||||||
if !base.exists() {
|
if !base.exists() {
|
||||||
return Err(BotError::config(format!(
|
return Err(BotError::config(format!(
|
||||||
"locales directory not found: {base_path}"
|
"locales directory not found: {_base_path}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut bundles = HashMap::new();
|
let mut bundles = HashMap::new();
|
||||||
let mut available = Vec::new();
|
let mut available = Vec::new();
|
||||||
|
|
||||||
let entries = fs::read_dir(base).map_err(|e| {
|
let entries = fs::read_dir(base)
|
||||||
BotError::config(format!("failed to read locales directory: {e}"))
|
.map_err(|e| BotError::config(format!("failed to read locales directory: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| {
|
let entry = entry
|
||||||
BotError::config(format!("failed to read directory entry: {e}"))
|
.map_err(|e| BotError::config(format!("failed to read directory entry: {e}")))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
|
||||||
|
|
@ -174,6 +229,49 @@ impl I18nBundle {
|
||||||
fallback,
|
fallback,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
fn load_embedded() -> BotResult<Self> {
|
||||||
|
let mut bundles = HashMap::new();
|
||||||
|
let mut available = Vec::new();
|
||||||
|
let mut seen_locales = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
let files: Vec<_> = EmbeddedLocales::iter().collect();
|
||||||
|
log::info!("Loading embedded locales, found {} files", files.len());
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
// Path structure: locale/file.ftl
|
||||||
|
let parts: Vec<&str> = file.split('/').collect();
|
||||||
|
if let Some(locale_str) = parts.first() {
|
||||||
|
if !seen_locales.contains(*locale_str) {
|
||||||
|
match LocaleBundle::load_embedded(locale_str) {
|
||||||
|
Ok(bundle) => {
|
||||||
|
available.push(bundle.locale.clone());
|
||||||
|
bundles.insert(bundle.locale.to_string(), bundle);
|
||||||
|
seen_locales.insert(locale_str.to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"failed to load embedded locale bundle {}: {}",
|
||||||
|
locale_str,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback = Locale::default();
|
||||||
|
log::info!("Loaded {} embedded locales: {:?}", available.len(), available);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
bundles,
|
||||||
|
available,
|
||||||
|
fallback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_message(&self, locale: &Locale, key: &str, args: Option<&MessageArgs>) -> String {
|
pub fn get_message(&self, locale: &Locale, key: &str, args: Option<&MessageArgs>) -> String {
|
||||||
let negotiated = Locale::negotiate(&[locale], &self.available, &self.fallback);
|
let negotiated = Locale::negotiate(&[locale], &self.available, &self.fallback);
|
||||||
|
|
@ -258,8 +356,7 @@ impl I18nBundle {
|
||||||
let mut result = template.to_string();
|
let mut result = template.to_string();
|
||||||
|
|
||||||
for (key, value) in args {
|
for (key, value) in args {
|
||||||
let count: i64 = value.parse().unwrap_or(0);
|
if let Ok(count) = value.parse::<i64>() {
|
||||||
|
|
||||||
let plural_pattern = format!("{{ ${key} ->");
|
let plural_pattern = format!("{{ ${key} ->");
|
||||||
|
|
||||||
if let Some(start) = result.find(&plural_pattern) {
|
if let Some(start) = result.find(&plural_pattern) {
|
||||||
|
|
@ -270,6 +367,7 @@ impl I18nBundle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
@ -330,10 +428,7 @@ world = World
|
||||||
greeting = Hello, { $name }!
|
greeting = Hello, { $name }!
|
||||||
"#;
|
"#;
|
||||||
let file = TranslationFile::parse(content);
|
let file = TranslationFile::parse(content);
|
||||||
assert_eq!(
|
assert_eq!(file.get("greeting"), Some(&"Hello, { $name }!".to_string()));
|
||||||
file.get("greeting"),
|
|
||||||
Some(&"Hello, { $name }!".to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,13 @@ use env_logger::fmt::Formatter;
|
||||||
use log::Record;
|
use log::Record;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
// ANSI color codes
|
|
||||||
const RED: &str = "\x1b[31m";
|
|
||||||
const YELLOW: &str = "\x1b[33m";
|
|
||||||
const GREEN: &str = "\x1b[32m";
|
|
||||||
const CYAN: &str = "\x1b[36m";
|
|
||||||
const RESET: &str = "\x1b[0m";
|
|
||||||
|
|
||||||
pub fn compact_format(buf: &mut Formatter, record: &Record) -> std::io::Result<()> {
|
pub fn compact_format(buf: &mut Formatter, record: &Record) -> std::io::Result<()> {
|
||||||
let (level, color) = match record.level() {
|
let level_str = match record.level() {
|
||||||
log::Level::Error => ("E", RED),
|
log::Level::Error => "error",
|
||||||
log::Level::Warn => ("W", YELLOW),
|
log::Level::Warn => "warn",
|
||||||
log::Level::Info => ("I", GREEN),
|
log::Level::Info => "info",
|
||||||
log::Level::Debug => ("D", CYAN),
|
log::Level::Debug => "debug",
|
||||||
log::Level::Trace => ("T", ""),
|
log::Level::Trace => "trace",
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Local::now();
|
let now = chrono::Local::now();
|
||||||
|
|
@ -28,10 +21,50 @@ pub fn compact_format(buf: &mut Formatter, record: &Record) -> std::io::Result<(
|
||||||
target
|
target
|
||||||
};
|
};
|
||||||
|
|
||||||
if color.is_empty() {
|
// Format: "YYYYMMDDHHMMSS.mmm level module:"
|
||||||
writeln!(buf, "{} {} {}:{}", timestamp, level, module, record.args())
|
// Length: 18 + 1 + 5 (error) + 1 + module.len() + 1 = 26 + module.len()
|
||||||
|
let prefix = format!("{} {} {}:", timestamp, level_str, module);
|
||||||
|
|
||||||
|
// Max width 100
|
||||||
|
// Indent for wrapping: 18 timestamp + 1 space + 5 (longest level "error") + 1 space = 25 spaces
|
||||||
|
let message = record.args().to_string();
|
||||||
|
let indent = " "; // 25 spaces
|
||||||
|
|
||||||
|
if prefix.len() + message.len() <= 100 {
|
||||||
|
writeln!(buf, "{}{}", prefix, message)
|
||||||
} else {
|
} else {
|
||||||
writeln!(buf, "{} {}{}{} {}:{}", timestamp, color, level, RESET, module, record.args())
|
let available_first_line = if prefix.len() < 100 {
|
||||||
|
100 - prefix.len()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let available_other_lines = 100 - 25; // 75 chars
|
||||||
|
|
||||||
|
let mut current_pos = 0;
|
||||||
|
let chars: Vec<char> = message.chars().collect();
|
||||||
|
let total_chars = chars.len();
|
||||||
|
|
||||||
|
// First line
|
||||||
|
write!(buf, "{}", prefix)?;
|
||||||
|
|
||||||
|
// If prefix is already >= 100, we force a newline immediately?
|
||||||
|
// Or we just print a bit and wrap?
|
||||||
|
// Let's assume typical usage where module name isn't huge.
|
||||||
|
|
||||||
|
let take = std::cmp::min(available_first_line, total_chars);
|
||||||
|
let first_chunk: String = chars[0..take].iter().collect();
|
||||||
|
writeln!(buf, "{}", first_chunk)?;
|
||||||
|
current_pos += take;
|
||||||
|
|
||||||
|
while current_pos < total_chars {
|
||||||
|
write!(buf, "{}", indent)?;
|
||||||
|
let remaining = total_chars - current_pos;
|
||||||
|
let take = std::cmp::min(remaining, available_other_lines);
|
||||||
|
let chunk: String = chars[current_pos..current_pos + take].iter().collect();
|
||||||
|
writeln!(buf, "{}", chunk)?;
|
||||||
|
current_pos += take;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,8 +75,6 @@ pub fn init_compact_logger(default_filter: &str) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_compact_logger_with_style(default_filter: &str) {
|
pub fn init_compact_logger_with_style(default_filter: &str) {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_filter))
|
// Style ignored to strictly follow text format spec
|
||||||
.format(compact_format)
|
init_compact_logger(default_filter);
|
||||||
.write_style(env_logger::WriteStyle::Always)
|
|
||||||
.init();
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -17,6 +16,8 @@ impl MessageType {
|
||||||
pub const SUGGESTION: Self = Self(4);
|
pub const SUGGESTION: Self = Self(4);
|
||||||
|
|
||||||
pub const CONTEXT_CHANGE: Self = Self(5);
|
pub const CONTEXT_CHANGE: Self = Self(5);
|
||||||
|
|
||||||
|
pub const TOOL_EXEC: Self = Self(6);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<i32> for MessageType {
|
impl From<i32> for MessageType {
|
||||||
|
|
@ -46,6 +47,7 @@ impl std::fmt::Display for MessageType {
|
||||||
3 => "CONTINUE",
|
3 => "CONTINUE",
|
||||||
4 => "SUGGESTION",
|
4 => "SUGGESTION",
|
||||||
5 => "CONTEXT_CHANGE",
|
5 => "CONTEXT_CHANGE",
|
||||||
|
6 => "TOOL_EXEC",
|
||||||
_ => "UNKNOWN",
|
_ => "UNKNOWN",
|
||||||
};
|
};
|
||||||
write!(f, "{name}")
|
write!(f, "{name}")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue