This file is a merged representation of the entire codebase, combined into a single document by Repomix.
<file_summary>
This section contains a summary of this file.
<purpose>
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>
<file_format>
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
- File path as an attribute
- Full contents of the file
</file_format>
<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
</usage_guidelines>
<notes>
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)
</notes>
</file_summary>
<directory_structure>
.cargo/
audit.toml
.githooks/
pre-commit
README.md
.github/
workflows/
ci.yml
release.yml
dependabot.yml
examples/
config.toml
packaging/
homebrew/
lazycelery.rb
scoop/
lazycelery.json
.gitignore
generate_packages.py
README.md
screenshots/
help-screen.png
queues-view.png
search-mode.png
tasks-view.png
workers-view.png
scripts/
validate-versions.py
src/
app/
actions.rs
mod.rs
state.rs
broker/
amqp/
mod.rs
redis/
protocol/
mod.rs
queue_parser.rs
task_parser.rs
worker_parser.rs
facade.rs
mod.rs
operations.rs
pool.rs
result_backend/
mod.rs
redis.rs
mod.rs
models/
mod.rs
queue.rs
task.rs
worker.rs
ui/
widgets/
base.rs
mod.rs
queues.rs
tasks.rs
workers.rs
events.rs
layout.rs
mod.rs
modals.rs
utils/
formatting.rs
mod.rs
config.rs
error.rs
lib.rs
main.rs
tests/
integration_test.rs
redis_test_utils.rs
test_app_actions.rs
test_app.rs
test_broker_utils.rs
test_complete_integration.rs
test_config.rs
test_error.rs
test_event_handling.rs
test_models.rs
test_redis_broker_basic.rs
test_redis_broker_integration.rs
test_ui_base_widgets.rs
test_ui_layout.rs
test_ui_modals.rs
test_ui_widgets.rs
test_utils_formatting.rs
test_utils.rs
.editorconfig
.gitignore
.mise.toml
Cargo.toml
CHANGELOG.md
CLAUDE.md
CONTRIBUTING.md
deny.toml
Dockerfile
LICENSE
README.md
ROADMAP.md
rustfmt.toml
</directory_structure>
<files>
This section contains the contents of the repository's files.
<file path="src/broker/amqp/mod.rs">
//! AMQP (RabbitMQ) broker implementation for Celery
//!
//! This module provides AMQP broker support using the lapin library.
//! It connects to RabbitMQ and consumes Celery events from the `celeryev` queue.
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures_lite::stream::StreamExt;
use lapin::{
options::{
BasicAckOptions, BasicConsumeOptions, BasicPublishOptions, ExchangeDeclareOptions,
QueueBindOptions, QueueDeclareOptions, QueuePurgeOptions,
},
types::FieldTable,
BasicProperties, Channel, Connection, ConnectionProperties, Consumer,
};
use serde_json::Value;
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
use crate::broker::Broker;
use crate::error::BrokerError;
use crate::models::{Queue, Task, TaskStatus, Worker, WorkerStatus};
/// Celery event types we care about
#[derive(Debug, Clone)]
enum CeleryEventType {
WorkerOnline,
WorkerOffline,
TaskStarted,
TaskSuccess,
TaskFailure,
TaskRetry,
TaskReceived,
Unknown,
}
/// Parsed Celery event
#[derive(Debug, Clone)]
struct CeleryEvent {
event_type: CeleryEventType,
timestamp: f64,
hostname: Option<String>,
task_id: Option<String>,
task_name: Option<String>,
result: Option<String>,
traceback: Option<String>,
exception: Option<String>,
args: Option<String>,
kwargs: Option<String>,
retries: Option<u32>,
}
impl CeleryEvent {
/// Parse a raw event JSON into a CeleryEvent
fn parse(data: &[u8]) -> Result<Self, BrokerError> {
let json: Value =
serde_json::from_slice(data).map_err(|e| BrokerError::OperationError(format!("Failed to parse event JSON: {}", e)))?;
let event_type = match json.get("type").and_then(|v| v.as_str()) {
Some("worker-online") => CeleryEventType::WorkerOnline,
Some("worker-offline") => CeleryEventType::WorkerOffline,
Some("task-started") => CeleryEventType::TaskStarted,
Some("task-success") => CeleryEventType::TaskSuccess,
Some("task-failure") => CeleryEventType::TaskFailure,
Some("task-retry") => CeleryEventType::TaskRetry,
Some("task-received") => CeleryEventType::TaskReceived,
_ => CeleryEventType::Unknown,
};
let timestamp = json
.get("timestamp")
.and_then(|v| v.as_f64())
.unwrap_or_else(|| Utc::now().timestamp_millis() as f64);
let hostname = json.get("hostname").and_then(|v| v.as_str()).map(String::from);
let task_id = json.get("id").and_then(|v| v.as_str()).map(String::from);
let task_name = json.get("name").and_then(|v| v.as_str()).map(String::from);
let result = json.get("result").and_then(|v| v.as_str()).map(String::from);
let traceback = json.get("traceback").and_then(|v| v.as_str()).map(String::from);
let exception = json.get("exception").and_then(|v| v.as_str()).map(String::from);
let args = json.get("args").and_then(|v| v.as_str()).map(String::from);
let kwargs = json.get("kwargs").and_then(|v| v.as_str()).map(String::from);
let retries = json.get("retries").and_then(|v| v.as_u64()).map(|v| v as u32);
Ok(Self {
event_type,
timestamp,
hostname,
task_id,
task_name,
result,
traceback,
exception,
args,
kwargs,
retries,
})
}
/// Convert to Worker model
fn to_worker(&self) -> Option<Worker> {
let hostname = self.hostname.clone()?;
let status = match self.event_type {
CeleryEventType::WorkerOnline => WorkerStatus::Online,
CeleryEventType::WorkerOffline => WorkerStatus::Offline,
_ => return None,
};
Some(Worker {
hostname,
status,
concurrency: 1,
queues: vec![],
active_tasks: vec![],
processed: 0,
failed: 0,
})
}
/// Convert to Task model
fn to_task(&self) -> Option<Task> {
let task_id = self.task_id.clone()?;
let task_name = self.task_name.clone().unwrap_or_else(|| "unknown".to_string());
let status = match self.event_type {
CeleryEventType::TaskReceived => TaskStatus::Pending,
CeleryEventType::TaskStarted => TaskStatus::Active,
CeleryEventType::TaskSuccess => TaskStatus::Success,
CeleryEventType::TaskFailure => TaskStatus::Failure,
CeleryEventType::TaskRetry => TaskStatus::Retry,
_ => return None,
};
Some(Task {
id: task_id,
name: task_name,
args: self.args.clone().unwrap_or_else(|| "[]".to_string()),
kwargs: self.kwargs.clone().unwrap_or_else(|| "{}".to_string()),
status,
worker: self.hostname.clone(),
timestamp: DateTime::from_timestamp_millis(self.timestamp as i64).unwrap_or_else(Utc::now),
result: self.result.clone(),
traceback: self.traceback.clone(),
})
}
}
/// AMQP broker state that persists across method calls
#[derive(Clone)]
struct AmqpState {
/// Known workers discovered from events
workers: Arc<RwLock<Vec<Worker>>>,
/// Known tasks discovered from events
tasks: Arc<RwLock<Vec<Task>>>,
/// Whether we're connected and listening
connected: Arc<RwLock<bool>>,
}
impl AmqpState {
fn new() -> Self {
Self {
workers: Arc::new(RwLock::new(Vec::new())),
tasks: Arc::new(RwLock::new(Vec::new())),
connected: Arc::new(RwLock::new(false)),
}
}
}
/// AMQP broker implementation for Celery
pub struct AmqpBroker {
/// AMQP connection
connection: Connection,
/// AMQP channel for operations
channel: Channel,
/// Broker URL
url: String,
/// Internal state
state: AmqpState,
/// Celery event consumer (kept alive)
#[allow(dead_code)]
consumer: Option<Consumer>,
}
impl AmqpBroker {
/// Create a new AMQP broker
pub async fn connect(url: &str) -> Result<Self, BrokerError> {
info!("Connecting to AMQP broker: {}", url.split('@').last().unwrap_or("hidden"));
let connection = Connection::connect(url, ConnectionProperties::default())
.await
.map_err(|e| {
error!("Failed to connect to AMQP: {}", e);
BrokerError::ConnectionError(format!("AMQP connection failed: {}", e))
})?;
let channel = connection
.create_channel()
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to create channel: {}", e)))?;
// Declare the celeryev exchange if it doesn't exist
let _ = channel
.exchange_declare(
"celeryev",
lapin::ExchangeKind::Topic,
ExchangeDeclareOptions {
passive: false,
durable: true,
auto_delete: false,
internal: false,
nowait: false,
},
FieldTable::default(),
)
.await;
info!("AMQP broker connected successfully");
let broker = Self {
connection,
channel,
url: url.to_string(),
state: AmqpState::new(),
consumer: None,
};
// Start consuming events in the background
let _ = broker.start_event_consumer().await;
Ok(broker)
}
/// Start consuming Celery events in the background
async fn start_event_consumer(&self) -> Result<(), BrokerError> {
let channel = self.connection.create_channel().await.map_err(|e| {
BrokerError::OperationError(format!("Failed to create event channel: {}", e))
})?;
// Declare the celeryev queue
let queue = channel
.queue_declare(
"celeryev",
QueueDeclareOptions {
passive: false,
durable: true,
exclusive: false,
auto_delete: false,
nowait: false,
},
FieldTable::default(),
)
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to declare queue: {}", e)))?;
// Bind to the celeryev exchange
channel
.queue_bind("celeryev", "celeryev", "*", QueueBindOptions::default(), FieldTable::default())
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to bind queue: {}", e)))?;
// Create consumer
let mut consumer = channel
.basic_consume(
"celeryev",
"lazycelery",
BasicConsumeOptions {
no_local: false,
no_ack: false,
exclusive: false,
nowait: false,
},
FieldTable::default(),
)
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to start consumer: {}", e)))?;
let workers = self.state.workers.clone();
let tasks = self.state.tasks.clone();
let connected = self.state.connected.clone();
// Mark as connected
{
let mut conn = connected.write().await;
*conn = true;
}
// Spawn the event consumer task
tokio::spawn(async move {
info!("Starting Celery event consumer");
while let Some(delivery_result) = consumer.next().await {
match delivery_result {
Ok(delivery) => {
let data = delivery.data.clone();
if let Ok(event) = CeleryEvent::parse(&data) {
match event.to_worker() {
Some(worker) => {
let mut workers_guard = workers.write().await;
// Update or add worker
if let Some(existing) = workers_guard.iter_mut().find(|w| w.hostname == worker.hostname) {
*existing = worker;
} else {
workers_guard.push(worker);
}
}
None => {}
}
match event.to_task() {
Some(task) => {
let mut tasks_guard = tasks.write().await;
// Update or add task
if let Some(existing) = tasks_guard.iter_mut().find(|t| t.id == task.id) {
// Update existing task
existing.status = task.status;
existing.result = task.result.clone().or(existing.result.clone());
existing.traceback = task.traceback.clone().or(existing.traceback.clone());
if let Some(w) = task.worker {
existing.worker = Some(w);
}
} else {
tasks_guard.push(task);
}
}
None => {}
}
}
let _ = delivery.ack(BasicAckOptions::default()).await;
}
Err(e) => {
warn!("Error receiving event: {}", e);
}
}
}
info!("Celery event consumer stopped");
});
Ok(())
}
/// List all queues in the broker
async fn list_queues(&self) -> Result<Vec<String>, BrokerError> {
// In AMQP/RabbitMQ, listing queues requires the management plugin
// For basic implementation, we return common Celery queue patterns
// The actual queues will be discovered when workers connect
let mut queues = vec!["celery".to_string()];
// Try to declare common Celery queues passively to check existence
// This is a workaround since queue_list() requires management plugin
let celery_queues = ["celery", "celery@", "celeryd", "celeryd@"];
for queue_name in celery_queues {
match self.channel.queue_declare(
queue_name,
QueueDeclareOptions {
passive: true,
durable: true,
exclusive: false,
auto_delete: false,
nowait: false,
},
FieldTable::default(),
).await {
Ok(_) => {
if !queues.contains(&queue_name.to_string()) {
queues.push(queue_name.to_string());
}
}
Err(_) => {}
}
}
Ok(queues)
}
}
#[async_trait]
impl Broker for AmqpBroker {
async fn connect(url: &str) -> Result<Self, BrokerError>
where
Self: Sized,
{
Self::connect(url).await
}
async fn get_workers(&self) -> Result<Vec<Worker>, BrokerError> {
// Return workers from our cached state
let workers = self.state.workers.read().await;
Ok(workers.clone())
}
async fn get_tasks(&self) -> Result<Vec<Task>, BrokerError> {
// Return tasks from our cached state
let tasks = self.state.tasks.read().await;
Ok(tasks.clone())
}
async fn get_queues(&self) -> Result<Vec<Queue>, BrokerError> {
let queue_names = self.list_queues().await?;
let mut queues = Vec::new();
for name in queue_names {
// Get queue info using passive declare
match self
.channel
.queue_declare(&name, QueueDeclareOptions {
passive: true,
durable: true,
exclusive: false,
auto_delete: false,
nowait: false,
}, FieldTable::default())
.await
{
Ok(declaration) => {
queues.push(Queue {
name,
length: declaration.message_count() as u64,
consumers: declaration.consumer_count(),
});
}
Err(e) => {
debug!("Could not get info for queue: {}", e);
}
}
}
Ok(queues)
}
async fn retry_task(&self, task_id: &str) -> Result<(), BrokerError> {
// To retry a task in Celery via AMQP, we need to republish it
// This requires knowing the original task message
// For now, return not implemented - would need task message storage
// Find the task to get its details
let tasks = self.state.tasks.read().await;
if let Some(task) = tasks.iter().find(|t| t.id == task_id) {
// Republish the task to the default queue
let payload = serde_json::json!({
"id": task_id,
"name": task.name,
"args": task.args,
"kwargs": task.kwargs,
"retries": 1,
});
self.channel
.basic_publish(
"",
"celery",
BasicPublishOptions::default(),
&payload.to_string().as_bytes(),
BasicProperties::default()
.with_content_type("application/json".into())
.with_correlation_id(task_id.into()),
)
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to retry task: {}", e)))?;
info!("Task {} requeued for retry", task_id);
Ok(())
} else {
Err(BrokerError::OperationError(format!("Task {} not found", task_id)))
}
}
async fn revoke_task(&self, task_id: &str) -> Result<(), BrokerError> {
// To revoke a task, we publish a 'revoke' message to the Celery control exchange
let revoke_msg = serde_json::json!({
"action": "revoke",
"task_id": task_id,
"terminate": false,
});
self.channel
.basic_publish(
"celery",
"celeryctl",
BasicPublishOptions::default(),
&revoke_msg.to_string().as_bytes(),
BasicProperties::default()
.with_content_type("application/json".into())
.with_correlation_id(task_id.into()),
)
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to revoke task: {}", e)))?;
info!("Task {} revoked", task_id);
Ok(())
}
async fn purge_queue(&self, queue_name: &str) -> Result<u64, BrokerError> {
// Purge a queue by redeclaring it with purge option
let queue = self
.channel
.queue_declare(queue_name, QueueDeclareOptions {
passive: false,
durable: true,
exclusive: false,
auto_delete: false,
nowait: false,
}, FieldTable::default())
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to declare queue: {}", e)))?;
let message_count = queue.message_count();
// Purge messages by consuming them all
if message_count > 0 {
// Create a new channel for purging
let purge_channel = self
.connection
.create_channel()
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to create channel: {}", e)))?;
// Purge the queue - returns u32 directly
let purged = purge_channel
.queue_purge(queue_name, QueuePurgeOptions::default())
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to purge queue: {}", e)))?;
info!("Purged {} messages from queue {}", purged, queue_name);
Ok(purged as u64)
} else {
Ok(0)
}
}
}
</file>
<file path="src/broker/result_backend/mod.rs">
//! Result backend trait for storing task results
//!
//! This module provides a trait for result backends that store task results,
//! tracebacks, and metadata. This is separate from the broker which handles
//! message routing.
use async_trait::async_trait;
use crate::error::BrokerError;
use crate::models::Task;
/// Result backend trait for storing task results
///
/// Result backends store the results of tasks after they complete,
/// including success results, failure tracebacks, and timing information.
#[async_trait]
pub trait ResultBackend: Send + Sync {
/// Get a task result by ID
async fn get_task_result(&self, task_id: &str) -> Result<Option<Task>, BrokerError>;
/// Connect to the result backend
async fn connect(url: &str) -> Result<Self, BrokerError>
where
Self: Sized;
}
</file>
<file path="src/broker/result_backend/redis.rs">
//! Redis result backend implementation
//!
//! This module provides Redis as a result backend for storing task results.
use async_trait::async_trait;
use redis::{AsyncCommands, Client};
use tracing::{debug, error};
use crate::broker::ResultBackend;
use crate::error::BrokerError;
use crate::models::{Task, TaskStatus};
/// Redis result backend
pub struct RedisResultBackend {
client: Client,
}
impl RedisResultBackend {
/// Create a new Redis result backend
pub async fn connect(url: &str) -> Result<Self, BrokerError> {
let client = Client::open(url)
.map_err(|e| BrokerError::ConnectionError(format!("Failed to connect to Redis: {}", e)))?;
// Test the connection
let mut conn = client
.get_multiplexed_tokio_connection()
.await
.map_err(|e| BrokerError::ConnectionError(format!("Failed to get connection: {}", e)))?;
redis::cmd("PING")
.query_async::<_, String>(&mut conn)
.await
.map_err(|e| BrokerError::ConnectionError(format!("Redis ping failed: {}", e)))?;
tracing::info!("Redis result backend connected");
Ok(Self { client })
}
/// Parse a task from Redis result metadata
fn parse_task_meta(task_id: &str, data: &str) -> Result<Task, BrokerError> {
let json: serde_json::Value = serde_json::from_str(data)
.map_err(|e| BrokerError::OperationError(format!("Failed to parse task result: {}", e)))?;
let status = match json.get("status").and_then(|v| v.as_str()) {
Some("pending") => TaskStatus::Pending,
Some("started") => TaskStatus::Active,
Some("success") => TaskStatus::Success,
Some("failure") => TaskStatus::Failure,
Some("retry") => TaskStatus::Retry,
Some("revoked") => TaskStatus::Revoked,
_ => TaskStatus::Pending,
};
let result = json.get("result").and_then(|v| v.as_str()).map(String::from);
let traceback = json.get("traceback").and_then(|v| v.as_str()).map(String::from);
let name = json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let args = json
.get("args")
.and_then(|v| v.as_str())
.unwrap_or("[]")
.to_string();
let kwargs = json
.get("kwargs")
.and_then(|v| v.as_str())
.unwrap_or("{}")
.to_string();
// Try to get timestamps
let timestamp = chrono::Utc::now();
// Try to get worker info
let worker = json
.get("worker")
.and_then(|v| v.as_str())
.map(String::from);
Ok(Task {
id: task_id.to_string(),
name,
args,
kwargs,
status,
worker,
timestamp,
result,
traceback,
})
}
}
#[async_trait]
impl ResultBackend for RedisResultBackend {
async fn get_task_result(&self, task_id: &str) -> Result<Option<Task>, BrokerError> {
let mut conn = self
.client
.get_multiplexed_tokio_connection()
.await
.map_err(|e| BrokerError::OperationError(format!("Failed to get connection: {}", e)))?;
// Celery stores task results with this key pattern
let key = format!("celery-task-meta-{}", task_id);
debug!("Getting task result for: {}", key);
let result: Option<String> = conn
.get(&key)
.await
.map_err(|e| {
error!("Failed to get task result: {}", e);
BrokerError::OperationError(format!("Failed to get task result: {}", e))
})?;
match result {
Some(data) => {
let task = Self::parse_task_meta(task_id, &data)?;
Ok(Some(task))
}
None => Ok(None),
}
}
async fn connect(url: &str) -> Result<Self, BrokerError>
where
Self: Sized,
{
Self::connect(url).await
}
}
</file>
<file path=".cargo/audit.toml">
# Audit configuration for cargo-audit
[advisories]
# Ignore the paste crate unmaintained warning as it's a transitive dependency
# from ratatui and not a security vulnerability
ignore = [
"RUSTSEC-2024-0436" # paste - no longer maintained
]
</file>
<file path=".githooks/README.md">
# Git Hooks
This directory contains custom git hooks for the LazyCelery project.
## Setup
To enable the pre-commit hook, run:
```bash
git config core.hooksPath .githooks
```
This will configure git to use the hooks in this directory instead of `.git/hooks/`.
## Hooks
### pre-commit
Runs before each commit and performs the following checks:
1. **Code formatting** - Ensures code is properly formatted with `rustfmt`
2. **Linting** - Runs `clippy` to catch common issues and style violations
3. **Tests** - Runs the full test suite to ensure nothing is broken
4. **Security audit** - Checks for known security vulnerabilities in dependencies
If any check fails, the commit is aborted and you'll need to fix the issues before committing.
## Bypassing Hooks
In emergency situations, you can bypass the pre-commit hook with:
```bash
git commit --no-verify
```
However, this should be used sparingly as it defeats the purpose of having quality checks.
## Requirements
- `mise` must be installed and configured
- All mise tasks must be properly set up (`fmt`, `lint`, `test`, `audit`)
</file>
<file path="examples/config.toml">
# LazyCelery Configuration Example
[broker]
# Redis broker URL
url = "redis://localhost:6379/0"
# Connection timeout in seconds
timeout = 30
# Number of retry attempts for failed connections
retry_attempts = 3
[ui]
# Data refresh interval in milliseconds
refresh_interval = 1000
# UI theme (currently only "dark" is supported)
theme = "dark"
</file>
<file path="packaging/.gitignore">
# Package manager working files
*.pkg.tar.xz
*.deb
*.rpm
*.snap
*.nupkg
*.zip
*.tar.gz
# Temporary build files
build/
dist/
target/
# Checksums and metadata
checksums.txt
*.SRCINFO
*.buildinfo
# Package manager specific
chocolatey/tools/*.exe
scoop/generated/
homebrew/generated/
</file>
<file path="packaging/generate_packages.py">
#!/usr/bin/env python3
"""
Package Metadata Generator for LazyCelery
This script generates package configuration files from Cargo.toml metadata,
reducing duplication and ensuring consistency across package managers.
Usage:
python3 packaging/generate_packages.py [--calculate-hashes]
"""
import os
import sys
import json
import re
import hashlib
import urllib.request
import tempfile
from pathlib import Path
from typing import Dict, Any, Optional
def parse_toml_simple(content: str) -> Dict[str, Any]:
"""Simple TOML parser for Cargo.toml package section"""
lines = content.split('\n')
in_package = False
package_data = {}
for line in lines:
line = line.strip()
if line == '[package]':
in_package = True
continue
elif line.startswith('[') and line != '[package]':
in_package = False
continue
if in_package and '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"\'')
# Handle arrays
if value.startswith('[') and value.endswith(']'):
value = value[1:-1]
if value:
items = [item.strip().strip('"\'') for item in value.split(',')]
package_data[key] = items
else:
package_data[key] = []
else:
package_data[key] = value
return package_data
def load_cargo_metadata(cargo_toml_path: str) -> Dict[str, Any]:
"""Load metadata from Cargo.toml"""
with open(cargo_toml_path, 'r') as f:
content = f.read()
package = parse_toml_simple(content)
# Extract author from array format if needed
authors = package.get('authors', ['Unknown'])
if isinstance(authors, list):
authors = authors[0] if authors else 'Unknown'
return {
'name': package.get('name', 'lazycelery'),
'version': package.get('version', '0.4.0'),
'description': package.get('description', 'Terminal UI for monitoring Celery'),
'authors': [authors],
'license': package.get('license', 'MIT'),
'repository': package.get('repository', 'https://github.com/Fguedes90/lazycelery'),
'homepage': package.get('homepage', 'https://github.com/Fguedes90/lazycelery'),
'keywords': package.get('keywords', ['celery', 'tui', 'terminal', 'monitoring', 'redis']),
'categories': package.get('categories', ['command-line-utilities'])
}
def calculate_sha256_from_url(url: str) -> Optional[str]:
"""
Calculate SHA256 hash of a file from URL.
Returns None if the file doesn't exist or there's an error.
"""
try:
with urllib.request.urlopen(url) as response:
hasher = hashlib.sha256()
while True:
chunk = response.read(8192)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
except Exception as e:
print(f"Warning: Could not calculate hash for {url}: {e}")
return None
def sanitize_input(value: str, max_length: int = 256, allowed_chars: str = None) -> str:
"""
Sanitize input to prevent injection attacks.
Args:
value: Input string to sanitize
max_length: Maximum allowed length
allowed_chars: String of allowed characters (if None, uses safe defaults)
Returns:
Sanitized string
Raises:
ValueError: If input is invalid
"""
if not isinstance(value, str):
raise ValueError("Input must be a string")
if len(value) > max_length:
raise ValueError(f"Input exceeds maximum length of {max_length}")
if allowed_chars is None:
# Safe defaults for package names, versions, URLs
allowed_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_/:"
for char in value:
if char not in allowed_chars:
raise ValueError(f"Invalid character '{char}' in input")
return value
def validate_version(version: str) -> str:
"""Validate semantic version format"""
# Allow only semantic version format: x.y.z with optional pre-release
if not re.match(r'^\d+\.\d+\.\d+(-[a-zA-Z0-9\-\.]+)?$', version):
raise ValueError(f"Invalid version format: {version}")
return version
def validate_url(url: str) -> str:
"""Validate URL format"""
if not re.match(r'^https?://[a-zA-Z0-9\-\.]+(/.*)?$', url):
raise ValueError(f"Invalid URL format: {url}")
return url
def generate_pkgbuild_source(metadata: Dict[str, Any], calculate_hashes: bool = False) -> str:
"""Generate AUR PKGBUILD for source build"""
# Validate and sanitize inputs
name = sanitize_input(metadata['name'], 50, "abcdefghijklmnopqrstuvwxyz0123456789-")
version = validate_version(metadata['version'])
repository = validate_url(metadata['repository'])
homepage = validate_url(metadata['homepage'])
# Calculate SHA256 if requested and possible
sha256_hash = "PLACEHOLDER_SHA256"
if calculate_hashes:
source_url = f"{repository}/archive/v{version}.tar.gz"
calculated_hash = calculate_sha256_from_url(source_url)
if calculated_hash:
sha256_hash = calculated_hash
return f"""# Maintainer: {metadata['authors'][0]}
pkgname={name}
pkgver={version}
pkgrel=1
pkgdesc="{metadata['description']}"
arch=('x86_64')
url="{homepage}"
license=('{metadata['license']}')
depends=('gcc-libs')
makedepends=('rust' 'cargo')
source=("$pkgname-$pkgver.tar.gz::{repository}/archive/v$pkgver.tar.gz")
sha256sums=('{sha256_hash}')
build() {{
cd "$pkgname-$pkgver"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cargo build --release --locked
}}
check() {{
cd "$pkgname-$pkgver"
export RUSTUP_TOOLCHAIN=stable
cargo test --release --locked
}}
package() {{
cd "$pkgname-$pkgver"
install -Dm755 target/release/$pkgname "$pkgdir/usr/bin/$pkgname"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}}"""
def generate_pkgbuild_bin(metadata: Dict[str, Any], calculate_hashes: bool = False) -> str:
"""Generate AUR PKGBUILD for binary release"""
# Validate and sanitize inputs
name = sanitize_input(metadata['name'], 50, "abcdefghijklmnopqrstuvwxyz0123456789-")
version = validate_version(metadata['version'])
repository = validate_url(metadata['repository'])
homepage = validate_url(metadata['homepage'])
# Calculate SHA256 if requested and possible
sha256_hash = "PLACEHOLDER_SHA256"
if calculate_hashes:
binary_url = f"{repository}/releases/download/v{version}/{name}-linux-x86_64.tar.gz"
calculated_hash = calculate_sha256_from_url(binary_url)
if calculated_hash:
sha256_hash = calculated_hash
return f"""# Maintainer: {metadata['authors'][0]}
pkgname={name}-bin
pkgver={version}
pkgrel=1
pkgdesc="{metadata['description']} (binary release)"
arch=('x86_64')
url="{homepage}"
license=('{metadata['license']}')
depends=('gcc-libs')
provides=('{name}')
conflicts=('{name}')
source_x86_64=("{repository}/releases/download/v$pkgver/{name}-linux-x86_64.tar.gz")
sha256sums_x86_64=('{sha256_hash}')
package() {{
install -Dm755 {metadata['name']} "$pkgdir/usr/bin/{metadata['name']}"
# Download and install license and docs
curl -sL "{metadata['repository']}/raw/v$pkgver/LICENSE" -o LICENSE
curl -sL "{metadata['repository']}/raw/v$pkgver/README.md" -o README.md
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}}"""
def generate_homebrew_formula(metadata: Dict[str, Any], calculate_hashes: bool = False) -> str:
"""Generate Homebrew formula"""
# Validate and sanitize inputs
name = sanitize_input(metadata['name'], 50, "abcdefghijklmnopqrstuvwxyz0123456789-")
version = validate_version(metadata['version'])
repository = validate_url(metadata['repository'])
homepage = validate_url(metadata['homepage'])
# Calculate SHA256 if requested and possible
sha256_hash = "PLACEHOLDER_SHA256"
if calculate_hashes:
source_url = f"{repository}/archive/v{version}.tar.gz"
calculated_hash = calculate_sha256_from_url(source_url)
if calculated_hash:
sha256_hash = calculated_hash
class_name = name.capitalize()
return f"""class {class_name} < Formula
desc "{metadata['description']}"
homepage "{homepage}"
url "{repository}/archive/v{version}.tar.gz"
sha256 "{sha256_hash}"
license "{metadata['license']}"
depends_on "rust" => :build
def install
system "cargo", "install", *std_cargo_args
end
test do
assert_match "{metadata['name']}", shell_output("#{{bin}}/{metadata['name']} --help")
end
end"""
def generate_chocolatey_nuspec(metadata: Dict[str, Any], calculate_hashes: bool = False) -> str:
"""Generate Chocolatey package specification"""
# Validate and sanitize inputs
name = sanitize_input(metadata['name'], 50, "abcdefghijklmnopqrstuvwxyz0123456789-")
version = validate_version(metadata['version'])
repository = validate_url(metadata['repository'])
author = metadata['authors'][0].split('<')[0].strip()
tags = ' '.join(metadata['keywords'])
return f"""<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>{name}</id>
<version>{version}</version>
<packageSourceUrl>{repository}/tree/main/packaging/chocolatey</packageSourceUrl>
<owners>{author}</owners>
<title>{name.capitalize()}</title>
<authors>{author}</authors>
<projectUrl>{repository}</projectUrl>
<iconUrl>{repository}/raw/main/screenshots/workers-view.png</iconUrl>
<copyright>2024 {author}</copyright>
<licenseUrl>{repository}/blob/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<projectSourceUrl>{repository}</projectSourceUrl>
<docsUrl>{repository}/blob/main/README.md</docsUrl>
<bugTrackerUrl>{repository}/issues</bugTrackerUrl>
<tags>{tags}</tags>
<summary>{metadata['description']}</summary>
<description>
{metadata['description']}
## Features
- Real-time monitoring of Celery workers and tasks
- Task management (retry, revoke, purge queues)
- Redis broker support with real Celery protocol integration
- Intuitive terminal interface with vim-style navigation
- Cross-platform support (Linux, macOS, Windows)
## Usage
Run `{name}` in your terminal to start monitoring your Celery infrastructure.
</description>
<releaseNotes>{repository}/releases</releaseNotes>
</metadata>
<files>
<file src="tools\\**" target="tools" />
</files>
</package>"""
def generate_scoop_manifest(metadata: Dict[str, Any], calculate_hashes: bool = False) -> str:
"""Generate Scoop package manifest"""
# Validate and sanitize inputs
name = sanitize_input(metadata['name'], 50, "abcdefghijklmnopqrstuvwxyz0123456789-")
version = validate_version(metadata['version'])
repository = validate_url(metadata['repository'])
homepage = validate_url(metadata['homepage'])
# Calculate SHA256 if requested and possible
sha256_hash = "PLACEHOLDER_SHA256"
if calculate_hashes:
windows_url = f"{repository}/releases/download/v{version}/{name}-windows-x86_64.zip"
calculated_hash = calculate_sha256_from_url(windows_url)
if calculated_hash:
sha256_hash = calculated_hash
manifest = {
"version": version,
"description": metadata['description'],
"homepage": homepage,
"license": metadata['license'],
"architecture": {
"64bit": {
"url": f"{repository}/releases/download/v{version}/{name}-windows-x86_64.zip",
"hash": sha256_hash,
"extract_dir": ""
}
},
"bin": f"{name}.exe",
"checkver": {
"github": repository
},
"autoupdate": {
"architecture": {
"64bit": {
"url": f"{repository}/releases/download/v$version/{name}-windows-x86_64.zip"
}
}
}
}
return json.dumps(manifest, indent=4)
def generate_snapcraft_yaml(metadata: Dict[str, Any], calculate_hashes: bool = False) -> str:
"""Generate Snapcraft YAML"""
# Validate and sanitize inputs
name = sanitize_input(metadata['name'], 50, "abcdefghijklmnopqrstuvwxyz0123456789-")
version = validate_version(metadata['version'])
repository = validate_url(metadata['repository'])
return f"""name: {name}
base: core22
version: '{version}'
summary: {metadata['description']}
description: |
{metadata['description']}
Features:
- Real-time monitoring of Celery workers and tasks
- Task management (retry, revoke, purge queues)
- Redis broker support with real Celery protocol integration
- Intuitive terminal interface with vim-style navigation
- Cross-platform support
grade: stable
confinement: strict
architectures:
- build-on: amd64
- build-on: arm64
apps:
{name}:
command: bin/{name}
plugs:
- network
- network-bind
- home
parts:
{name}:
plugin: rust
source: {repository}.git
source-tag: v{version}
rust-features: []
build-packages:
- build-essential
- pkg-config
override-build: |
craftctl default
# Strip the binary to reduce size
strip $CRAFT_PART_INSTALL/bin/{name}"""
def main():
"""Main function to generate all package files"""
# Parse command line arguments
calculate_hashes = "--calculate-hashes" in sys.argv
# Get project root directory
script_dir = Path(__file__).parent.absolute()
project_root = script_dir.parent
cargo_toml = project_root / "Cargo.toml"
if not cargo_toml.exists():
print(f"Error: Cargo.toml not found at {cargo_toml}", file=sys.stderr)
sys.exit(1)
# Load and validate metadata
try:
metadata = load_cargo_metadata(str(cargo_toml))
# Validate critical metadata fields
validate_version(metadata['version'])
validate_url(metadata['repository'])
validate_url(metadata['homepage'])
sanitize_input(metadata['name'], 50, "abcdefghijklmnopqrstuvwxyz0123456789-")
print(f"Generating packages for {metadata['name']} v{metadata['version']}")
except (ValueError, KeyError) as e:
print(f"Error: Invalid metadata in Cargo.toml: {e}", file=sys.stderr)
sys.exit(1)
if calculate_hashes:
print("\nCalculating SHA256 hashes from release artifacts...")
# Generate all package files
generators = [
("aur/PKGBUILD", generate_pkgbuild_source),
("aur/PKGBUILD-bin", generate_pkgbuild_bin),
("homebrew/lazycelery.rb", generate_homebrew_formula),
("chocolatey/lazycelery.nuspec", generate_chocolatey_nuspec),
("scoop/lazycelery.json", generate_scoop_manifest),
("snap/snapcraft.yaml", generate_snapcraft_yaml),
]
for file_path, generator in generators:
full_path = script_dir / file_path
try:
content = generator(metadata, calculate_hashes)
# Ensure directory exists
full_path.parent.mkdir(parents=True, exist_ok=True)
# Write file
with open(full_path, 'w') as f:
f.write(content)
print(f"Generated: {file_path}")
except Exception as e:
print(f"Error generating {file_path}: {e}", file=sys.stderr)
continue
print(f"\nAll package files generated successfully!")
if not calculate_hashes:
print(f"Note: Use --calculate-hashes to compute real SHA256 hashes from release artifacts.")
else:
print(f"Note: SHA256 hashes calculated from available release artifacts.")
if __name__ == "__main__":
main()
</file>
<file path="packaging/README.md">
# Package Management
This directory contains packaging configurations for various package managers, all generated from the canonical metadata in `Cargo.toml`.
## Template System
To avoid metadata duplication and ensure consistency across all package managers, we use a template generation system:
### Usage
```bash
# Generate all package files from Cargo.toml metadata
python3 packaging/generate_packages.py
```
This script automatically:
- Extracts metadata from `Cargo.toml` (version, description, author, etc.)
- Generates consistent package configurations for all platforms
- Updates version numbers across all packaging formats
### Supported Package Managers
| Package Manager | File | Platform |
|-----------------|------|----------|
| **AUR Source** | `aur/PKGBUILD` | Arch Linux (build from source) |
| **AUR Binary** | `aur/PKGBUILD-bin` | Arch Linux (pre-built binary) |
| **Homebrew** | `homebrew/lazycelery.rb` | macOS |
| **Chocolatey** | `chocolatey/lazycelery.nuspec` | Windows |
| **Scoop** | `scoop/lazycelery.json` | Windows |
| **Snap** | `snap/snapcraft.yaml` | Ubuntu/Linux |
### Manual Updates Required
After running the generator, you may need to manually update:
1. **SHA256 Hashes**: Update `PLACEHOLDER_SHA256` values after creating release artifacts
2. **Platform-specific details**: Adjust build dependencies or installation steps if needed
3. **Release notes**: Add version-specific changelog entries
### Benefits
- โ
**Single Source of Truth**: All metadata comes from `Cargo.toml`
- โ
**Version Consistency**: No more mismatched versions across packages
- โ
**Reduced Duplication**: Description, license, repository URL only defined once
- โ
**Easy Updates**: Change once in `Cargo.toml`, regenerate all packages
- โ
**CI Integration**: Can be automated in release workflows
### Integration with CI/CD
The package generation can be integrated into the automated release workflow:
```yaml
- name: Generate package files
run: python3 packaging/generate_packages.py
- name: Update SHA256 hashes
run: |
# Calculate and update hashes for release artifacts
# This would be done after building release binaries
```
### Development Workflow
1. **Update version** in `Cargo.toml`
2. **Run generator**: `python3 packaging/generate_packages.py`
3. **Commit changes**: All package files are now updated consistently
4. **Create release**: CI will use the updated package configurations
This approach ensures that package maintainers always have accurate, up-to-date configurations without manual synchronization across multiple files.
</file>
<file path="scripts/validate-versions.py">
#!/usr/bin/env python3
"""
Version Consistency Validation Script for LazyCelery
This script validates that all version references across the project are consistent
and prevents version drift between different configuration files.
Usage:
python3 scripts/validate-versions.py [--fix]
Options:
--fix Attempt to automatically fix version inconsistencies
"""
import os
import sys
import re
import json
import argparse
from pathlib import Path
from typing import Dict, List, Tuple, Optional
class VersionValidator:
"""Validates version consistency across project files"""
def __init__(self, project_root: Path):
self.project_root = project_root
self.errors: List[str] = []
self.warnings: List[str] = []
def parse_toml_simple(self, content: str) -> Dict[str, str]:
"""Simple TOML parser for extracting version information"""
lines = content.split('\n')
in_package = False
data = {}
for line in lines:
line = line.strip()
if line == '[package]':
in_package = True
continue
elif line.startswith('[') and line != '[package]':
in_package = False
continue
if in_package and '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"\'')
data[key] = value
return data
def get_cargo_version(self) -> Optional[str]:
"""Get version from Cargo.toml"""
cargo_toml = self.project_root / "Cargo.toml"
if not cargo_toml.exists():
self.errors.append("Cargo.toml not found")
return None
with open(cargo_toml, 'r') as f:
content = f.read()
package_data = self.parse_toml_simple(content)
return package_data.get('version')
def get_cargo_rust_version(self) -> Optional[str]:
"""Get rust-version from Cargo.toml"""
cargo_toml = self.project_root / "Cargo.toml"
if not cargo_toml.exists():
return None
with open(cargo_toml, 'r') as f:
content = f.read()
package_data = self.parse_toml_simple(content)
return package_data.get('rust-version')
def get_mise_rust_version(self) -> Optional[str]:
"""Get Rust version from .mise.toml"""
mise_toml = self.project_root / ".mise.toml"
if not mise_toml.exists():
return None
with open(mise_toml, 'r') as f:
content = f.read()
# Extract rust version from [tools] section
lines = content.split('\n')
in_tools = False
for line in lines:
line = line.strip()
if line == '[tools]':
in_tools = True
continue
elif line.startswith('[') and line != '[tools]':
in_tools = False
continue
if in_tools and 'rust' in line and '=' in line:
_, value = line.split('=', 1)
value = value.strip().strip('"\'')
return value
return None
def get_dockerfile_rust_version(self) -> Optional[str]:
"""Get Rust version from Dockerfile"""
dockerfile = self.project_root / "Dockerfile"
if not dockerfile.exists():
return None
with open(dockerfile, 'r') as f:
content = f.read()
# Look for FROM rust:VERSION pattern
match = re.search(r'FROM rust:([0-9.]+)', content)
return match.group(1) if match else None
def validate_rust_versions(self) -> bool:
"""Validate that all Rust versions are consistent"""
cargo_rust = self.get_cargo_rust_version()
mise_rust = self.get_mise_rust_version()
docker_rust = self.get_dockerfile_rust_version()
all_good = True
if not cargo_rust:
self.errors.append("No rust-version found in Cargo.toml")
all_good = False
if not mise_rust:
self.errors.append("No Rust version found in .mise.toml")
all_good = False
if not docker_rust:
self.warnings.append("No Rust version found in Dockerfile")
# Compare versions if all are present
if cargo_rust and mise_rust:
if cargo_rust != mise_rust:
self.errors.append(f"Rust version mismatch: Cargo.toml={cargo_rust}, .mise.toml={mise_rust}")
all_good = False
if cargo_rust and docker_rust:
if cargo_rust != docker_rust:
self.errors.append(f"Rust version mismatch: Cargo.toml={cargo_rust}, Dockerfile={docker_rust}")
all_good = False
if mise_rust and docker_rust:
if mise_rust != docker_rust:
self.errors.append(f"Rust version mismatch: .mise.toml={mise_rust}, Dockerfile={docker_rust}")
all_good = False
return all_good
def validate_package_versions(self) -> bool:
"""Validate that all package manager files have consistent versions"""
cargo_version = self.get_cargo_version()
if not cargo_version:
self.errors.append("Could not determine version from Cargo.toml")
return False
all_good = True
packaging_dir = self.project_root / "packaging"
if not packaging_dir.exists():
self.warnings.append("No packaging directory found")
return True
# Check various package files
package_files = [
("packaging/chocolatey/lazycelery.nuspec", r'<version>([^<]+)</version>'),
("packaging/homebrew/lazycelery.rb", r'url ".*v([0-9.]+)\.tar\.gz"'),
("packaging/scoop/lazycelery.json", r'"version":\s*"([^"]+)"'),
("packaging/snap/snapcraft.yaml", r"version:\s*'([^']+)'"),
("packaging/aur/PKGBUILD", r'pkgver=([0-9.]+)'),
("packaging/aur/PKGBUILD-bin", r'pkgver=([0-9.]+)'),
]
for file_path, pattern in package_files:
full_path = self.project_root / file_path
if not full_path.exists():
self.warnings.append(f"Package file not found: {file_path}")
continue
with open(full_path, 'r') as f:
content = f.read()
match = re.search(pattern, content)
if match:
found_version = match.group(1)
if found_version != cargo_version:
self.errors.append(f"Version mismatch in {file_path}: found={found_version}, expected={cargo_version}")
all_good = False
else:
self.warnings.append(f"Could not find version in {file_path}")
return all_good
def check_placeholder_hashes(self) -> bool:
"""Check for placeholder SHA256 hashes in package files"""
all_good = True
packaging_dir = self.project_root / "packaging"
if not packaging_dir.exists():
return True
# Find all files that might contain SHA256 hashes
for file_path in packaging_dir.rglob("*"):
if file_path.is_file() and file_path.suffix in ['.rb', '.json', '.yaml', '.yml', '.nuspec']:
with open(file_path, 'r') as f:
content = f.read()
if 'PLACEHOLDER_SHA256' in content:
self.warnings.append(f"Placeholder SHA256 found in {file_path.relative_to(self.project_root)}")
# This is a warning, not an error, as placeholders are expected during development
return all_good
def fix_rust_versions(self, target_version: str) -> bool:
"""Attempt to fix Rust version inconsistencies"""
fixed_any = False
# Fix Cargo.toml
cargo_toml = self.project_root / "Cargo.toml"
if cargo_toml.exists():
with open(cargo_toml, 'r') as f:
content = f.read()
new_content = re.sub(
r'rust-version = "[^"]*"',
f'rust-version = "{target_version}"',
content
)
if new_content != content:
with open(cargo_toml, 'w') as f:
f.write(new_content)
print(f"โ
Fixed Rust version in Cargo.toml to {target_version}")
fixed_any = True
# Fix Dockerfile
dockerfile = self.project_root / "Dockerfile"
if dockerfile.exists():
with open(dockerfile, 'r') as f:
content = f.read()
new_content = re.sub(
r'FROM rust:[0-9.]+',
f'FROM rust:{target_version}',
content
)
if new_content != content:
with open(dockerfile, 'w') as f:
f.write(new_content)
print(f"โ
Fixed Rust version in Dockerfile to {target_version}")
fixed_any = True
return fixed_any
def fix_package_versions(self, target_version: str) -> bool:
"""Regenerate package files with correct version"""
packaging_dir = self.project_root / "packaging"
generate_script = packaging_dir / "generate_packages.py"
if not generate_script.exists():
print("โ Cannot fix package versions: generate_packages.py not found")
return False
import subprocess
try:
result = subprocess.run([
sys.executable, str(generate_script)
], cwd=self.project_root, capture_output=True, text=True)
if result.returncode == 0:
print("โ
Regenerated package files with correct versions")
return True
else:
print(f"โ Failed to regenerate packages: {result.stderr}")
return False
except Exception as e:
print(f"โ Error running package generator: {e}")
return False
def validate_all(self) -> bool:
"""Run all validations"""
print("๐ Validating version consistency across project...")
rust_ok = self.validate_rust_versions()
package_ok = self.validate_package_versions()
self.check_placeholder_hashes() # Only warnings
return rust_ok and package_ok
def print_results(self):
"""Print validation results"""
if self.errors:
print("\nโ Errors found:")
for error in self.errors:
print(f" โข {error}")
if self.warnings:
print("\nโ ๏ธ Warnings:")
for warning in self.warnings:
print(f" โข {warning}")
if not self.errors and not self.warnings:
print("\nโ
All version checks passed!")
elif not self.errors:
print("\nโ
No critical errors found (warnings only)")
def main():
parser = argparse.ArgumentParser(description="Validate version consistency across project")
parser.add_argument("--fix", action="store_true", help="Attempt to fix version inconsistencies")
args = parser.parse_args()
# Find project root
script_dir = Path(__file__).parent.absolute()
project_root = script_dir.parent
validator = VersionValidator(project_root)
# Run validation
is_valid = validator.validate_all()
# Attempt fixes if requested
if args.fix and not is_valid:
print("\n๐ง Attempting to fix version inconsistencies...")
# Get the authoritative version from .mise.toml for Rust
mise_rust = validator.get_mise_rust_version()
cargo_version = validator.get_cargo_version()
if mise_rust:
validator.fix_rust_versions(mise_rust)
if cargo_version:
validator.fix_package_versions(cargo_version)
# Re-validate after fixes
print("\n๐ Re-validating after fixes...")
validator.errors.clear()
validator.warnings.clear()
is_valid = validator.validate_all()
# Print results
validator.print_results()
# Exit with appropriate code
sys.exit(0 if is_valid else 1)
if __name__ == "__main__":
main()
</file>
<file path="src/app/actions.rs">
use crate::app::state::{AppState, PendingAction, Tab};
use crate::error::AppError;
impl AppState {
/// Refresh all data from the broker
pub async fn refresh_data(&mut self) -> Result<(), AppError> {
let (workers_result, tasks_result, queues_result) = {
let broker = self.broker.lock().await;
// Fetch all data in parallel
tokio::join!(
broker.get_workers(),
broker.get_tasks(),
broker.get_queues()
)
};
self.workers = workers_result?;
self.tasks = tasks_result?;
self.queues = queues_result?;
// Validate selections after data refresh
self.validate_selections();
Ok(())
}
/// Execute the pending action (purge queue, retry task, or revoke task)
pub async fn execute_pending_action(&mut self) -> Result<(), AppError> {
if let Some(action) = self.pending_action.take() {
let message = {
let broker = self.broker.lock().await;
match &action {
PendingAction::PurgeQueue(queue_name) => {
match broker.purge_queue(queue_name).await {
Ok(count) => {
format!("Purged {count} messages from queue '{queue_name}'")
}
Err(e) => format!("Failed to purge queue '{queue_name}': {e}"),
}
}
PendingAction::RetryTask(task_id) => match broker.retry_task(task_id).await {
Ok(_) => format!("Task '{task_id}' marked for retry"),
Err(e) => format!("Failed to retry task '{task_id}': {e}"),
},
PendingAction::RevokeTask(task_id) => match broker.revoke_task(task_id).await {
Ok(_) => format!("Task '{task_id}' revoked"),
Err(e) => format!("Failed to revoke task '{task_id}': {e}"),
},
}
};
self.set_status_message(message);
}
self.hide_confirmation_dialog();
Ok(())
}
/// Initiate queue purge action with confirmation dialog
pub fn initiate_purge_queue(&mut self) {
if !self.queues.is_empty() && self.selected_tab == Tab::Queues {
let queue = &self.queues[self.selected_queue];
let message = format!(
"Are you sure you want to purge all {} messages from queue '{}'?",
queue.length, queue.name
);
self.show_confirmation_dialog(message, PendingAction::PurgeQueue(queue.name.clone()));
}
}
/// Initiate task retry action with confirmation dialog
pub fn initiate_retry_task(&mut self) {
if !self.tasks.is_empty() && self.selected_tab == Tab::Tasks {
let filtered_tasks = self.get_filtered_tasks();
if self.selected_task < filtered_tasks.len() {
let task = filtered_tasks[self.selected_task];
let message = format!("Are you sure you want to retry task '{}'?", task.id);
self.show_confirmation_dialog(message, PendingAction::RetryTask(task.id.clone()));
}
}
}
/// Initiate task revoke action with confirmation dialog
pub fn initiate_revoke_task(&mut self) {
if !self.tasks.is_empty() && self.selected_tab == Tab::Tasks {
let filtered_tasks = self.get_filtered_tasks();
if self.selected_task < filtered_tasks.len() {
let task = filtered_tasks[self.selected_task];
let message = format!("Are you sure you want to revoke task '{}'?", task.id);
self.show_confirmation_dialog(message, PendingAction::RevokeTask(task.id.clone()));
}
}
}
}
</file>
<file path="src/app/mod.rs">
//! Application module providing state management and business logic for LazyCelery.
//!
//! This module is organized into separate concerns:
//! - `state`: Core application state, navigation, and UI state management
//! - `actions`: Business logic for broker operations and user actions
mod actions;
mod state;
// Re-export the main types for convenience
pub use state::{AppState, Tab};
// Create a type alias for backward compatibility
pub type App = AppState;
</file>
<file path="src/app/state.rs">
use crate::broker::Broker;
use crate::models::{Queue, Task, Worker};
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Workers,
Queues,
Tasks,
}
#[derive(Debug, Clone)]
pub enum PendingAction {
PurgeQueue(String),
RetryTask(String),
RevokeTask(String),
}
pub struct AppState {
// Data state
pub workers: Vec<Worker>,
pub tasks: Vec<Task>,
pub queues: Vec<Queue>,
// Navigation state
pub selected_tab: Tab,
pub selected_worker: usize,
pub selected_task: usize,
pub selected_queue: usize,
// UI state
pub should_quit: bool,
pub show_help: bool,
pub search_query: String,
pub is_searching: bool,
// Dialog state
pub show_confirmation: bool,
pub confirmation_message: String,
pub pending_action: Option<PendingAction>,
pub status_message: String,
// Task details state
pub show_task_details: bool,
pub selected_task_details: Option<Task>,
// Broker
pub(crate) broker: Arc<Mutex<Box<dyn Broker>>>,
}
impl AppState {
pub fn new(broker: Box<dyn Broker>) -> Self {
Self {
workers: Vec::new(),
tasks: Vec::new(),
queues: Vec::new(),
selected_tab: Tab::Workers,
should_quit: false,
selected_worker: 0,
selected_task: 0,
selected_queue: 0,
show_help: false,
search_query: String::new(),
is_searching: false,
show_confirmation: false,
confirmation_message: String::new(),
pending_action: None,
status_message: String::new(),
show_task_details: false,
selected_task_details: None,
broker: Arc::new(Mutex::new(broker)),
}
}
// Tab navigation
pub fn next_tab(&mut self) {
self.selected_tab = match self.selected_tab {
Tab::Workers => Tab::Queues,
Tab::Queues => Tab::Tasks,
Tab::Tasks => Tab::Workers,
};
}
pub fn previous_tab(&mut self) {
self.selected_tab = match self.selected_tab {
Tab::Workers => Tab::Tasks,
Tab::Queues => Tab::Workers,
Tab::Tasks => Tab::Queues,
};
}
// Item selection
pub fn select_next(&mut self) {
match self.selected_tab {
Tab::Workers => {
if !self.workers.is_empty() {
self.selected_worker = (self.selected_worker + 1) % self.workers.len();
}
}
Tab::Tasks => {
let filtered_count = self.get_filtered_tasks().len();
if filtered_count > 0 {
self.selected_task = (self.selected_task + 1) % filtered_count;
}
}
Tab::Queues => {
if !self.queues.is_empty() {
self.selected_queue = (self.selected_queue + 1) % self.queues.len();
}
}
}
}
pub fn select_previous(&mut self) {
match self.selected_tab {
Tab::Workers => {
if !self.workers.is_empty() {
self.selected_worker = if self.selected_worker == 0 {
self.workers.len() - 1
} else {
self.selected_worker - 1
};
}
}
Tab::Tasks => {
let filtered_count = self.get_filtered_tasks().len();
if filtered_count > 0 {
self.selected_task = if self.selected_task == 0 {
filtered_count - 1
} else {
self.selected_task - 1
};
}
}
Tab::Queues => {
if !self.queues.is_empty() {
self.selected_queue = if self.selected_queue == 0 {
self.queues.len() - 1
} else {
self.selected_queue - 1
};
}
}
}
}
// UI state management
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn start_search(&mut self) {
self.is_searching = true;
self.search_query.clear();
}
pub fn stop_search(&mut self) {
self.is_searching = false;
self.search_query.clear();
// Reset selection when search is cleared
if self.selected_tab == Tab::Tasks {
self.selected_task = 0;
}
}
// Task filtering
pub fn get_filtered_tasks(&self) -> Vec<&Task> {
if self.search_query.is_empty() {
self.tasks.iter().collect()
} else {
self.tasks
.iter()
.filter(|task| {
task.name
.to_lowercase()
.contains(&self.search_query.to_lowercase())
|| task
.id
.to_lowercase()
.contains(&self.search_query.to_lowercase())
})
.collect()
}
}
// Dialog management
pub fn show_confirmation_dialog(&mut self, message: String, action: PendingAction) {
self.confirmation_message = message;
self.pending_action = Some(action);
self.show_confirmation = true;
}
pub fn hide_confirmation_dialog(&mut self) {
self.show_confirmation = false;
self.confirmation_message.clear();
self.pending_action = None;
}
// Status message management
pub fn set_status_message(&mut self, message: String) {
self.status_message = message;
}
pub fn clear_status_message(&mut self) {
self.status_message.clear();
}
// Task details management
pub fn show_task_details(&mut self) {
if !self.tasks.is_empty() && self.selected_tab == Tab::Tasks {
let filtered_tasks = self.get_filtered_tasks();
if self.selected_task < filtered_tasks.len() {
let task = filtered_tasks[self.selected_task];
self.selected_task_details = Some(task.clone());
self.show_task_details = true;
}
}
}
pub fn hide_task_details(&mut self) {
self.show_task_details = false;
self.selected_task_details = None;
}
// Data validation after refresh
pub fn validate_selections(&mut self) {
// Ensure selection indices are valid
if self.selected_worker >= self.workers.len() && !self.workers.is_empty() {
self.selected_worker = self.workers.len() - 1;
}
if self.selected_task >= self.tasks.len() && !self.tasks.is_empty() {
self.selected_task = self.tasks.len() - 1;
}
if self.selected_queue >= self.queues.len() && !self.queues.is_empty() {
self.selected_queue = self.queues.len() - 1;
}
}
}
</file>
<file path="src/broker/redis/protocol/mod.rs">
//! Redis protocol parsing modules
//!
//! This module contains parsers for different Celery protocol data types.
//! Each parser is responsible for parsing a specific type of data from Redis.
mod queue_parser;
mod task_parser;
mod worker_parser;
pub use queue_parser::QueueParser;
pub use task_parser::TaskParser;
pub use worker_parser::WorkerParser;
// Re-export the main ProtocolParser for backward compatibility
use crate::error::BrokerError;
use crate::models::{Queue, Task, Worker};
use redis::aio::MultiplexedConnection;
/// Main protocol parser that delegates to specialized parsers
pub struct ProtocolParser;
impl ProtocolParser {
/// Parse workers from Redis connection
pub async fn parse_workers(
connection: &MultiplexedConnection,
) -> Result<Vec<Worker>, BrokerError> {
WorkerParser::parse_workers(connection).await
}
/// Parse tasks from Redis connection
pub async fn parse_tasks(connection: &MultiplexedConnection) -> Result<Vec<Task>, BrokerError> {
TaskParser::parse_tasks(connection).await
}
/// Parse queues from Redis connection
pub async fn parse_queues(
connection: &MultiplexedConnection,
) -> Result<Vec<Queue>, BrokerError> {
QueueParser::parse_queues(connection).await
}
}
</file>
<file path="src/broker/redis/protocol/queue_parser.rs">
//! Queue parser for Redis Celery protocol
//!
//! This module handles parsing queue information from Redis data structures.
//! It discovers queues from kombu bindings and checks standard queue names
//! to provide information about queue status and message counts.
use crate::error::BrokerError;
use crate::models::Queue;
use redis::aio::MultiplexedConnection;
use redis::AsyncCommands;
use std::collections::HashSet;
/// Parser for queue-related data from Redis
pub struct QueueParser;
impl QueueParser {
/// Parse queues from Redis connection
///
/// Discovers active queues from kombu bindings and standard queue names,
/// then checks their length and consumer information to build a comprehensive
/// view of the queue system.
pub async fn parse_queues(
connection: &MultiplexedConnection,
) -> Result<Vec<Queue>, BrokerError> {
let mut conn = connection.clone();
let mut queues = Vec::new();
let mut discovered_queues = HashSet::new();
// First, discover queues from kombu bindings
let binding_keys: Vec<String> = conn.keys("_kombu.binding.*").await.unwrap_or_default();
for binding_key in binding_keys {
if let Some(queue_name) = binding_key.strip_prefix("_kombu.binding.") {
discovered_queues.insert(queue_name.to_string());
}
}
// Also check for common queue names
let common_queues = vec!["celery", "default", "priority", "high", "low"];
for queue_name in common_queues {
discovered_queues.insert(queue_name.to_string());
}
// Check each discovered queue
for queue_name in discovered_queues {
let length: u64 = conn.llen(&queue_name).await.unwrap_or(0);
// Only include queues that exist (have been used) or are standard
if length > 0 || ["celery", "default"].contains(&queue_name.as_str()) {
// Estimate consumers from worker data (simplified)
let consumers = if length > 0 { 1 } else { 0 }; // Simplified consumer count
queues.push(Queue {
name: queue_name,
length,
consumers,
});
}
}
// Sort queues by name for consistent display
queues.sort_by(|a, b| a.name.cmp(&b.name));
Ok(queues)
}
}
</file>
<file path="src/broker/redis/protocol/worker_parser.rs">
//! Worker parser for Redis Celery protocol
//!
//! This module handles parsing worker information from Redis data structures.
//! It extracts worker statistics, status, and queue assignments from task metadata
//! and queue messages.
use crate::error::BrokerError;
use crate::models::{Worker, WorkerStatus};
use redis::aio::MultiplexedConnection;
use redis::AsyncCommands;
use serde_json::Value;
use std::collections::HashMap;
// Configuration constants for worker parsing
const MAX_TASK_METADATA_KEYS: usize = 500;
const DEFAULT_WORKER_CONCURRENCY: u32 = 16;
/// Parser for worker-related data from Redis
pub struct WorkerParser;
impl WorkerParser {
/// Parse workers from Redis connection
///
/// Extracts worker information from task metadata and queue messages to build
/// a comprehensive view of active workers, their status, and statistics.
pub async fn parse_workers(
connection: &MultiplexedConnection,
) -> Result<Vec<Worker>, BrokerError> {
let mut conn = connection.clone();
let mut worker_stats: HashMap<String, (u64, u64, Vec<String>)> = HashMap::new();
let active_workers: HashMap<String, Vec<String>> = HashMap::new();
// Get task metadata and extract worker information
Self::get_task_metadata(&mut conn, &mut worker_stats).await?;
// Extract worker info from queue messages
Self::extract_worker_info_from_queues(&mut conn, &mut worker_stats).await?;
// Build the final worker list
let mut workers = Self::build_worker_list(worker_stats, active_workers);
// Handle case where no workers are detected
Self::ensure_default_worker_if_needed(&mut conn, &mut workers).await?;
Ok(workers)
}
/// Extract worker statistics from task metadata
///
/// Processes completed task metadata to extract worker performance statistics
/// including processed and failed task counts.
async fn get_task_metadata(
conn: &mut MultiplexedConnection,
worker_stats: &mut HashMap<String, (u64, u64, Vec<String>)>,
) -> Result<(), BrokerError> {
let task_keys: Vec<String> = conn.keys("celery-task-meta-*").await.map_err(|e| {
BrokerError::OperationError(format!("Failed to get task metadata keys: {e}"))
})?;
for key in task_keys.iter().take(MAX_TASK_METADATA_KEYS) {
match conn.get::<_, String>(key).await {
Ok(data) => {
match serde_json::from_str::<Value>(&data) {
Ok(task_data) => {
let status = task_data
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("UNKNOWN");
// For completed tasks, we don't have hostname in metadata
// So we'll create a generic worker based on activity
let hostname = "celery-worker".to_string();
let (processed, failed, queues) =
worker_stats.entry(hostname).or_insert((0, 0, Vec::new()));
match status {
"SUCCESS" => *processed += 1,
"FAILURE" => *failed += 1,
_ => {}
}
// Add default queue
if !queues.contains(&"celery".to_string()) {
queues.push("celery".to_string());
}
}
Err(_) => {
// Skip malformed task data - log error but continue processing
continue;
}
}
}
Err(_) => {
// Skip inaccessible keys - continue processing other tasks
continue;
}
}
}
Ok(())
}
/// Extract worker information from queue messages
///
/// Analyzes pending tasks in queues to identify worker hostnames and
/// associated queue assignments.
async fn extract_worker_info_from_queues(
conn: &mut MultiplexedConnection,
worker_stats: &mut HashMap<String, (u64, u64, Vec<String>)>,
) -> Result<(), BrokerError> {
let queue_names = vec!["celery", "default", "priority"];
for queue_name in queue_names {
match conn.llen::<_, u64>(queue_name).await {
Ok(queue_length) if queue_length > 0 => {
match conn.lrange::<_, Vec<String>>(queue_name, 0, 5).await {
Ok(messages) => {
for message in &messages {
if let Ok(task_message) = serde_json::from_str::<Value>(message) {
if let Some(hostname) =
Self::extract_hostname_from_message(&task_message)
{
let (_processed, _failed, queues) = worker_stats
.entry(hostname)
.or_insert((0, 0, Vec::new()));
if !queues.contains(&queue_name.to_string()) {
queues.push(queue_name.to_string());
}
}
}
}
}
Err(_) => {
// Skip queue if we can't read messages - continue with other queues
continue;
}
}
}
_ => {
// Skip empty or inaccessible queues
continue;
}
}
}
Ok(())
}
/// Extract hostname from a task message
///
/// Parses the 'origin' field from task headers to extract the worker hostname.
/// Handles various origin formats like "gen447152@archflowx13".
fn extract_hostname_from_message(task_message: &Value) -> Option<String> {
task_message
.get("headers")
.and_then(|headers| headers.get("origin"))
.and_then(|origin| origin.as_str())
.map(|origin| {
// Extract hostname from origin like "gen447152@archflowx13"
if let Some(at_pos) = origin.find('@') {
origin[at_pos + 1..].to_string()
} else {
origin.to_string()
}
})
}
/// Build the final worker list from collected statistics
///
/// Converts raw worker statistics into Worker structs with appropriate
/// status determination and queue assignments.
fn build_worker_list(
worker_stats: HashMap<String, (u64, u64, Vec<String>)>,
active_workers: HashMap<String, Vec<String>>,
) -> Vec<Worker> {
let mut workers = Vec::new();
for (hostname, (processed, failed, queues)) in worker_stats {
let active_tasks = active_workers.get(&hostname).cloned().unwrap_or_default();
// Determine worker status - if we have recent task data, assume online
let status = if processed > 0 || failed > 0 {
WorkerStatus::Online
} else {
WorkerStatus::Offline
};
workers.push(Worker {
hostname,
status,
concurrency: DEFAULT_WORKER_CONCURRENCY,
queues: if queues.is_empty() {
vec!["celery".to_string()]
} else {
queues
},
active_tasks,
processed,
failed,
});
}
workers
}
/// Ensure at least one worker exists if activity is detected
///
/// Creates a default worker when no specific workers are found but
/// there is evidence of Celery activity (pending tasks or completed tasks).
async fn ensure_default_worker_if_needed(
conn: &mut MultiplexedConnection,
workers: &mut Vec<Worker>,
) -> Result<(), BrokerError> {
if workers.is_empty() {
let celery_queue_len: u64 = conn.llen("celery").await.unwrap_or(0);
let task_keys: Vec<String> = conn.keys("celery-task-meta-*").await.map_err(|e| {
BrokerError::OperationError(format!("Failed to check for task metadata keys: {e}"))
})?;
let task_count = task_keys.len();
if celery_queue_len > 0 || task_count > 0 {
// There is activity, so assume a worker exists
workers.push(Worker {
hostname: "detected-worker".to_string(),
status: if celery_queue_len > 0 {
WorkerStatus::Offline
} else {
WorkerStatus::Online
},
concurrency: DEFAULT_WORKER_CONCURRENCY,
queues: vec!["celery".to_string()],
active_tasks: vec![],
processed: task_count as u64,
failed: 0,
});
}
}
Ok(())
}
}
</file>
<file path="src/ui/widgets/base.rs">
use crate::app::App;
use ratatui::{layout::Rect, Frame};
/// Common trait for all UI widgets that display data with list and details sections
pub trait Widget {
/// Draw the complete widget with its list and details sections
fn draw(f: &mut Frame, app: &App, area: Rect);
/// Draw the list section (left or top panel)
fn draw_list(f: &mut Frame, app: &App, area: Rect);
/// Draw the details section (right or bottom panel)
fn draw_details(f: &mut Frame, app: &App, area: Rect);
}
/// Common helper functions for widget styling and layout
pub mod helpers {
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph},
};
/// Create a standard selection style for highlighted items
pub fn selection_style() -> Style {
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
}
/// Create a standard block with borders and title
pub fn titled_block(title: &str) -> Block {
Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "))
}
/// Create a standard "no data" message
pub fn no_data_message(item_type: &str) -> Paragraph {
let message = format!("No {item_type} found");
let title = format!("{item_type} Details");
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.border_type(BorderType::Rounded)
.title(format!(" {title} "));
Paragraph::new(message).block(block)
}
/// Create a colored status indicator line
pub fn status_line(label: &str, value: &str, color: Color) -> Line<'static> {
Line::from(vec![
Span::raw(format!("{label}: ")),
Span::styled(value.to_string(), Style::default().fg(color)),
])
}
/// Create a field line with label and value
pub fn field_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::raw(format!("{label}: ")),
Span::raw(value.to_string()),
])
}
/// Create a highlighted field line (for important values)
pub fn highlighted_field_line(label: &str, value: &str, color: Color) -> Line<'static> {
Line::from(vec![
Span::raw(format!("{label}: ")),
Span::styled(value.to_string(), Style::default().fg(color)),
])
}
}
</file>
<file path="src/ui/layout.rs">
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Tabs},
Frame,
};
use crate::app::{App, Tab};
/// Draw the header section with tab navigation
pub fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let titles = vec!["Workers", "Queues", "Tasks"];
let selected = match app.selected_tab {
Tab::Workers => 0,
Tab::Queues => 1,
Tab::Tasks => 2,
};
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.title(" LazyCelery v0.4.0 "),
)
.select(selected)
.style(Style::default().fg(Color::Cyan))
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Black),
);
f.render_widget(tabs, area);
}
/// Draw the status bar with information and key hints
pub fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
let status_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Left side - general info or status message
let status_left = if !app.status_message.is_empty() {
app.status_message.clone()
} else if app.is_searching {
format!("Search: {}_", app.search_query)
} else {
format!(
"Workers: {} | Tasks: {} | Queues: {}",
app.workers.len(),
app.tasks.len(),
app.queues.len()
)
};
let status_left_widget = Block::default()
.borders(Borders::ALL)
.title(Span::raw(status_left));
f.render_widget(status_left_widget, status_chunks[0]);
// Right side - key hints
let key_hints = get_key_hints(app);
let status_right_widget = Block::default()
.borders(Borders::ALL)
.title(Span::raw(key_hints));
f.render_widget(status_right_widget, status_chunks[1]);
}
/// Get appropriate key hints based on current application state
fn get_key_hints(app: &App) -> &'static str {
if app.show_confirmation {
"[y/Enter] Confirm | [n/Esc] Cancel"
} else if app.show_task_details {
"[Any key] Close details"
} else if app.is_searching {
"[Enter] Confirm | [Esc] Cancel"
} else {
match app.selected_tab {
Tab::Queues => "[Tab] Switch | [โโ] Navigate | [p] Purge | [/] Search | [?] Help | [q] Quit",
Tab::Tasks => "[Tab] Switch | [โโ] Navigate | [Enter/d] Details | [r] Retry | [x] Revoke | [/] Search | [?] Help | [q] Quit",
_ => "[Tab] Switch | [โโ] Navigate | [/] Search | [?] Help | [q] Quit",
}
}
}
/// Create the main application layout with header, content, and status bar
pub fn create_main_layout(area: Rect) -> Vec<Rect> {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Main content
Constraint::Length(3), // Status bar
])
.split(area)
.to_vec()
}
/// Create a centered rectangle for modal dialogs
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
</file>
<file path="src/utils/mod.rs">
pub mod formatting;
</file>
<file path="tests/test_app_actions.rs">
use lazycelery::app::{AppState, Tab};
use lazycelery::models::{Queue, Task, TaskStatus, Worker, WorkerStatus};
mod test_broker_utils;
use test_broker_utils::MockBrokerBuilder;
#[tokio::test]
async fn test_refresh_data_success() {
let test_workers = vec![Worker {
hostname: "test-host".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec!["default".to_string()],
active_tasks: vec!["task1".to_string(), "task2".to_string()],
processed: 100,
failed: 5,
}];
let test_tasks = vec![Task {
id: "task1".to_string(),
name: "test.task".to_string(),
status: TaskStatus::Success,
worker: Some("worker1".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: Some("OK".to_string()),
traceback: None,
}];
let test_queues = vec![Queue {
name: "default".to_string(),
length: 5,
consumers: 2,
}];
let broker = MockBrokerBuilder::new()
.with_workers(test_workers.clone())
.with_tasks(test_tasks.clone())
.with_queues(test_queues.clone())
.build();
let mut app_state = AppState::new(broker);
// Verify initial state is empty
assert!(app_state.workers.is_empty());
assert!(app_state.tasks.is_empty());
assert!(app_state.queues.is_empty());
// Refresh data
let result = app_state.refresh_data().await;
assert!(result.is_ok());
// Verify data was loaded
assert_eq!(app_state.workers.len(), 1);
assert_eq!(app_state.tasks.len(), 1);
assert_eq!(app_state.queues.len(), 1);
assert_eq!(app_state.workers[0].hostname, "test-host");
assert_eq!(app_state.tasks[0].id, "task1");
assert_eq!(app_state.queues[0].name, "default");
}
#[tokio::test]
async fn test_refresh_data_selections_validation() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// Set selections beyond bounds
app_state.selected_worker = 10;
app_state.selected_task = 10;
app_state.selected_queue = 10;
let result = app_state.refresh_data().await;
assert!(result.is_ok());
// Selections should remain unchanged when no data is available
// (validation only acts when data exists but selection is out of bounds)
assert_eq!(app_state.selected_worker, 10);
assert_eq!(app_state.selected_task, 10);
assert_eq!(app_state.selected_queue, 10);
}
#[tokio::test]
async fn test_execute_pending_action_purge_queue() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// Add a queue and initiate purge action
app_state.queues = vec![Queue {
name: "test_queue".to_string(),
length: 10,
consumers: 1,
}];
app_state.selected_tab = Tab::Queues;
app_state.selected_queue = 0;
// Initiate purge action
app_state.initiate_purge_queue();
// Should show confirmation dialog
assert!(app_state.show_confirmation);
assert!(app_state.pending_action.is_some());
// Execute the pending action
let result = app_state.execute_pending_action().await;
assert!(result.is_ok());
// Pending action should be cleared
assert!(app_state.pending_action.is_none());
// Confirmation dialog should be hidden
assert!(!app_state.show_confirmation);
// Status message should be set
assert!(!app_state.status_message.is_empty());
assert!(app_state.status_message.contains("test_queue"));
}
#[tokio::test]
async fn test_execute_pending_action_retry_task() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// Add a task and initiate retry action
app_state.tasks = vec![Task {
id: "task123".to_string(),
name: "test.task".to_string(),
status: TaskStatus::Failure,
worker: Some("worker1".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: None,
traceback: Some("Error".to_string()),
}];
app_state.selected_tab = Tab::Tasks;
app_state.selected_task = 0;
// Initiate retry action
app_state.initiate_retry_task();
// Should show confirmation dialog
assert!(app_state.show_confirmation);
assert!(app_state.pending_action.is_some());
// Execute the pending action
let result = app_state.execute_pending_action().await;
assert!(result.is_ok());
assert!(app_state.pending_action.is_none());
assert!(!app_state.show_confirmation);
assert!(!app_state.status_message.is_empty());
assert!(app_state.status_message.contains("task123"));
assert!(app_state.status_message.contains("retry"));
}
#[tokio::test]
async fn test_execute_pending_action_revoke_task() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// Add a task and initiate revoke action
app_state.tasks = vec![Task {
id: "task456".to_string(),
name: "test.task".to_string(),
status: TaskStatus::Active,
worker: Some("worker1".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: None,
traceback: None,
}];
app_state.selected_tab = Tab::Tasks;
app_state.selected_task = 0;
// Initiate revoke action
app_state.initiate_revoke_task();
// Should show confirmation dialog
assert!(app_state.show_confirmation);
assert!(app_state.pending_action.is_some());
// Execute the pending action
let result = app_state.execute_pending_action().await;
assert!(result.is_ok());
assert!(app_state.pending_action.is_none());
assert!(!app_state.show_confirmation);
assert!(!app_state.status_message.is_empty());
assert!(app_state.status_message.contains("task456"));
assert!(app_state.status_message.contains("revoked"));
}
#[tokio::test]
async fn test_execute_pending_action_no_action() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// No pending action
assert!(app_state.pending_action.is_none());
let result = app_state.execute_pending_action().await;
assert!(result.is_ok());
// State should remain unchanged
assert!(app_state.pending_action.is_none());
assert!(!app_state.show_confirmation);
}
#[test]
fn test_initiate_purge_queue() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// Add a queue
app_state.queues = vec![Queue {
name: "celery".to_string(),
length: 42,
consumers: 3,
}];
app_state.selected_tab = Tab::Queues;
app_state.selected_queue = 0;
app_state.initiate_purge_queue();
// Confirmation dialog should be shown
assert!(app_state.show_confirmation);
assert!(!app_state.confirmation_message.is_empty());
assert!(app_state.confirmation_message.contains("celery"));
assert!(app_state.confirmation_message.contains("42"));
// Pending action should be set
assert!(app_state.pending_action.is_some());
}
#[test]
fn test_initiate_purge_queue_wrong_tab() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
app_state.queues = vec![Queue {
name: "test".to_string(),
length: 1,
consumers: 1,
}];
app_state.selected_tab = Tab::Workers; // Wrong tab
app_state.initiate_purge_queue();
// Should not initiate purge
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
}
#[test]
fn test_initiate_purge_queue_no_queues() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
app_state.selected_tab = Tab::Queues;
// No queues available
app_state.initiate_purge_queue();
// Should not initiate purge
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
}
#[test]
fn test_initiate_retry_task() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// Add a task
app_state.tasks = vec![Task {
id: "retry-task".to_string(),
name: "test.retry".to_string(),
status: TaskStatus::Failure,
worker: Some("worker1".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: None,
traceback: Some("Error occurred".to_string()),
}];
app_state.selected_tab = Tab::Tasks;
app_state.selected_task = 0;
app_state.initiate_retry_task();
// Confirmation dialog should be shown
assert!(app_state.show_confirmation);
assert!(!app_state.confirmation_message.is_empty());
assert!(app_state.confirmation_message.contains("retry-task"));
assert!(app_state.confirmation_message.contains("retry"));
// Pending action should be set
assert!(app_state.pending_action.is_some());
}
#[test]
fn test_initiate_revoke_task() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
// Add a task
app_state.tasks = vec![Task {
id: "revoke-task".to_string(),
name: "test.revoke".to_string(),
status: TaskStatus::Active,
worker: Some("worker1".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: None,
traceback: None,
}];
app_state.selected_tab = Tab::Tasks;
app_state.selected_task = 0;
app_state.initiate_revoke_task();
// Confirmation dialog should be shown
assert!(app_state.show_confirmation);
assert!(!app_state.confirmation_message.is_empty());
assert!(app_state.confirmation_message.contains("revoke-task"));
assert!(app_state.confirmation_message.contains("revoke"));
// Pending action should be set
assert!(app_state.pending_action.is_some());
}
#[test]
fn test_initiate_task_actions_wrong_tab() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
app_state.tasks = vec![Task {
id: "test-task".to_string(),
name: "test".to_string(),
status: TaskStatus::Active,
worker: None,
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: None,
traceback: None,
}];
app_state.selected_tab = Tab::Workers; // Wrong tab
app_state.initiate_retry_task();
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
app_state.initiate_revoke_task();
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
}
#[test]
fn test_initiate_task_actions_no_tasks() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
app_state.selected_tab = Tab::Tasks;
// No tasks available
app_state.initiate_retry_task();
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
app_state.initiate_revoke_task();
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
}
#[test]
fn test_initiate_task_actions_out_of_bounds() {
let broker = MockBrokerBuilder::empty().build();
let mut app_state = AppState::new(broker);
app_state.tasks = vec![Task {
id: "single-task".to_string(),
name: "test".to_string(),
status: TaskStatus::Active,
worker: None,
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: None,
traceback: None,
}];
app_state.selected_tab = Tab::Tasks;
app_state.selected_task = 5; // Out of bounds
app_state.initiate_retry_task();
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
app_state.initiate_revoke_task();
assert!(!app_state.show_confirmation);
assert!(app_state.pending_action.is_none());
}
</file>
<file path="tests/test_broker_utils.rs">
//! Shared test broker utilities
//!
//! This module provides a consolidated MockBroker implementation with a fluent builder API
//! to replace the 3 duplicated implementations across test files. It follows the excellent
//! pattern established in `redis_test_utils.rs`.
use async_trait::async_trait;
use chrono::Utc;
use lazycelery::broker::Broker;
use lazycelery::error::BrokerError;
use lazycelery::models::{Queue, Task, TaskStatus, Worker, WorkerStatus};
/// Builder for configurable mock broker instances
#[derive(Default)]
pub struct MockBrokerBuilder {
workers: Vec<Worker>,
tasks: Vec<Task>,
queues: Vec<Queue>,
should_fail_operations: bool,
should_return_not_implemented: bool,
}
impl MockBrokerBuilder {
/// Create a new empty broker builder
pub fn new() -> Self {
Self::default()
}
/// Create a broker that returns empty collections (for UI navigation tests)
pub fn empty() -> Self {
Self::new()
}
/// Create a broker with basic test data (for app state tests)
pub fn with_basic_data() -> Self {
Self::new()
.with_workers(vec![
Worker {
hostname: "test-worker-1".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec!["default".to_string()],
active_tasks: vec!["task-1".to_string()],
processed: 100,
failed: 5,
},
Worker {
hostname: "test-worker-2".to_string(),
status: WorkerStatus::Offline,
concurrency: 2,
queues: vec!["priority".to_string()],
active_tasks: vec![],
processed: 50,
failed: 2,
},
])
.with_tasks(vec![
Task {
id: "task-1".to_string(),
name: "test.task.example".to_string(),
args: r#"["arg1", "arg2"]"#.to_string(),
kwargs: r#"{"key": "value"}"#.to_string(),
status: TaskStatus::Active,
worker: Some("test-worker-1".to_string()),
timestamp: Utc::now(),
result: None,
traceback: None,
},
Task {
id: "task-2".to_string(),
name: "test.task.completed".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Success,
worker: Some("test-worker-1".to_string()),
timestamp: Utc::now() - chrono::Duration::minutes(5),
result: Some(r#"{"result": "success"}"#.to_string()),
traceback: None,
},
])
.with_queues(vec![
Queue {
name: "default".to_string(),
length: 10,
consumers: 2,
},
Queue {
name: "priority".to_string(),
length: 5,
consumers: 1,
},
])
}
/// Create a broker with realistic integration test data
pub fn with_integration_data() -> Self {
Self::new()
.with_workers(vec![
Worker {
hostname: "celery@worker-prod-1".to_string(),
status: WorkerStatus::Online,
concurrency: 8,
queues: vec![
"default".to_string(),
"priority".to_string(),
"emails".to_string(),
],
active_tasks: vec!["task-001".to_string(), "task-002".to_string()],
processed: 15234,
failed: 23,
},
Worker {
hostname: "celery@worker-prod-2".to_string(),
status: WorkerStatus::Online,
concurrency: 8,
queues: vec!["default".to_string(), "priority".to_string()],
active_tasks: vec![],
processed: 14892,
failed: 19,
},
Worker {
hostname: "celery@worker-prod-3".to_string(),
status: WorkerStatus::Offline,
concurrency: 4,
queues: vec!["background".to_string()],
active_tasks: vec![],
processed: 8923,
failed: 5,
},
])
.with_tasks(vec![
Task {
id: "task-001".to_string(),
name: "app.tasks.send_welcome_email".to_string(),
args: r#"["user@example.com"]"#.to_string(),
kwargs: r#"{"template": "welcome"}"#.to_string(),
status: TaskStatus::Active,
worker: Some("celery@worker-prod-1".to_string()),
timestamp: Utc::now() - chrono::Duration::minutes(2),
result: None,
traceback: None,
},
Task {
id: "task-002".to_string(),
name: "app.tasks.process_payment".to_string(),
args: r#"[100.50, "USD"]"#.to_string(),
kwargs: r#"{"user_id": 12345}"#.to_string(),
status: TaskStatus::Active,
worker: Some("celery@worker-prod-1".to_string()),
timestamp: Utc::now() - chrono::Duration::seconds(30),
result: None,
traceback: None,
},
Task {
id: "task-003".to_string(),
name: "app.tasks.generate_report".to_string(),
args: "[]".to_string(),
kwargs: r#"{"report_type": "monthly", "month": 12}"#.to_string(),
status: TaskStatus::Success,
worker: Some("celery@worker-prod-2".to_string()),
timestamp: Utc::now() - chrono::Duration::hours(1),
result: Some(r#"{"status": "completed", "rows": 1523}"#.to_string()),
traceback: None,
},
Task {
id: "task-004".to_string(),
name: "app.tasks.sync_inventory".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Failure,
worker: Some("celery@worker-prod-2".to_string()),
timestamp: Utc::now() - chrono::Duration::minutes(15),
result: None,
traceback: Some("Traceback (most recent call last):\n File \"tasks.py\", line 45\n ConnectionError: Database timeout".to_string()),
},
Task {
id: "task-005".to_string(),
name: "app.tasks.cleanup_temp_files".to_string(),
args: "[]".to_string(),
kwargs: r#"{"older_than": "1d"}"#.to_string(),
status: TaskStatus::Pending,
worker: None,
timestamp: Utc::now(),
result: None,
traceback: None,
},
])
.with_queues(vec![
Queue {
name: "default".to_string(),
length: 42,
consumers: 3,
},
Queue {
name: "priority".to_string(),
length: 8,
consumers: 2,
},
Queue {
name: "emails".to_string(),
length: 15,
consumers: 1,
},
Queue {
name: "background".to_string(),
length: 0,
consumers: 0,
},
])
}
/// Add custom workers to the broker
pub fn with_workers(mut self, workers: Vec<Worker>) -> Self {
self.workers = workers;
self
}
/// Add custom tasks to the broker
pub fn with_tasks(mut self, tasks: Vec<Task>) -> Self {
self.tasks = tasks;
self
}
/// Add custom queues to the broker
pub fn with_queues(mut self, queues: Vec<Queue>) -> Self {
self.queues = queues;
self
}
/// Configure broker to fail all operations (for error testing)
pub fn with_failing_operations(mut self) -> Self {
self.should_fail_operations = true;
self
}
/// Configure broker to return NotImplemented for operations (for UI tests)
pub fn with_not_implemented_operations(mut self) -> Self {
self.should_return_not_implemented = true;
self
}
/// Build the configured mock broker
pub fn build(self) -> Box<dyn Broker> {
Box::new(MockBroker {
workers: self.workers,
tasks: self.tasks,
queues: self.queues,
should_fail_operations: self.should_fail_operations,
should_return_not_implemented: self.should_return_not_implemented,
})
}
}
/// Mock broker implementation with configurable behavior
struct MockBroker {
workers: Vec<Worker>,
tasks: Vec<Task>,
queues: Vec<Queue>,
should_fail_operations: bool,
should_return_not_implemented: bool,
}
#[async_trait]
impl Broker for MockBroker {
async fn connect(_url: &str) -> Result<Self, BrokerError> {
// This should not be called directly - use MockBrokerBuilder instead
Ok(MockBroker {
workers: vec![],
tasks: vec![],
queues: vec![],
should_fail_operations: false,
should_return_not_implemented: false,
})
}
async fn get_workers(&self) -> Result<Vec<Worker>, BrokerError> {
if self.should_fail_operations {
return Err(BrokerError::ConnectionError(
"Simulated failure".to_string(),
));
}
Ok(self.workers.clone())
}
async fn get_tasks(&self) -> Result<Vec<Task>, BrokerError> {
if self.should_fail_operations {
return Err(BrokerError::ConnectionError(
"Simulated failure".to_string(),
));
}
Ok(self.tasks.clone())
}
async fn get_queues(&self) -> Result<Vec<Queue>, BrokerError> {
if self.should_fail_operations {
return Err(BrokerError::ConnectionError(
"Simulated failure".to_string(),
));
}
Ok(self.queues.clone())
}
async fn retry_task(&self, _task_id: &str) -> Result<(), BrokerError> {
if self.should_fail_operations {
return Err(BrokerError::OperationError("Retry failed".to_string()));
}
if self.should_return_not_implemented {
return Err(BrokerError::NotImplemented);
}
Ok(())
}
async fn revoke_task(&self, _task_id: &str) -> Result<(), BrokerError> {
if self.should_fail_operations {
return Err(BrokerError::OperationError("Revoke failed".to_string()));
}
if self.should_return_not_implemented {
return Err(BrokerError::NotImplemented);
}
Ok(())
}
async fn purge_queue(&self, _queue_name: &str) -> Result<u64, BrokerError> {
if self.should_fail_operations {
return Err(BrokerError::OperationError("Purge failed".to_string()));
}
if self.should_return_not_implemented {
return Err(BrokerError::NotImplemented);
}
// Return simulated purge count
Ok(42)
}
}
/// Helper functions for common test scenarios
impl MockBrokerBuilder {
/// Create a broker for UI navigation tests (empty data, NotImplemented operations)
pub fn for_ui_tests() -> Box<dyn Broker> {
Self::empty().with_not_implemented_operations().build()
}
/// Create a broker for app state tests (basic data, working operations)
pub fn for_app_tests() -> Box<dyn Broker> {
Self::with_basic_data().build()
}
/// Create a broker for integration tests (realistic data, working operations)
pub fn for_integration_tests() -> Box<dyn Broker> {
Self::with_integration_data().build()
}
/// Create a broker for error testing (empty data, failing operations)
pub fn for_error_tests() -> Box<dyn Broker> {
Self::empty().with_failing_operations().build()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_empty_broker() {
let broker = MockBrokerBuilder::empty().build();
let workers = broker.get_workers().await.unwrap();
let tasks = broker.get_tasks().await.unwrap();
let queues = broker.get_queues().await.unwrap();
assert!(workers.is_empty());
assert!(tasks.is_empty());
assert!(queues.is_empty());
}
#[tokio::test]
async fn test_basic_data_broker() {
let broker = MockBrokerBuilder::with_basic_data().build();
let workers = broker.get_workers().await.unwrap();
let tasks = broker.get_tasks().await.unwrap();
let queues = broker.get_queues().await.unwrap();
assert_eq!(workers.len(), 2);
assert_eq!(tasks.len(), 2);
assert_eq!(queues.len(), 2);
assert_eq!(workers[0].hostname, "test-worker-1");
assert_eq!(tasks[0].id, "task-1");
assert_eq!(queues[0].name, "default");
}
#[tokio::test]
async fn test_integration_data_broker() {
let broker = MockBrokerBuilder::with_integration_data().build();
let workers = broker.get_workers().await.unwrap();
let tasks = broker.get_tasks().await.unwrap();
let queues = broker.get_queues().await.unwrap();
assert_eq!(workers.len(), 3);
assert_eq!(tasks.len(), 5);
assert_eq!(queues.len(), 4);
assert_eq!(workers[0].hostname, "celery@worker-prod-1");
assert_eq!(tasks[0].id, "task-001");
assert_eq!(queues[0].name, "default");
}
#[tokio::test]
async fn test_not_implemented_operations() {
let broker = MockBrokerBuilder::empty()
.with_not_implemented_operations()
.build();
let result = broker.retry_task("test-task").await;
assert!(matches!(result, Err(BrokerError::NotImplemented)));
}
#[tokio::test]
async fn test_failing_operations() {
let broker = MockBrokerBuilder::empty().with_failing_operations().build();
let result = broker.retry_task("test-task").await;
assert!(matches!(result, Err(BrokerError::OperationError(_))));
}
#[tokio::test]
async fn test_convenience_constructors() {
let ui_broker = MockBrokerBuilder::for_ui_tests();
let app_broker = MockBrokerBuilder::for_app_tests();
let integration_broker = MockBrokerBuilder::for_integration_tests();
let error_broker = MockBrokerBuilder::for_error_tests();
// Test that UI broker returns NotImplemented
let result = ui_broker.retry_task("test").await;
assert!(matches!(result, Err(BrokerError::NotImplemented)));
// Test that app broker has data
let workers = app_broker.get_workers().await.unwrap();
assert_eq!(workers.len(), 2);
// Test that integration broker has realistic data
let tasks = integration_broker.get_tasks().await.unwrap();
assert_eq!(tasks.len(), 5);
// Test that error broker fails operations
let result = error_broker.retry_task("test").await;
assert!(matches!(result, Err(BrokerError::OperationError(_))));
}
}
</file>
<file path="tests/test_complete_integration.rs">
#![allow(clippy::uninlined_format_args)]
use anyhow::Result;
use base64::Engine;
use lazycelery::broker::{redis::RedisBroker, Broker};
use lazycelery::models::TaskStatus;
use redis::{AsyncCommands, Client};
use serde_json::json;
/// Teste de integraรงรฃo completo que simula um ambiente Celery real
#[tokio::test]
async fn test_complete_celery_workflow() -> Result<()> {
let client = match Client::open("redis://127.0.0.1:6379") {
Ok(client) => client,
Err(_) => {
eprintln!("Skipping integration test: Redis not available");
return Ok(());
}
};
let mut conn = match client.get_multiplexed_tokio_connection().await {
Ok(conn) => conn,
Err(_) => {
eprintln!("Skipping integration test: Redis connection failed");
return Ok(());
}
};
// Usar database 2 para isolamento completo
let _: () = redis::cmd("SELECT").arg(2).query_async(&mut conn).await?;
let _: () = redis::cmd("FLUSHDB").query_async(&mut conn).await?;
// === CENรRIO 1: Simular um ambiente de produรงรฃo tรญpico ===
// 1. Adicionar tarefas pendentes na fila (como um produtor real)
let pending_tasks = vec![
(
"urgent_processing",
"high_priority",
"[1000, 2000]",
r#"{"timeout": 60}"#,
),
(
"data_analysis",
"analytics",
"[\"dataset_001\"]",
r#"{"format": "json"}"#,
),
(
"email_notification",
"notifications",
r#"["user@example.com"]"#,
r#"{"template": "welcome"}"#,
),
];
for (task_name, queue, args, kwargs) in pending_tasks {
let task_id = format!("pending-{}-{}", task_name, chrono::Utc::now().timestamp());
let task_body = json!([
serde_json::from_str::<serde_json::Value>(args)?,
serde_json::from_str::<serde_json::Value>(kwargs)?,
{}
]);
let encoded_body = base64::engine::general_purpose::STANDARD.encode(task_body.to_string());
let task_message = json!({
"body": encoded_body,
"content-encoding": "utf-8",
"content-type": "application/json",
"headers": {
"lang": "py",
"task": format!("myapp.tasks.{}", task_name),
"id": task_id,
"retries": 0,
"origin": format!("worker@prod-server-{}", queue),
"argsrepr": args,
"kwargsrepr": kwargs
}
});
let _: () = conn.lpush(queue, task_message.to_string()).await?;
}
// 2. Adicionar histรณrico de tarefas processadas
let processed_tasks = vec![
(
"task-001",
"SUCCESS",
"\"Processing completed successfully\"",
None,
),
(
"task-002",
"FAILURE",
"null",
Some("Traceback: ValueError: Invalid input data"),
),
("task-003", "SUCCESS", "42", None),
("task-004", "RETRY", "null", Some("Temporary network error")),
("task-005", "REVOKED", "null", None),
];
for (task_id, status, result, traceback) in processed_tasks {
let task_metadata = json!({
"status": status,
"result": serde_json::from_str::<serde_json::Value>(result)?,
"traceback": traceback,
"children": [],
"date_done": chrono::Utc::now().to_rfc3339(),
"task_id": task_id
});
let _: () = conn
.set(
format!("celery-task-meta-{}", task_id),
task_metadata.to_string(),
)
.await?;
}
// 3. Adicionar bindings e configuraรงรตes do Celery
let _: () = conn.set("_kombu.binding.high_priority", "").await?;
let _: () = conn.set("_kombu.binding.analytics", "").await?;
let _: () = conn.set("_kombu.binding.notifications", "").await?;
let _: () = conn.set("_kombu.binding.celery", "").await?;
// 4. Adicionar tarefas revogadas
let _: () = conn.sadd("revoked", "task-005").await?;
let _: () = conn.sadd("revoked", "old-task-123").await?;
// === FASE DE TESTES ===
let broker = RedisBroker::connect("redis://127.0.0.1:6379/2").await?;
// Teste 1: Descoberta de Workers
println!("=== Teste 1: Descoberta de Workers ===");
let workers = broker.get_workers().await?;
assert!(
!workers.is_empty(),
"Should discover workers from task activity"
);
for worker in &workers {
println!(
"Worker: {} (Status: {:?}, Processed: {}, Failed: {})",
worker.hostname, worker.status, worker.processed, worker.failed
);
assert!(!worker.hostname.is_empty(), "Worker should have hostname");
assert!(
worker.concurrency > 0,
"Worker should have positive concurrency"
);
assert!(
!worker.queues.is_empty(),
"Worker should have at least one queue"
);
}
// Teste 2: Parsing de Tarefas
println!("\n=== Teste 2: Parsing de Tarefas ===");
let tasks = broker.get_tasks().await?;
// Deve encontrar tarefas dos metadados + filas (limitado a 100 pela implementaรงรฃo)
assert!(
tasks.len() >= 5,
"Should find processed tasks + pending tasks, found: {}",
tasks.len()
);
// Verificar tipos de status
let success_count = tasks
.iter()
.filter(|t| t.status == TaskStatus::Success)
.count();
let failure_count = tasks
.iter()
.filter(|t| t.status == TaskStatus::Failure)
.count();
let pending_count = tasks
.iter()
.filter(|t| t.status == TaskStatus::Pending)
.count();
let retry_count = tasks
.iter()
.filter(|t| t.status == TaskStatus::Retry)
.count();
let revoked_count = tasks
.iter()
.filter(|t| t.status == TaskStatus::Revoked)
.count();
println!("Task Status Distribution:");
println!(" Success: {}", success_count);
println!(" Failure: {}", failure_count);
println!(" Pending: {}", pending_count);
println!(" Retry: {}", retry_count);
println!(" Revoked: {}", revoked_count);
assert!(success_count >= 2, "Should have successful tasks");
assert!(failure_count >= 1, "Should have failed tasks");
// Note: Pending tasks from queue may not always be parsed depending on implementation
assert!(
pending_count + retry_count + revoked_count >= 1,
"Should have some non-completed tasks"
);
// Teste 3: Descoberta de Filas
println!("\n=== Teste 3: Descoberta de Filas ===");
let queues = broker.get_queues().await?;
assert!(!queues.is_empty(), "Should discover at least one queue");
for queue in &queues {
println!(
"Queue: {} (Length: {}, Consumers: {})",
queue.name, queue.length, queue.consumers
);
}
// Verificar se pelo menos algumas filas foram descobertas
let queue_names: Vec<&str> = queues.iter().map(|q| q.name.as_str()).collect();
println!("Discovered queues: {:?}", queue_names);
// Deve encontrar pelo menos a fila padrรฃo
let has_default_queue = queues
.iter()
.any(|q| q.name == "celery" || q.name == "default");
assert!(has_default_queue, "Should find at least a default queue");
// Teste 4: Operaรงรตes de Tarefas
println!("\n=== Teste 4: Operaรงรตes de Tarefas ===");
// Retry de tarefa falhada
let retry_result = broker.retry_task("task-002").await;
assert!(
retry_result.is_ok(),
"Should successfully retry failed task: {:?}",
retry_result.err()
);
// Revoke de nova tarefa
let revoke_result = broker.revoke_task("task-003").await;
assert!(revoke_result.is_ok(), "Should successfully revoke task");
// Verificar se a revogaรงรฃo foi aplicada
let is_revoked: bool = conn.sismember("revoked", "task-003").await?;
assert!(is_revoked, "Task should be added to revoked set");
// Teste 5: Performance com grande volume
println!("\n=== Teste 5: Performance ===");
let start = std::time::Instant::now();
// Executar todas as operaรงรตes em sequรชncia
let _workers = broker.get_workers().await?;
let _tasks = broker.get_tasks().await?;
let _queues = broker.get_queues().await?;
let duration = start.elapsed();
println!("Total execution time: {:?}", duration);
assert!(
duration.as_millis() < 2000,
"Should complete all operations within 2 seconds"
);
// === LIMPEZA ===
let _: () = redis::cmd("SELECT").arg(0).query_async(&mut conn).await?;
println!("\nโ
Teste de integraรงรฃo completo passou com sucesso!");
Ok(())
}
/// Teste de stress com grande volume de dados
#[tokio::test]
async fn test_stress_with_high_volume() -> Result<()> {
let client = match Client::open("redis://127.0.0.1:6379") {
Ok(client) => client,
Err(_) => {
eprintln!("Skipping stress test: Redis not available");
return Ok(());
}
};
let mut conn = match client.get_multiplexed_tokio_connection().await {
Ok(conn) => conn,
Err(_) => {
eprintln!("Skipping stress test: Redis connection failed");
return Ok(());
}
};
// Usar database 3 para stress test
let _: () = redis::cmd("SELECT").arg(3).query_async(&mut conn).await?;
let _: () = redis::cmd("FLUSHDB").query_async(&mut conn).await?;
// Criar 500 tarefas para teste de stress
println!("Creating 500 tasks for stress test...");
for i in 0..500 {
let status = match i % 4 {
0 => "SUCCESS",
1 => "FAILURE",
2 => "PENDING",
_ => "RETRY",
};
let task_metadata = json!({
"status": status,
"result": if status == "SUCCESS" { json!(i) } else { json!(null) },
"traceback": if status == "FAILURE" { json!(format!("Error in task {}", i)) } else { json!(null) },
"task_id": format!("stress-task-{:04}", i),
"date_done": chrono::Utc::now().to_rfc3339()
});
let _: () = conn
.set(
format!("celery-task-meta-stress-task-{:04}", i),
task_metadata.to_string(),
)
.await?;
}
let broker = RedisBroker::connect("redis://127.0.0.1:6379/3").await?;
println!("Running stress test operations...");
let start = std::time::Instant::now();
// Executar operaรงรตes sob stress
let workers = broker.get_workers().await?;
let tasks = broker.get_tasks().await?;
let queues = broker.get_queues().await?;
let duration = start.elapsed();
// Verificaรงรตes
assert!(
tasks.len() >= 100,
"Should find at least 100 tasks (limited by implementation)"
);
assert!(
duration.as_millis() < 10000,
"Should handle 500 tasks within 10 seconds"
);
println!("Stress test results:");
println!(" Workers discovered: {}", workers.len());
println!(" Tasks parsed: {}", tasks.len());
println!(" Queues discovered: {}", queues.len());
println!(" Execution time: {:?}", duration);
// Voltar ao database padrรฃo
let _: () = redis::cmd("SELECT").arg(0).query_async(&mut conn).await?;
println!("โ
Stress test passed!");
Ok(())
}
</file>
<file path="tests/test_redis_broker_basic.rs">
//! Basic Redis Broker Tests
//!
//! This module contains basic connection, parsing logic, and error handling tests.
//! These tests focus on core broker functionality and graceful handling of various
//! connection scenarios. For comprehensive integration tests with real Celery
//! protocol format, see test_redis_broker_integration.rs.
mod redis_test_utils;
use anyhow::Result;
use lazycelery::broker::{redis::RedisBroker, Broker};
use lazycelery::error::BrokerError;
use lazycelery::models::TaskStatus;
use redis_test_utils::*;
use std::time::Duration;
use tokio::time::timeout;
#[tokio::test]
async fn test_redis_broker_connect_success() {
// Test successful connection with valid Redis URL
let url = "redis://127.0.0.1:6379";
// This will fail in CI without Redis, but shows the connection logic
match RedisBroker::connect(url).await {
Ok(_broker) => {
// Connection successful
}
Err(BrokerError::ConnectionError(_)) => {
// Expected in CI environment without Redis
}
Err(e) => panic!("Unexpected error type: {e:?}"),
}
}
#[tokio::test]
async fn test_redis_broker_connect_invalid_url() {
let invalid_urls = vec![
"",
"not-a-url",
"http://wrong-protocol",
"redis://",
"redis://invalid-host:99999",
];
for url in invalid_urls {
let result = RedisBroker::connect(url).await;
match result {
Err(BrokerError::InvalidUrl(_)) | Err(BrokerError::ConnectionError(_)) => {
// Expected behavior
}
_ => panic!("Expected InvalidUrl or ConnectionError for URL: {url}"),
}
}
}
#[tokio::test]
async fn test_redis_broker_connection_timeout() {
// Test connection timeout with unreachable host
let unreachable_url = "redis://192.0.2.1:6379"; // RFC 5737 test address
let result = timeout(
Duration::from_secs(5),
RedisBroker::connect(unreachable_url),
)
.await;
match result {
Ok(Err(BrokerError::ConnectionError(_))) => {
// Expected: connection should fail
}
Err(_) => {
// Timeout occurred, which is also acceptable
}
Ok(Ok(_)) => {
panic!("Connection should not succeed to unreachable host");
}
Ok(Err(_)) => {
// Any other broker error is also acceptable for unreachable host
}
}
}
// Integration tests that run only when Redis is available
mod integration_tests {
use super::*;
#[tokio::test]
async fn test_redis_get_workers_integration() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_basic_tasks().await?;
builder.add_queue_data().await?;
let broker = db.broker().await?;
let workers = broker.get_workers().await?;
// Should discover workers from task activity
TestAssertions::assert_worker_properties(&workers, 1, true);
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_redis_get_tasks_integration() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_basic_tasks().await?;
// Add small delay to ensure data is persisted
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let broker = db.broker().await?;
let tasks = broker.get_tasks().await?;
// Should find our test tasks
assert!(tasks.len() >= 2, "Should find at least 2 tasks");
// Verify specific tasks using test assertions
TestAssertions::assert_task_properties(
&tasks,
"basic-success-1",
TaskStatus::Success,
true, // should have result
false, // should not have traceback
);
TestAssertions::assert_task_properties(
&tasks,
"basic-failure-1",
TaskStatus::Failure,
false, // should not have result
true, // should have traceback
);
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_redis_get_queues_integration() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_basic_tasks().await?;
builder.add_queue_data().await?;
let broker = db.broker().await?;
let queues = broker.get_queues().await?;
// Verify queue properties using assertions
TestAssertions::assert_queue_properties(
&queues,
1, // min_count
Some(("celery", 2)), // expected celery queue with 2 items
);
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_redis_task_operations_implemented() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
let task_id = "test-failed-task";
// Add a failed task for testing retry
builder.add_retry_test_task(task_id).await?;
let broker = db.broker().await?;
// Test retry operation (should work for failed tasks)
let retry_result = broker.retry_task(task_id).await;
assert!(
retry_result.is_ok(),
"Should successfully retry failed task"
);
// Test revoke operation (should work for any task)
let revoke_task_id = "test-task-to-revoke";
let revoke_result = broker.revoke_task(revoke_task_id).await;
assert!(revoke_result.is_ok(), "Should successfully revoke task");
// Verify task was added to revoked set using assertions
TestAssertions::assert_task_revoked(&client, revoke_task_id, true).await?;
Ok(())
})
.await
}
.await,
)
}
}
// Unit tests for parsing logic (without Redis dependency)
mod parsing_tests {
use super::*;
use serde_json::json;
#[test]
fn test_task_status_mapping() {
// Test all possible status mappings
let test_cases = vec![
("SUCCESS", TaskStatus::Success),
("FAILURE", TaskStatus::Failure),
("PENDING", TaskStatus::Pending),
("RETRY", TaskStatus::Retry),
("REVOKED", TaskStatus::Revoked),
("UNKNOWN", TaskStatus::Active), // Default case
("", TaskStatus::Active), // Empty string case
];
for (status_str, expected) in test_cases {
let task_data = json!({
"status": status_str,
"task": "test.task",
"args": "[]",
"kwargs": "{}",
"hostname": "worker-1"
});
// This tests the mapping logic used in parse_tasks
let parsed_status = match task_data["status"].as_str() {
Some("SUCCESS") => TaskStatus::Success,
Some("FAILURE") => TaskStatus::Failure,
Some("PENDING") => TaskStatus::Pending,
Some("RETRY") => TaskStatus::Retry,
Some("REVOKED") => TaskStatus::Revoked,
_ => TaskStatus::Active,
};
assert_eq!(parsed_status, expected, "Failed for status: {status_str}");
}
}
#[test]
fn test_task_data_parsing_edge_cases() {
// Test malformed JSON handling
let malformed_cases = vec![
"",
"{",
r#"{"incomplete": true"#,
"invalid json",
"{malformed}",
];
for case in malformed_cases {
let result = serde_json::from_str::<serde_json::Value>(case);
assert!(result.is_err(), "Should fail to parse: {case}");
}
// Test cases that parse successfully but might not have expected structure
let valid_but_unexpected = vec![
"null",
"\"string\"",
"123",
"true",
"[]", // Array is valid JSON but not the expected object format
];
for case in valid_but_unexpected {
let result = serde_json::from_str::<serde_json::Value>(case);
assert!(result.is_ok(), "Should parse as valid JSON: {case}");
// But accessing object fields would return None/default
if let Ok(value) = result {
assert_eq!(value["task"].as_str().unwrap_or("unknown"), "unknown");
assert_eq!(value["status"].as_str().unwrap_or("default"), "default");
}
}
}
#[test]
fn test_task_data_missing_fields() {
// Test parsing when optional fields are missing
let minimal_task = json!({
"task": "test.minimal",
"status": "SUCCESS"
});
// Simulate the parsing logic from parse_tasks
let task_name = minimal_task["task"].as_str().unwrap_or("unknown");
let args = minimal_task["args"].to_string();
let kwargs = minimal_task["kwargs"].to_string();
let worker = minimal_task["hostname"].as_str().map(|s| s.to_string());
let result = minimal_task["result"].as_str().map(|s| s.to_string());
let traceback = minimal_task["traceback"].as_str().map(|s| s.to_string());
assert_eq!(task_name, "test.minimal");
assert_eq!(args, "null"); // Missing field becomes "null"
assert_eq!(kwargs, "null");
assert_eq!(worker, None);
assert_eq!(result, None);
assert_eq!(traceback, None);
}
#[test]
fn test_task_id_extraction() {
// Test task ID extraction from Redis key
let test_cases = vec![
("celery-task-meta-abc123", "abc123"),
("celery-task-meta-", ""),
("celery-task-meta-complex-uuid-456", "complex-uuid-456"),
("invalid-key-format", "invalid-key-format"), // Fallback to "unknown" in real code
];
for (key, expected) in test_cases {
let extracted = key.strip_prefix("celery-task-meta-").unwrap_or("unknown");
if key.starts_with("celery-task-meta-") {
assert_eq!(extracted, expected, "Failed for key: {key}");
} else {
assert_eq!(
extracted, "unknown",
"Should fallback to unknown for: {key}"
);
}
}
}
}
// Error handling tests
mod error_tests {
use super::*;
#[tokio::test]
async fn test_redis_operation_errors() {
// Test various Redis operation failure scenarios
// These would be mocked in a real test environment
// Test network timeout simulation
let timeout_result = timeout(Duration::from_millis(1), async {
// Simulate slow operation
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<(), BrokerError>(())
})
.await;
assert!(timeout_result.is_err(), "Should timeout");
}
#[test]
fn test_broker_error_types() {
// Test that all expected error types can be created
let _invalid_url = BrokerError::InvalidUrl("test".to_string());
let _connection_error = BrokerError::ConnectionError("test".to_string());
let _operation_error = BrokerError::OperationError("test".to_string());
let _not_implemented = BrokerError::NotImplemented;
// Verify error display formatting
let error = BrokerError::InvalidUrl("redis://invalid".to_string());
let error_str = format!("{error}");
assert!(error_str.contains("redis://invalid"));
}
}
</file>
<file path="tests/test_redis_broker_integration.rs">
//! Comprehensive Redis Broker Integration Tests
//!
//! This module contains comprehensive integration tests using real Celery protocol format.
//! These tests validate the broker's ability to parse authentic Celery messages, handle
//! task operations (retry, revoke), and perform well with realistic data volumes.
//! For basic connection and parsing logic tests, see test_redis_broker_basic.rs.
mod redis_test_utils;
use anyhow::Result;
use base64::Engine;
use lazycelery::broker::Broker;
use lazycelery::models::TaskStatus;
use redis::AsyncCommands;
use redis_test_utils::*;
use serde_json::json;
use std::time::Duration;
#[tokio::test]
async fn test_real_celery_worker_discovery() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_real_celery_data().await?;
let broker = db.broker().await?;
let workers = broker.get_workers().await?;
// Should discover workers based on task activity
TestAssertions::assert_worker_properties(&workers, 1, false);
if !workers.is_empty() {
let worker = &workers[0];
assert!(!worker.queues.is_empty(), "Worker should have queues");
}
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_real_celery_task_parsing() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_real_celery_data().await?;
let broker = db.broker().await?;
let tasks = broker.get_tasks().await?;
// Should find tasks from metadata + queue
assert!(tasks.len() >= 3, "Should find at least 3 tasks");
// Verify successful task (isolated test, should be clean)
let success_task = tasks
.iter()
.find(|t| t.id == "real-success-task")
.expect("Should find successful task");
assert_eq!(success_task.status, TaskStatus::Success);
assert_eq!(success_task.result, Some("42".to_string()));
// Verify failed task
let failed_task = tasks
.iter()
.find(|t| t.id == "real-failure-task")
.expect("Should find failed task");
assert_eq!(failed_task.status, TaskStatus::Failure);
assert!(failed_task.traceback.is_some());
assert!(failed_task
.traceback
.as_ref()
.unwrap()
.contains("ValueError"));
// Verify pending task from queue
let pending_task = tasks.iter().find(|t| t.name == "myapp.tasks.add_numbers");
if let Some(task) = pending_task {
assert_eq!(task.status, TaskStatus::Pending);
assert!(!task.args.is_empty());
assert!(!task.kwargs.is_empty());
}
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_real_celery_queue_discovery() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_real_celery_data().await?;
let broker = db.broker().await?;
let queues = broker.get_queues().await?;
// Should discover queues based on bindings + activity
TestAssertions::assert_queue_properties(
&queues,
1, // min_count
Some(("celery", 1)), // expected celery queue with 1 item
);
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_task_retry_functionality() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_real_celery_data().await?;
let broker = db.broker().await?;
let failed_task_id = "real-failure-task";
let success_task_id = "real-success-task";
// Test retry of failed task
let result = broker.retry_task(failed_task_id).await;
assert!(result.is_ok(), "Should successfully retry failed task");
// Verify status was updated
TestAssertions::assert_task_metadata_updated(
&client,
failed_task_id,
"RETRY",
true, // should have retries
)
.await?;
// Test retry of successful task (should fail)
let result = broker.retry_task(success_task_id).await;
assert!(result.is_err(), "Should fail to retry successful task");
// Test retry of nonexistent task (should fail)
let result = broker.retry_task("nonexistent-task").await;
assert!(result.is_err(), "Should fail to retry nonexistent task");
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_task_revoke_functionality() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_real_celery_data().await?;
let broker = db.broker().await?;
let task_id = "real-success-task";
// Revoke a task
let result = broker.revoke_task(task_id).await;
assert!(result.is_ok(), "Should successfully revoke task");
// Verify task was added to revoked set
TestAssertions::assert_task_revoked(&client, task_id, true).await?;
// Verify metadata was updated (if exists)
let mut conn = client.get_multiplexed_tokio_connection().await?;
if let Ok(updated_data) = conn
.get::<_, String>(&format!("celery-task-meta-{task_id}"))
.await
{
let task_json: serde_json::Value = serde_json::from_str(&updated_data)?;
assert_eq!(task_json["status"], "REVOKED");
}
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_task_timestamp_parsing() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
builder.add_real_celery_data().await?;
let broker = db.broker().await?;
let tasks = broker.get_tasks().await?;
// Verify correct timestamp parsing
let success_task = tasks
.iter()
.find(|t| t.id == "real-success-task")
.expect("Should find successful task");
// Verify timestamp was parsed correctly
assert!(
success_task.timestamp.timestamp() > 0,
"Should have valid timestamp"
);
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_base64_task_body_decoding() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let mut conn = client.get_multiplexed_tokio_connection().await?;
// Add message with base64 encoded body
let task_args = json!([[10, 20], {"multiply": true}]);
let encoded_body =
base64::engine::general_purpose::STANDARD.encode(task_args.to_string());
let task_message = json!({
"body": encoded_body,
"headers": {
"task": "math.multiply",
"id": "base64-test-task"
}
});
let _: () = conn.lpush("celery", task_message.to_string()).await?;
let broker = db.broker().await?;
let tasks = broker.get_tasks().await?;
// Should find task with decoded args
let decoded_task = tasks
.iter()
.find(|t| t.name == "math.multiply")
.expect("Should find task with decoded body");
assert!(decoded_task.args.contains("10"));
assert!(decoded_task.args.contains("20"));
assert!(decoded_task.kwargs.contains("multiply"));
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_performance_with_large_dataset() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
// Add many tasks for performance testing
builder.add_performance_data(50).await?;
let broker = db.broker().await?;
// Measure execution time
let start = std::time::Instant::now();
let tasks = broker.get_tasks().await?;
let duration = start.elapsed();
// Verify results
assert!(tasks.len() >= 50, "Should find all tasks");
assert!(
duration < Duration::from_millis(5000),
"Should complete within 5 seconds"
);
// Verify worker discovery based on activity
let workers = broker.get_workers().await?;
// With 50 processed tasks, should detect activity
if !workers.is_empty() {
let worker = &workers[0];
// With SUCCESS and FAILURE tasks, should show activity
assert!(
worker.processed >= 16 || worker.failed >= 16, // 50/3 = ~16 of each type
"Worker should show activity from processed tasks (processed: {}, failed: {})",
worker.processed,
worker.failed
);
}
Ok(())
})
.await
}
.await,
)
}
#[tokio::test]
async fn test_edge_cases_and_malformed_data() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
// Add malformed data
builder.add_malformed_data().await?;
let broker = db.broker().await?;
// Should handle malformed data gracefully
let result = broker.get_tasks().await;
assert!(result.is_ok(), "Should handle malformed data gracefully");
let tasks = result.unwrap();
// Should find at least the incomplete task
let incomplete = tasks.iter().find(|t| t.id == "incomplete");
if let Some(task) = incomplete {
assert_eq!(task.status, TaskStatus::Success);
assert_eq!(task.args, "[]"); // Default value
assert_eq!(task.kwargs, "{}"); // Default value
}
Ok(())
})
.await
}
.await,
)
}
</file>
<file path="tests/test_ui_base_widgets.rs">
use lazycelery::ui::widgets::base::helpers::*;
use ratatui::style::{Color, Modifier, Style};
#[test]
fn test_selection_style() {
let style = selection_style();
assert_eq!(style.bg, Some(Color::DarkGray));
assert!(style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_titled_block() {
let _block = titled_block("Test Title");
// Test that the function runs without panicking
// The actual title format is " Test Title " (with spaces)
// Function executed successfully if we reach this point
}
#[test]
fn test_titled_block_different_titles() {
let test_titles = vec![
"Workers",
"Queues",
"Tasks",
"Details",
"Very Long Title With Spaces",
"",
"Title with 123 numbers",
];
for title in test_titles {
let _block = titled_block(title);
// Test that each call completes successfully
// No assertion needed - function success is implicit
}
}
#[test]
fn test_no_data_message() {
let _paragraph = no_data_message("workers");
// The paragraph is created successfully
// We can't easily inspect the exact text content, but we can verify structure
// The function should create a paragraph with a border and title
// Test with different item types
let item_types = vec!["workers", "tasks", "queues", "results"];
for item_type in item_types {
let _paragraph = no_data_message(item_type);
// Each call should succeed without panicking
// No assertion needed - function success is implicit
}
}
#[test]
fn test_status_line() {
let line = status_line("Status", "Online", Color::Green);
// Verify the line contains both label and value spans
assert_eq!(line.spans.len(), 2);
// First span should be the label with colon
assert_eq!(line.spans[0].content, "Status: ");
// Second span should be the value with color
assert_eq!(line.spans[1].content, "Online");
assert_eq!(line.spans[1].style.fg, Some(Color::Green));
}
#[test]
fn test_status_line_different_colors() {
let test_cases = vec![
("Active", "Running", Color::Green),
("Failed", "Error", Color::Red),
("Pending", "Waiting", Color::Yellow),
("Unknown", "N/A", Color::Gray),
];
for (label, value, color) in test_cases {
let line = status_line(label, value, color);
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content, format!("{label}: "));
assert_eq!(line.spans[1].content, value);
assert_eq!(line.spans[1].style.fg, Some(color));
}
}
#[test]
fn test_field_line() {
let line = field_line("Name", "test-worker");
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content, "Name: ");
assert_eq!(line.spans[1].content, "test-worker");
// Both spans should have default styling (no specific color)
assert_eq!(line.spans[0].style, Style::default());
assert_eq!(line.spans[1].style, Style::default());
}
#[test]
fn test_field_line_edge_cases() {
let test_cases = vec![
("", ""),
("Empty Value", ""),
("", "Empty Label"),
("Long Label Name", "Short"),
("ID", "very-long-id-string-with-many-characters-123456789"),
("Special/Chars!", "Value@#$%^&*()"),
];
for (label, value) in test_cases {
let line = field_line(label, value);
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content, format!("{label}: "));
assert_eq!(line.spans[1].content, value);
}
}
#[test]
fn test_highlighted_field_line() {
let line = highlighted_field_line("Priority", "High", Color::Red);
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content, "Priority: ");
assert_eq!(line.spans[1].content, "High");
assert_eq!(line.spans[1].style.fg, Some(Color::Red));
// First span should be default style
assert_eq!(line.spans[0].style, Style::default());
}
#[test]
fn test_highlighted_field_line_various_colors() {
let test_cases = vec![
("Error", "Critical", Color::Red),
("Success", "Completed", Color::Green),
("Warning", "Attention", Color::Yellow),
("Info", "Details", Color::Blue),
("Debug", "Verbose", Color::Magenta),
];
for (label, value, color) in test_cases {
let line = highlighted_field_line(label, value, color);
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content, format!("{label}: "));
assert_eq!(line.spans[1].content, value);
assert_eq!(line.spans[1].style.fg, Some(color));
}
}
#[test]
fn test_line_span_consistency() {
// Test that all line creation functions produce consistent span structures
let status_line_result = status_line("Test", "Value", Color::White);
let field_line_result = field_line("Test", "Value");
let highlighted_line_result = highlighted_field_line("Test", "Value", Color::White);
// All should have exactly 2 spans
assert_eq!(status_line_result.spans.len(), 2);
assert_eq!(field_line_result.spans.len(), 2);
assert_eq!(highlighted_line_result.spans.len(), 2);
// All should have the same label format
assert_eq!(status_line_result.spans[0].content, "Test: ");
assert_eq!(field_line_result.spans[0].content, "Test: ");
assert_eq!(highlighted_line_result.spans[0].content, "Test: ");
// All should have the same value content
assert_eq!(status_line_result.spans[1].content, "Value");
assert_eq!(field_line_result.spans[1].content, "Value");
assert_eq!(highlighted_line_result.spans[1].content, "Value");
}
#[test]
fn test_helper_functions_with_unicode() {
// Test with Unicode characters to ensure proper handling
let unicode_cases = vec![
("็ถๆ", "ๅจ็บฟ", Color::Green),
("รame", "Tรซst", Color::Blue),
("๐ง Tool", "โก Status", Color::Yellow),
("รmoji", "๐ Success", Color::Green),
];
for (label, value, color) in unicode_cases {
let status_line_result = status_line(label, value, color);
let field_line_result = field_line(label, value);
let highlighted_line_result = highlighted_field_line(label, value, color);
// Should handle Unicode without issues
assert_eq!(status_line_result.spans[0].content, format!("{label}: "));
assert_eq!(status_line_result.spans[1].content, value);
assert_eq!(field_line_result.spans[0].content, format!("{label}: "));
assert_eq!(field_line_result.spans[1].content, value);
assert_eq!(
highlighted_line_result.spans[0].content,
format!("{label}: ")
);
assert_eq!(highlighted_line_result.spans[1].content, value);
}
}
</file>
<file path="tests/test_ui_layout.rs">
use lazycelery::ui::layout::{centered_rect, create_main_layout};
use ratatui::layout::Rect;
mod test_broker_utils;
#[test]
fn test_create_main_layout() {
let area = Rect::new(0, 0, 100, 50);
let layout = create_main_layout(area);
assert_eq!(layout.len(), 3);
// Header should be 3 units high
assert_eq!(layout[0].height, 3);
assert_eq!(layout[0].x, 0);
assert_eq!(layout[0].y, 0);
assert_eq!(layout[0].width, 100);
// Status bar should be 3 units high at bottom
assert_eq!(layout[2].height, 3);
assert_eq!(layout[2].x, 0);
assert_eq!(layout[2].y, 47); // 50 - 3
assert_eq!(layout[2].width, 100);
// Main content should fill remaining space
assert_eq!(layout[1].height, 44); // 50 - 3 - 3
assert_eq!(layout[1].x, 0);
assert_eq!(layout[1].y, 3);
assert_eq!(layout[1].width, 100);
}
#[test]
fn test_create_main_layout_small_area() {
let area = Rect::new(10, 5, 20, 10);
let layout = create_main_layout(area);
assert_eq!(layout.len(), 3);
// Header
assert_eq!(layout[0].height, 3);
assert_eq!(layout[0].x, 10);
assert_eq!(layout[0].y, 5);
assert_eq!(layout[0].width, 20);
// Status bar
assert_eq!(layout[2].height, 3);
assert_eq!(layout[2].x, 10);
assert_eq!(layout[2].y, 12); // 5 + 10 - 3
assert_eq!(layout[2].width, 20);
// Main content (minimum height 0 due to constraint)
assert_eq!(layout[1].height, 4); // 10 - 3 - 3
assert_eq!(layout[1].x, 10);
assert_eq!(layout[1].y, 8); // 5 + 3
assert_eq!(layout[1].width, 20);
}
#[test]
fn test_centered_rect_50_percent() {
let area = Rect::new(0, 0, 100, 50);
let centered = centered_rect(50, 50, area);
// Should be 50% of width and height, centered
assert_eq!(centered.width, 50);
assert_eq!(centered.height, 25);
assert_eq!(centered.x, 25); // (100 - 50) / 2
assert_eq!(centered.y, 13); // Actual ratatui layout calculation
}
#[test]
fn test_centered_rect_80_percent() {
let area = Rect::new(0, 0, 100, 50);
let centered = centered_rect(80, 70, area);
// Should be 80% width, 70% height, centered
assert_eq!(centered.width, 80);
assert_eq!(centered.height, 35);
assert_eq!(centered.x, 10); // (100 - 80) / 2
assert_eq!(centered.y, 8); // Actual ratatui layout calculation
}
#[test]
fn test_centered_rect_with_offset() {
let area = Rect::new(20, 10, 60, 30);
let centered = centered_rect(50, 50, area);
// Should respect the area's offset
assert_eq!(centered.width, 30); // 50% of 60
assert_eq!(centered.height, 15); // 50% of 30
assert_eq!(centered.x, 35); // 20 + (60 - 30) / 2
assert_eq!(centered.y, 18); // Actual ratatui layout calculation
}
// Note: get_key_hints is a private function in layout.rs
// Testing it indirectly through integration tests would be more appropriate
// Since it's mainly used in draw_status_bar function
</file>
<file path="tests/test_ui_modals.rs">
use lazycelery::app::App;
use lazycelery::models::{Task, TaskStatus};
use lazycelery::ui::modals::{draw_confirmation_dialog, draw_help, draw_task_details_modal};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
mod test_broker_utils;
use test_broker_utils::MockBrokerBuilder;
#[test]
fn test_modal_content_generation() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
// Test confirmation dialog setup
app.show_confirmation = true;
app.confirmation_message = "Are you sure you want to purge this queue?".to_string();
assert!(app.show_confirmation);
assert!(!app.confirmation_message.is_empty());
// Test task details setup
let test_task = Task {
id: "test-task-123".to_string(),
name: "test.task".to_string(),
status: TaskStatus::Success,
worker: Some("worker@host".to_string()),
timestamp: chrono::Utc::now(),
args: "[\"arg1\", \"arg2\"]".to_string(),
kwargs: "{\"key\": \"value\"}".to_string(),
result: Some("Task completed successfully".to_string()),
traceback: None,
};
app.selected_task_details = Some(test_task.clone());
app.show_task_details = true;
assert!(app.show_task_details);
assert!(app.selected_task_details.is_some());
if let Some(task) = &app.selected_task_details {
assert_eq!(task.id, "test-task-123");
assert_eq!(task.name, "test.task");
assert_eq!(task.status, TaskStatus::Success);
}
}
#[test]
fn test_modal_state_transitions() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
// Test initial state
assert!(!app.show_help);
assert!(!app.show_confirmation);
assert!(!app.show_task_details);
assert!(app.selected_task_details.is_none());
// Test help modal
app.show_help = true;
assert!(app.show_help);
// Test confirmation modal
app.show_help = false;
app.show_confirmation = true;
app.confirmation_message = "Test confirmation".to_string();
assert!(!app.show_help);
assert!(app.show_confirmation);
// Test task details modal
app.show_confirmation = false;
app.show_task_details = true;
let task = Task {
id: "task-456".to_string(),
name: "another.task".to_string(),
status: TaskStatus::Failure,
worker: Some("worker2@host".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: None,
traceback: Some(
"Traceback (most recent call last):\n File \"test.py\", line 1\nError: Test error"
.to_string(),
),
};
app.selected_task_details = Some(task);
assert!(!app.show_confirmation);
assert!(app.show_task_details);
assert!(app.selected_task_details.is_some());
}
#[test]
fn test_task_details_with_failure_traceback() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
let failed_task = Task {
id: "failed-task".to_string(),
name: "failing.task".to_string(),
status: TaskStatus::Failure,
worker: Some("worker@host".to_string()),
timestamp: chrono::Utc::now(),
args: "[\"failed_arg\"]".to_string(),
kwargs: "{\"debug\": true}".to_string(),
result: None,
traceback: Some("Traceback (most recent call last):\n File \"worker.py\", line 42, in execute\n raise ValueError(\"Test failure\")\nValueError: Test failure".to_string()),
};
app.selected_task_details = Some(failed_task.clone());
app.show_task_details = true;
if let Some(task) = &app.selected_task_details {
assert_eq!(task.status, TaskStatus::Failure);
assert!(task.traceback.is_some());
if let Some(traceback) = &task.traceback {
assert!(traceback.contains("ValueError"));
assert!(traceback.contains("Test failure"));
assert!(traceback.lines().count() > 1);
}
}
}
#[test]
fn test_task_details_various_statuses() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
let statuses = vec![
TaskStatus::Success,
TaskStatus::Failure,
TaskStatus::Pending,
TaskStatus::Active,
TaskStatus::Retry,
TaskStatus::Revoked,
];
for status in statuses {
let task = Task {
id: format!("task-{status:?}").to_lowercase(),
name: format!("test.{status:?}").to_lowercase(),
status: status.clone(),
worker: Some("test-worker".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: if status == TaskStatus::Success {
Some("OK".to_string())
} else {
None
},
traceback: if status == TaskStatus::Failure {
Some("Error occurred".to_string())
} else {
None
},
};
app.selected_task_details = Some(task.clone());
assert_eq!(app.selected_task_details.as_ref().unwrap().status, status);
}
}
#[test]
fn test_confirmation_dialog_messages() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
let test_messages = vec![
"Are you sure you want to purge the queue 'celery'?",
"Confirm retry task 'test-task-123'?",
"Revoke task 'failing-task-456'? This action cannot be undone.",
"Delete all completed tasks?",
];
for message in test_messages {
app.confirmation_message = message.to_string();
app.show_confirmation = true;
assert_eq!(app.confirmation_message, message);
assert!(app.show_confirmation);
// Reset for next iteration
app.show_confirmation = false;
}
}
#[test]
fn test_modal_priority_logic() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
// Multiple modals active - should follow priority order in UI rendering
app.show_help = true;
app.show_confirmation = true;
app.show_task_details = true;
// All can be true simultaneously in app state
assert!(app.show_help);
assert!(app.show_confirmation);
assert!(app.show_task_details);
// The actual priority is handled in the rendering functions
// This test verifies the state can handle multiple modal flags
}
#[test]
fn test_task_details_edge_cases() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
// Task with minimal data
let minimal_task = Task {
id: "minimal".to_string(),
name: "minimal.task".to_string(),
status: TaskStatus::Pending,
worker: None, // No worker assigned
timestamp: chrono::Utc::now(),
args: "".to_string(), // Empty args
kwargs: "".to_string(), // Empty kwargs
result: None,
traceback: None,
};
app.selected_task_details = Some(minimal_task.clone());
if let Some(task) = &app.selected_task_details {
assert!(task.worker.is_none());
assert!(task.args.is_empty());
assert!(task.kwargs.is_empty());
assert!(task.result.is_none());
assert!(task.traceback.is_none());
}
// Task with very long data
let long_task = Task {
id: "x".repeat(100),
name: "very.long.task.name.with.many.segments".to_string(),
status: TaskStatus::Active,
worker: Some("worker-with-very-long-hostname@example.domain.com".to_string()),
timestamp: chrono::Utc::now(),
args: format!("[{}]", "\"arg\", ".repeat(50)),
kwargs: format!("{{{}}}", "\"key\": \"value\", ".repeat(20)),
result: Some(
"Very long result text that might wrap across multiple lines in the UI".to_string(),
),
traceback: None,
};
app.selected_task_details = Some(long_task.clone());
if let Some(task) = &app.selected_task_details {
assert!(task.id.len() == 100);
assert!(task.name.contains("very.long"));
assert!(task.args.len() > 100);
assert!(task.kwargs.len() > 100);
}
}
// Integration test to verify modal rendering doesn't crash
#[test]
fn test_modal_rendering_integration() {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
// Test rendering each modal type
terminal
.draw(|f| {
app.show_help = true;
draw_help(f);
})
.unwrap();
app.show_help = false;
app.show_confirmation = true;
app.confirmation_message = "Test confirmation".to_string();
terminal
.draw(|f| {
draw_confirmation_dialog(f, &app);
})
.unwrap();
app.show_confirmation = false;
app.show_task_details = true;
app.selected_task_details = Some(Task {
id: "test".to_string(),
name: "test.task".to_string(),
status: TaskStatus::Success,
worker: Some("worker".to_string()),
timestamp: chrono::Utc::now(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
result: Some("OK".to_string()),
traceback: None,
});
terminal
.draw(|f| {
draw_task_details_modal(f, &app);
})
.unwrap();
}
</file>
<file path="tests/test_ui_widgets.rs">
use lazycelery::models::{TaskStatus, Worker, WorkerStatus};
use ratatui::style::Color;
// Test for business logic without UI rendering
mod widget_logic_tests {
use super::*;
#[test]
fn test_task_status_color_mapping() {
let test_cases = vec![
(TaskStatus::Success, Color::Green),
(TaskStatus::Failure, Color::Red),
(TaskStatus::Active, Color::Yellow),
(TaskStatus::Pending, Color::Gray),
(TaskStatus::Retry, Color::Magenta),
(TaskStatus::Revoked, Color::DarkGray),
];
for (status, expected_color) in test_cases {
let actual_color = match status {
TaskStatus::Success => Color::Green,
TaskStatus::Failure => Color::Red,
TaskStatus::Active => Color::Yellow,
TaskStatus::Pending => Color::Gray,
TaskStatus::Retry => Color::Magenta,
TaskStatus::Revoked => Color::DarkGray,
};
assert_eq!(
actual_color, expected_color,
"Color mismatch for status: {status:?}"
);
}
}
#[test]
fn test_worker_status_symbols() {
let online_symbol = match WorkerStatus::Online {
WorkerStatus::Online => "โ",
WorkerStatus::Offline => "โ",
};
let offline_symbol = match WorkerStatus::Offline {
WorkerStatus::Online => "โ",
WorkerStatus::Offline => "โ",
};
assert_eq!(online_symbol, "โ");
assert_eq!(offline_symbol, "โ");
}
#[test]
fn test_worker_utilization_calculation() {
let worker = Worker {
hostname: "test-worker".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec![],
active_tasks: vec!["task1".to_string(), "task2".to_string()],
processed: 100,
failed: 5,
};
assert_eq!(worker.utilization(), 50.0); // 2/4 = 50%
}
#[test]
fn test_task_viewport_logic() {
let height = 10;
let total_items = 20;
// Test cases: (selected_index, expected_start)
let test_cases: Vec<(usize, usize)> = vec![
(0, 0), // Beginning
(5, 0), // Still within first page
(10, 5), // Should center around selection
(15, 10), // Should center around selection
(19, 10), // Last item, start should be total - height
];
for (selected, expected_start) in test_cases {
let start = if selected >= height && height > 0 {
selected.saturating_sub(height / 2)
} else {
0
};
let end = (start + height).min(total_items);
// Ensure we don't go beyond bounds
let actual_start = if end == total_items && total_items > height {
total_items - height
} else {
start
};
assert_eq!(
actual_start, expected_start,
"Viewport calculation failed for selected={selected}, height={height}, total={total_items}"
);
}
}
#[test]
fn test_duration_formatting() {
use chrono::Duration;
let duration = Duration::hours(2) + Duration::minutes(30) + Duration::seconds(45);
let formatted = format!(
"{:02}:{:02}:{:02}",
duration.num_hours(),
duration.num_minutes() % 60,
duration.num_seconds() % 60
);
assert_eq!(formatted, "02:30:45");
}
}
</file>
<file path="tests/test_utils_formatting.rs">
use chrono::{Datelike, Duration, TimeZone, Utc};
use lazycelery::utils::formatting::{format_duration, format_timestamp, truncate_string};
#[test]
fn test_format_duration_seconds_only() {
let duration = Duration::seconds(45);
let formatted = format_duration(duration);
assert_eq!(formatted, "00:45");
}
#[test]
fn test_format_duration_minutes_and_seconds() {
let duration = Duration::seconds(125); // 2 minutes, 5 seconds
let formatted = format_duration(duration);
assert_eq!(formatted, "02:05");
}
#[test]
fn test_format_duration_with_hours() {
let duration = Duration::seconds(3665); // 1 hour, 1 minute, 5 seconds
let formatted = format_duration(duration);
assert_eq!(formatted, "01:01:05");
}
#[test]
fn test_format_duration_zero() {
let duration = Duration::seconds(0);
let formatted = format_duration(duration);
assert_eq!(formatted, "00:00");
}
#[test]
fn test_format_duration_exactly_one_hour() {
let duration = Duration::seconds(3600); // Exactly 1 hour
let formatted = format_duration(duration);
assert_eq!(formatted, "01:00:00");
}
#[test]
fn test_format_duration_exactly_one_minute() {
let duration = Duration::seconds(60); // Exactly 1 minute
let formatted = format_duration(duration);
assert_eq!(formatted, "01:00");
}
#[test]
fn test_format_duration_large_values() {
let duration = Duration::seconds(359999); // 99 hours, 59 minutes, 59 seconds
let formatted = format_duration(duration);
assert_eq!(formatted, "99:59:59");
}
#[test]
fn test_format_timestamp() {
let timestamp = Utc.with_ymd_and_hms(2023, 12, 25, 14, 30, 45).unwrap();
let formatted = format_timestamp(timestamp);
assert_eq!(formatted, "2023-12-25 14:30:45");
}
#[test]
fn test_format_timestamp_start_of_year() {
let timestamp = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let formatted = format_timestamp(timestamp);
assert_eq!(formatted, "2024-01-01 00:00:00");
}
#[test]
fn test_format_timestamp_end_of_year() {
let timestamp = Utc.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap();
let formatted = format_timestamp(timestamp);
assert_eq!(formatted, "2023-12-31 23:59:59");
}
#[test]
fn test_format_timestamp_leap_year() {
let timestamp = Utc.with_ymd_and_hms(2024, 2, 29, 12, 0, 0).unwrap();
let formatted = format_timestamp(timestamp);
assert_eq!(formatted, "2024-02-29 12:00:00");
}
#[test]
fn test_truncate_string_no_truncation() {
let result = truncate_string("hello", 10);
assert_eq!(result, "hello");
}
#[test]
fn test_truncate_string_exact_length() {
let result = truncate_string("hello", 5);
assert_eq!(result, "hello");
}
#[test]
fn test_truncate_string_simple_truncation() {
let result = truncate_string("hello world", 8);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_string_very_short_limit() {
let result = truncate_string("hello", 3);
assert_eq!(result, "...");
}
#[test]
fn test_truncate_string_shorter_than_ellipsis() {
let result = truncate_string("hello", 2);
assert_eq!(result, "...");
}
#[test]
fn test_truncate_string_empty_string() {
let result = truncate_string("", 5);
assert_eq!(result, "");
}
#[test]
fn test_truncate_string_zero_length() {
let result = truncate_string("hello", 0);
assert_eq!(result, "...");
}
#[test]
fn test_truncate_string_unicode() {
// Note: This test might fail due to byte vs character counting
// The current implementation uses byte indexing which can panic on unicode boundaries
let result = truncate_string("hรฉllo", 6);
assert_eq!(result, "hรฉllo");
}
#[test]
fn test_truncate_string_long_text() {
let long_text = "The quick brown fox jumps over the lazy dog";
let result = truncate_string(long_text, 20);
assert_eq!(result, "The quick brown f...");
}
#[test]
fn test_truncate_string_single_character() {
let result = truncate_string("a", 1);
assert_eq!(result, "a");
}
#[test]
fn test_format_duration_negative() {
// Test behavior with negative durations
let duration = Duration::seconds(-30);
let formatted = format_duration(duration);
// The behavior with negative durations depends on implementation
// This test documents the current behavior
assert!(formatted.contains("00"));
}
#[test]
fn test_format_duration_boundary_values() {
// Test various boundary values
let test_cases = vec![
(59, "00:59"), // Just under 1 minute
(60, "01:00"), // Exactly 1 minute
(61, "01:01"), // Just over 1 minute
(3599, "59:59"), // Just under 1 hour
(3600, "01:00:00"), // Exactly 1 hour
(3661, "01:01:01"), // Just over 1 hour
];
for (seconds, expected) in test_cases {
let duration = Duration::seconds(seconds);
let formatted = format_duration(duration);
assert_eq!(formatted, expected, "Failed for {seconds} seconds");
}
}
#[test]
fn test_truncate_string_edge_cases() {
let test_cases = vec![
("", 0, ""), // Empty string stays empty even with 0 max_len
("", 1, ""),
("", 10, ""),
("a", 0, "..."), // Non-empty string with 0 max_len gets "..."
("a", 1, "a"),
("ab", 1, "..."),
("abc", 3, "abc"), // max_len == string length, no truncation needed
("abcd", 4, "abcd"), // max_len == string length, no truncation needed
("abcd", 5, "abcd"),
];
for (input, max_len, expected) in test_cases {
let result = truncate_string(input, max_len);
assert_eq!(
result, expected,
"Failed for input '{input}' with max_len {max_len}"
);
}
}
#[test]
fn test_formatting_functions_with_realistic_data() {
// Test with more realistic data that might be encountered in the application
// Test long task names that need truncation
let long_task_name = "my_app.tasks.process_large_dataset_with_complex_calculations";
let truncated = truncate_string(long_task_name, 30);
assert_eq!(truncated, "my_app.tasks.process_large_...");
// Test typical durations for Celery tasks
let quick_task = Duration::seconds(2); // 2 second task
assert_eq!(format_duration(quick_task), "00:02");
let medium_task = Duration::seconds(450); // 7.5 minute task
assert_eq!(format_duration(medium_task), "07:30");
let long_task = Duration::seconds(7200); // 2 hour task
assert_eq!(format_duration(long_task), "02:00:00");
// Test recent timestamp
let recent_time = Utc::now();
let formatted_time = format_timestamp(recent_time);
// Should contain the current year and be in correct format
assert!(formatted_time.len() == 19); // YYYY-MM-DD HH:MM:SS format
assert!(formatted_time.contains(&recent_time.year().to_string()));
}
</file>
<file path=".editorconfig">
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
# Rust files
[*.rs]
indent_style = space
indent_size = 4
# TOML files
[*.toml]
indent_style = space
indent_size = 2
# YAML files
[*.{yml,yaml}]
indent_style = space
indent_size = 2
# Markdown files
[*.md]
trim_trailing_whitespace = false
# Makefile
[Makefile]
indent_style = tab
</file>
<file path="CONTRIBUTING.md">
# Contributing to LazyCelery
Thank you for your interest in contributing to LazyCelery! This document provides guidelines and instructions for contributing.
## Code of Conduct
By participating in this project, you agree to abide by our code of conduct: be respectful, inclusive, and constructive.
## How to Contribute
### Reporting Issues
- Check if the issue already exists
- Include steps to reproduce
- Include system information (OS, Rust version)
- Include relevant logs or error messages
### Submitting Pull Requests
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes
4. Run tests (`mise run test`)
5. Run linting (`mise run lint`)
6. Commit with conventional commits (see below)
7. Push to your fork
8. Open a Pull Request
### Conventional Commits
We use [Conventional Commits](https://www.conventionalcommits.org/) for our commit messages:
- `feat:` New features
- `fix:` Bug fixes
- `docs:` Documentation changes
- `style:` Code style changes (formatting, etc)
- `refactor:` Code refactoring
- `perf:` Performance improvements
- `test:` Test additions or corrections
- `chore:` Maintenance tasks
- `ci:` CI/CD changes
Example: `feat: add AMQP broker support`
### Development Setup
1. Install Rust (1.70.0 or later)
2. Clone the repository
3. Install mise (task runner):
```bash
./scripts/install-mise.sh
```
4. Setup development environment:
```bash
mise run setup
```
This will:
- Install required Rust components
- Install development tools
- Start Redis using Docker
- Prepare your environment for development
### Running Tests
```bash
# Run all tests
mise run test
# Run tests in watch mode
mise run test-watch
# Run with coverage
mise run coverage
# Run specific test
cargo test test_worker_creation
```
### Code Style
- Run `mise run fmt` to format code
- Run `mise run lint` to check for issues
- Run `mise run check` to verify both formatting and linting
- Follow Rust naming conventions
- Add documentation for public APIs
### Pre-commit Checklist
Run before committing:
```bash
mise run pre-commit
```
This will run formatting, linting, tests, and security audit.
### Pull Request Process
1. Update documentation if needed
2. Add tests for new functionality
3. Ensure CI passes
4. Request review from maintainers
5. Address review feedback
## Release Process
Releases are automated via GitHub Actions:
1. Create a version bump PR using the Version Bump workflow
2. Merge the PR
3. Create and push a tag: `git tag v1.2.3 && git push origin v1.2.3`
4. GitHub Actions will create the release
## Questions?
Feel free to open an issue for any questions!
</file>
<file path="deny.toml">
# cargo-deny configuration
[graph]
# When creating the dependency graph used as the source of truth when checks are
# executed, this field can be used to prune crates from the graph, removing them
# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate
# is pruned from the graph, all of its dependencies will also be pruned unless
# they are connected to another crate in the graph that hasn't been pruned,
# so it should be used with care. The identifiers are [Package ID Specifications]
# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)
targets = []
[licenses]
# List of explicitly allowed licenses
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
"CC0-1.0",
]
# The confidence threshold for detecting a license from license text.
# Possible values are numbers between 0.0 and 1.0
confidence-threshold = 0.8
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
# Lint level for when a crate marked as 'deny' is detected
deny = "warn"
# Lint level for when a crate marked as 'warn' is detected
warn = "warn"
# Lint level for when a crate marked as 'allow' is detected
allow = "warn"
# List of explicitly disallowed crates
deny = [
# Example: { name = "openssl" }, # We prefer rustls
]
# Skip certain crates when doing duplicate detection.
skip = []
# Similarly named crates that are allowed to coexist
skip-tree = []
[advisories]
# The path where the advisory database is cloned/fetched into
db-path = "~/.cargo/advisory-db"
# The url(s) of the advisory databases to use
db-urls = ["https://github.com/rustsec/advisory-db"]
# The lint level for unmaintained crates
unmaintained = "warn"
# The lint level for crates that have been yanked from their source registry
yanked = "warn"
# The lint level for crates with security notices
notice = "warn"
# A list of advisory IDs to ignore
ignore = []
[sources]
# Lint level for what to happen when a crate from a crate registry that is not in the allow list is detected
unknown-registry = "warn"
# Lint level for what to happen when a crate from a git repository that is not in the allow list is detected
unknown-git = "warn"
# List of allowed crate registries
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# List of allowed Git repositories
allow-git = []
</file>
<file path="LICENSE">
MIT License
Copyright (c) 2024 LazyCelery Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</file>
<file path="src/broker/redis/protocol/task_parser.rs">
//! Task parser for Redis Celery protocol
//!
//! This module handles parsing task information from Redis data structures.
//! It extracts task metadata, status, and combines information from both
//! completed tasks (metadata) and pending tasks (queue messages).
use crate::error::BrokerError;
use crate::models::{Task, TaskStatus};
use base64::Engine;
use chrono::{DateTime, Utc};
use redis::aio::MultiplexedConnection;
use redis::AsyncCommands;
use serde_json::Value;
use std::collections::HashMap;
// Configuration constants for task parsing
const MAX_TASK_RESULTS: usize = 100;
const MAX_QUEUE_MESSAGES: usize = 100;
const MAX_PENDING_TASKS: usize = 20;
/// Parser for task-related data from Redis
pub struct TaskParser;
impl TaskParser {
/// Parse tasks from Redis connection
///
/// Combines information from task metadata (completed tasks) and queue messages
/// (pending tasks) to provide a comprehensive view of all tasks.
pub async fn parse_tasks(connection: &MultiplexedConnection) -> Result<Vec<Task>, BrokerError> {
let mut conn = connection.clone();
let mut tasks = Vec::new();
// First, get task names from pending queue messages
let task_names = Self::get_queue_messages(&mut conn).await?;
// Get task results from metadata keys
Self::parse_task_metadata(&mut conn, &mut tasks, &task_names).await?;
// Add pending tasks from queues that might not have metadata yet
Self::add_pending_tasks_from_queues(&mut conn, &mut tasks).await?;
Ok(tasks)
}
/// Extract task names and IDs from queue messages
///
/// Scans common queues to build a mapping of task IDs to task names,
/// which helps identify task types for completed tasks that may not
/// have this information in their metadata.
async fn get_queue_messages(
conn: &mut MultiplexedConnection,
) -> Result<HashMap<String, String>, BrokerError> {
let mut task_names: HashMap<String, String> = HashMap::new();
let queue_names = vec!["celery", "default", "priority"];
for queue_name in &queue_names {
match conn.llen::<_, u64>(queue_name).await {
Ok(queue_length) if queue_length > 0 => {
match conn
.lrange::<_, Vec<String>>(queue_name, 0, MAX_QUEUE_MESSAGES as isize)
.await
{
Ok(messages) => {
for message in &messages {
if let Ok(task_message) = serde_json::from_str::<Value>(message) {
if let Some(headers) = task_message.get("headers") {
if let (Some(task_id), Some(task_name)) = (
headers.get("id").and_then(|id| id.as_str()),
headers.get("task").and_then(|task| task.as_str()),
) {
task_names
.insert(task_id.to_string(), task_name.to_string());
}
}
}
}
}
Err(_) => {
// Skip queue if we can't read messages - continue with other queues
continue;
}
}
}
_ => {
// Skip empty or inaccessible queues
continue;
}
}
}
Ok(task_names)
}
/// Parse task metadata from Redis keys
///
/// Processes completed task metadata stored in Redis to extract task
/// information including status, results, and execution details.
async fn parse_task_metadata(
conn: &mut MultiplexedConnection,
tasks: &mut Vec<Task>,
task_names: &HashMap<String, String>,
) -> Result<(), BrokerError> {
let task_keys: Vec<String> = conn.keys("celery-task-meta-*").await.map_err(|e| {
BrokerError::OperationError(format!("Failed to get task metadata keys: {e}"))
})?;
for key in task_keys.iter().take(MAX_TASK_RESULTS) {
match conn.get::<_, String>(key).await {
Ok(data) => {
match serde_json::from_str::<Value>(&data) {
Ok(task_data) => {
match Self::extract_task_from_metadata(key, &task_data, task_names) {
Ok(task) => tasks.push(task),
Err(_) => {
// Skip malformed task metadata - continue processing
continue;
}
}
}
Err(_) => {
// Skip malformed JSON - continue processing
continue;
}
}
}
Err(_) => {
// Skip inaccessible keys - continue processing
continue;
}
}
}
Ok(())
}
/// Extract task information from metadata
///
/// Converts raw task metadata into a Task struct with all relevant
/// information including status, results, and timing data.
fn extract_task_from_metadata(
key: &str,
task_data: &Value,
task_names: &HashMap<String, String>,
) -> Result<Task, BrokerError> {
let task_id = key
.strip_prefix("celery-task-meta-")
.unwrap_or("unknown")
.to_string();
let timestamp = Self::parse_timestamp(task_data);
let task_name = Self::get_task_name(&task_id, task_data, task_names);
let status = Self::parse_task_status(task_data);
Ok(Task {
id: task_id,
name: task_name,
args: task_data
.get("args")
.map(|a| a.to_string())
.unwrap_or_else(|| "[]".to_string()),
kwargs: task_data
.get("kwargs")
.map(|k| k.to_string())
.unwrap_or_else(|| "{}".to_string()),
status,
worker: None, // Task metadata doesn't contain worker hostname
timestamp,
result: task_data.get("result").and_then(|r| {
if r.is_null() {
None
} else {
Some(r.to_string())
}
}),
traceback: task_data
.get("traceback")
.and_then(|t| t.as_str())
.map(|s| s.to_string()),
})
}
/// Parse timestamp from task data
///
/// Extracts and parses the completion timestamp from task metadata,
/// using the current time as fallback if parsing fails.
fn parse_timestamp(task_data: &Value) -> DateTime<Utc> {
if let Some(date_done) = task_data.get("date_done").and_then(|d| d.as_str()) {
date_done
.parse::<DateTime<Utc>>()
.unwrap_or_else(|_| Utc::now())
} else {
Utc::now()
}
}
/// Get task name from various sources
///
/// Attempts to determine the task name from the task names mapping
/// (from queue messages) or task metadata, with fallback to "unknown".
fn get_task_name(
task_id: &str,
task_data: &Value,
task_names: &HashMap<String, String>,
) -> String {
task_names
.get(task_id)
.cloned()
.or_else(|| {
task_data
.get("task")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "unknown".to_string())
}
/// Parse task status from metadata
///
/// Converts string status values from Celery into TaskStatus enum values.
fn parse_task_status(task_data: &Value) -> TaskStatus {
match task_data.get("status").and_then(|s| s.as_str()) {
Some("SUCCESS") => TaskStatus::Success,
Some("FAILURE") => TaskStatus::Failure,
Some("PENDING") => TaskStatus::Pending,
Some("RETRY") => TaskStatus::Retry,
Some("REVOKED") => TaskStatus::Revoked,
Some("STARTED") => TaskStatus::Active,
_ => TaskStatus::Pending,
}
}
/// Add pending tasks from queue messages
///
/// Scans queues for pending tasks that may not have metadata yet
/// and adds them to the task list with PENDING status.
async fn add_pending_tasks_from_queues(
conn: &mut MultiplexedConnection,
tasks: &mut Vec<Task>,
) -> Result<(), BrokerError> {
let queue_names = vec!["celery", "default", "priority"];
for queue_name in &queue_names {
match conn.llen::<_, u64>(queue_name).await {
Ok(queue_length) if queue_length > 0 => {
match conn
.lrange::<_, Vec<String>>(queue_name, 0, MAX_PENDING_TASKS as isize)
.await
{
Ok(messages) => {
for message in &messages {
if let Ok(task_message) = serde_json::from_str::<Value>(message) {
match Self::parse_task_message(&task_message, tasks) {
Ok(Some(task)) => tasks.push(task),
Ok(None) => continue, // Task already exists or invalid
Err(_) => continue, // Skip malformed message
}
}
}
}
Err(_) => {
// Skip queue if we can't read messages
continue;
}
}
}
_ => {
// Skip empty or inaccessible queues
continue;
}
}
}
Ok(())
}
/// Parse task from queue message
///
/// Extracts task information from a queue message, checking if the task
/// already exists to avoid duplicates.
fn parse_task_message(
task_message: &Value,
existing_tasks: &[Task],
) -> Result<Option<Task>, BrokerError> {
if let Some(headers) = task_message.get("headers") {
if let (Some(task_id), Some(task_name)) = (
headers.get("id").and_then(|id| id.as_str()),
headers.get("task").and_then(|task| task.as_str()),
) {
// Only add if not already in our task list
if !existing_tasks.iter().any(|t| t.id == task_id) {
let (args, kwargs) = Self::decode_task_body(task_message);
return Ok(Some(Task {
id: task_id.to_string(),
name: task_name.to_string(),
args,
kwargs,
status: TaskStatus::Pending,
worker: None,
timestamp: Utc::now(),
result: None,
traceback: None,
}));
}
}
}
Ok(None)
}
/// Decode base64-encoded task body
///
/// Attempts to decode the task body from base64 and extract
/// arguments and keyword arguments from the Celery message format.
fn decode_task_body(task_message: &Value) -> (String, String) {
if let Some(body) = task_message.get("body").and_then(|b| b.as_str()) {
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(body) {
if let Ok(body_str) = String::from_utf8(decoded) {
if let Ok(body_json) = serde_json::from_str::<Value>(&body_str) {
let args = body_json
.get(0)
.map(|a| a.to_string())
.unwrap_or_else(|| "[]".to_string());
let kwargs = body_json
.get(1)
.map(|k| k.to_string())
.unwrap_or_else(|| "{}".to_string());
return (args, kwargs);
}
}
}
}
("[]".to_string(), "{}".to_string())
}
}
</file>
<file path="src/broker/redis/facade.rs">
use crate::broker::redis::operations::TaskOperations;
use crate::broker::redis::pool::ConnectionPool;
use crate::broker::redis::protocol::ProtocolParser;
use crate::error::BrokerError;
use crate::models::{Queue, Task, Worker};
use std::sync::Arc;
use tracing::{debug, error, info, instrument, warn};
/// BrokerFacade provides a clean, high-level interface for Redis broker operations.
/// It encapsulates connection management, error handling, and operation complexity.
pub struct BrokerFacade {
pool: Arc<ConnectionPool>,
}
impl BrokerFacade {
pub async fn new(url: &str) -> Result<Self, BrokerError> {
info!(
"Creating new Redis broker facade for URL: {}",
url.split('@').next_back().unwrap_or("hidden")
);
let pool = ConnectionPool::new(url, Some(10)).await.map_err(|e| {
error!("Failed to create connection pool: {}", e);
e
})?;
info!("Redis broker facade created successfully");
Ok(Self {
pool: Arc::new(pool),
})
}
/// Get all workers with comprehensive error handling and logging
#[instrument(skip(self), name = "get_workers")]
pub async fn get_workers(&self) -> Result<Vec<Worker>, BrokerError> {
debug!("Fetching workers from Redis");
let connection = self.get_pooled_connection("get_workers").await?;
match ProtocolParser::parse_workers(&connection).await {
Ok(workers) => {
info!("Successfully retrieved {} workers", workers.len());
debug!(
"Workers: {:?}",
workers.iter().map(|w| &w.hostname).collect::<Vec<_>>()
);
Ok(workers)
}
Err(e) => {
error!("Failed to parse workers: {}", e);
Err(self.add_operation_context(e, "get_workers"))
}
}
}
/// Get all tasks with comprehensive error handling and logging
#[instrument(skip(self), name = "get_tasks")]
pub async fn get_tasks(&self) -> Result<Vec<Task>, BrokerError> {
debug!("Fetching tasks from Redis");
let connection = self.get_pooled_connection("get_tasks").await?;
match ProtocolParser::parse_tasks(&connection).await {
Ok(tasks) => {
info!("Successfully retrieved {} tasks", tasks.len());
debug!(
"Task statuses: {:?}",
tasks.iter().map(|t| &t.status).collect::<Vec<_>>()
);
Ok(tasks)
}
Err(e) => {
error!("Failed to parse tasks: {}", e);
Err(self.add_operation_context(e, "get_tasks"))
}
}
}
/// Get all queues with comprehensive error handling and logging
#[instrument(skip(self), name = "get_queues")]
pub async fn get_queues(&self) -> Result<Vec<Queue>, BrokerError> {
debug!("Fetching queues from Redis");
let connection = self.get_pooled_connection("get_queues").await?;
match ProtocolParser::parse_queues(&connection).await {
Ok(queues) => {
info!("Successfully retrieved {} queues", queues.len());
debug!(
"Queue names: {:?}",
queues.iter().map(|q| &q.name).collect::<Vec<_>>()
);
Ok(queues)
}
Err(e) => {
error!("Failed to parse queues: {}", e);
Err(self.add_operation_context(e, "get_queues"))
}
}
}
/// Retry a task with validation and comprehensive error handling
#[instrument(skip(self), fields(task_id = %task_id), name = "retry_task")]
pub async fn retry_task(&self, task_id: &str) -> Result<(), BrokerError> {
info!("Retrying task: {}", task_id);
if task_id.is_empty() {
warn!("Empty task ID provided for retry operation");
return Err(BrokerError::OperationError(
"Task ID cannot be empty".to_string(),
));
}
let connection = self.get_pooled_connection("retry_task").await?;
match TaskOperations::retry_task(&connection, task_id).await {
Ok(()) => {
info!("Successfully retried task: {}", task_id);
Ok(())
}
Err(e) => {
error!("Failed to retry task {}: {}", task_id, e);
Err(self.add_operation_context(e, "retry_task"))
}
}
}
/// Revoke a task with validation and comprehensive error handling
#[instrument(skip(self), fields(task_id = %task_id), name = "revoke_task")]
pub async fn revoke_task(&self, task_id: &str) -> Result<(), BrokerError> {
info!("Revoking task: {}", task_id);
if task_id.is_empty() {
warn!("Empty task ID provided for revoke operation");
return Err(BrokerError::OperationError(
"Task ID cannot be empty".to_string(),
));
}
let connection = self.get_pooled_connection("revoke_task").await?;
match TaskOperations::revoke_task(&connection, task_id).await {
Ok(()) => {
info!("Successfully revoked task: {}", task_id);
Ok(())
}
Err(e) => {
error!("Failed to revoke task {}: {}", task_id, e);
Err(self.add_operation_context(e, "revoke_task"))
}
}
}
/// Purge a queue with validation and comprehensive error handling
#[instrument(skip(self), fields(queue_name = %queue_name), name = "purge_queue")]
pub async fn purge_queue(&self, queue_name: &str) -> Result<u64, BrokerError> {
info!("Purging queue: {}", queue_name);
if queue_name.is_empty() {
warn!("Empty queue name provided for purge operation");
return Err(BrokerError::OperationError(
"Queue name cannot be empty".to_string(),
));
}
let connection = self.get_pooled_connection("purge_queue").await?;
match TaskOperations::purge_queue(&connection, queue_name).await {
Ok(purged_count) => {
info!(
"Successfully purged {} messages from queue: {}",
purged_count, queue_name
);
Ok(purged_count)
}
Err(e) => {
error!("Failed to purge queue {}: {}", queue_name, e);
Err(self.add_operation_context(e, "purge_queue"))
}
}
}
/// Perform health check on the connection pool
#[instrument(skip(self), name = "health_check")]
pub async fn health_check(&self) -> Result<(), BrokerError> {
debug!("Performing health check on connection pool");
match self.pool.health_check().await {
Ok(()) => {
debug!("Health check passed");
Ok(())
}
Err(e) => {
warn!("Health check failed: {}", e);
Err(self.add_operation_context(e, "health_check"))
}
}
}
/// Get statistics about the connection pool
#[allow(dead_code)]
pub async fn get_pool_stats(&self) -> PoolStats {
// This is a simplified implementation - in a real scenario,
// we'd track more detailed statistics
PoolStats {
active_connections: 1, // Simplified
total_connections: 1, // Simplified
healthy_connections: 1, // Simplified
}
}
/// Internal method to get a connection from the pool with context
async fn get_pooled_connection(
&self,
operation: &str,
) -> Result<redis::aio::MultiplexedConnection, BrokerError> {
debug!("Getting pooled connection for operation: {}", operation);
self.pool.get_connection().await.map_err(|e| {
error!("Failed to get pooled connection for {}: {}", operation, e);
self.add_operation_context(e, operation)
})
}
/// Add contextual information to errors for better debugging
fn add_operation_context(&self, error: BrokerError, operation: &str) -> BrokerError {
match error {
BrokerError::ConnectionError(msg) => {
BrokerError::ConnectionError(format!("Operation '{operation}': {msg}"))
}
BrokerError::OperationError(msg) => {
BrokerError::OperationError(format!("Operation '{operation}': {msg}"))
}
other => other, // Don't modify other error types
}
}
}
/// Statistics about the connection pool
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PoolStats {
pub active_connections: usize,
pub total_connections: usize,
pub healthy_connections: usize,
}
impl Drop for BrokerFacade {
fn drop(&mut self) {
debug!("BrokerFacade being dropped");
// Pool cleanup will happen automatically when Arc is dropped
}
}
</file>
<file path="src/broker/redis/operations.rs">
use crate::error::BrokerError;
use redis::aio::MultiplexedConnection;
use redis::AsyncCommands;
use serde_json::Value;
/// Input validation utilities for Redis operations
mod validation {
use crate::error::BrokerError;
/// Maximum allowed length for task IDs (based on Celery UUID format)
const MAX_TASK_ID_LENGTH: usize = 36;
/// Maximum allowed length for queue names
const MAX_QUEUE_NAME_LENGTH: usize = 255;
/// Valid characters for task IDs (UUID format and alphanumeric)
const TASK_ID_CHARS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.";
/// Valid characters for queue names (alphanumeric, dots, underscores, hyphens)
const QUEUE_NAME_CHARS: &str =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-";
/// Validate task ID format and content
pub fn validate_task_id(task_id: &str) -> Result<(), BrokerError> {
if task_id.is_empty() {
return Err(BrokerError::ValidationError(
"Task ID cannot be empty".to_string(),
));
}
if task_id.len() > MAX_TASK_ID_LENGTH {
return Err(BrokerError::ValidationError(format!(
"Task ID exceeds maximum length of {MAX_TASK_ID_LENGTH} characters"
)));
}
// Check for valid UUID-like format (8-4-4-4-12 pattern)
if task_id.len() == 36 {
let parts: Vec<&str> = task_id.split('-').collect();
if parts.len() != 5
|| parts[0].len() != 8
|| parts[1].len() != 4
|| parts[2].len() != 4
|| parts[3].len() != 4
|| parts[4].len() != 12
{
return Err(BrokerError::ValidationError(
"Task ID must be in valid UUID format (8-4-4-4-12)".to_string(),
));
}
}
// Validate characters
for ch in task_id.chars() {
if !TASK_ID_CHARS.contains(ch) {
return Err(BrokerError::ValidationError(format!(
"Task ID contains invalid character: '{ch}'"
)));
}
}
Ok(())
}
/// Validate queue name format and content
pub fn validate_queue_name(queue_name: &str) -> Result<(), BrokerError> {
if queue_name.is_empty() {
return Err(BrokerError::ValidationError(
"Queue name cannot be empty".to_string(),
));
}
if queue_name.len() > MAX_QUEUE_NAME_LENGTH {
return Err(BrokerError::ValidationError(format!(
"Queue name exceeds maximum length of {MAX_QUEUE_NAME_LENGTH} characters"
)));
}
// Validate characters
for ch in queue_name.chars() {
if !QUEUE_NAME_CHARS.contains(ch) {
return Err(BrokerError::ValidationError(format!(
"Queue name contains invalid character: '{ch}'"
)));
}
}
// Additional security checks
if queue_name.starts_with('.') || queue_name.ends_with('.') {
return Err(BrokerError::ValidationError(
"Queue name cannot start or end with a dot".to_string(),
));
}
if queue_name.contains("..") {
return Err(BrokerError::ValidationError(
"Queue name cannot contain consecutive dots".to_string(),
));
}
Ok(())
}
/// Sanitize Redis key to prevent injection attacks
pub fn sanitize_redis_key(key: &str) -> Result<String, BrokerError> {
if key.is_empty() {
return Err(BrokerError::ValidationError(
"Redis key cannot be empty".to_string(),
));
}
if key.len() > 512 {
return Err(BrokerError::ValidationError(
"Redis key exceeds maximum length of 512 characters".to_string(),
));
}
// Check for dangerous patterns
let dangerous_patterns = [
"EVAL",
"SCRIPT",
"FLUSHALL",
"FLUSHDB",
"CONFIG",
"SHUTDOWN",
"DEBUG",
"SAVE",
"BGSAVE",
"BGREWRITEAOF",
"LASTSAVE",
];
let key_upper = key.to_uppercase();
for pattern in &dangerous_patterns {
if key_upper.contains(pattern) {
return Err(BrokerError::ValidationError(format!(
"Redis key contains dangerous pattern: {pattern}"
)));
}
}
// Only allow safe characters in keys
const SAFE_KEY_CHARS: &str =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._:-";
for ch in key.chars() {
if !SAFE_KEY_CHARS.contains(ch) {
return Err(BrokerError::ValidationError(format!(
"Redis key contains unsafe character: '{ch}'"
)));
}
}
Ok(key.to_string())
}
}
pub struct TaskOperations;
impl TaskOperations {
pub async fn retry_task(
connection: &MultiplexedConnection,
task_id: &str,
) -> Result<(), BrokerError> {
// Validate input
validation::validate_task_id(task_id)?;
let mut conn = connection.clone();
// Get the task metadata to extract task information
let task_key = validation::sanitize_redis_key(&format!("celery-task-meta-{task_id}"))?;
let task_data: Option<String> = conn
.get(&task_key)
.await
.map_err(|e| BrokerError::OperationError(e.to_string()))?;
let task_data = task_data
.ok_or_else(|| BrokerError::OperationError(format!("Task {task_id} not found")))?;
let task_json: Value = serde_json::from_str(&task_data)
.map_err(|e| BrokerError::OperationError(e.to_string()))?;
// Only retry failed tasks
let status = task_json
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("");
if status != "FAILURE" {
return Err(BrokerError::OperationError(format!(
"Can only retry failed tasks, task {task_id} is {status}"
)));
}
// For a proper retry, we would need the original task message with args/kwargs
// Since we only have the result metadata, we'll update the status to indicate retry
let mut updated_task = task_json.clone();
updated_task["status"] = Value::String("RETRY".to_string());
updated_task["retries"] = Value::Number(
(task_json
.get("retries")
.and_then(|r| r.as_i64())
.unwrap_or(0)
+ 1)
.into(),
);
// Update the task metadata
let updated_data = serde_json::to_string(&updated_task)
.map_err(|e| BrokerError::OperationError(e.to_string()))?;
conn.set::<_, _, ()>(&task_key, updated_data)
.await
.map_err(|e| BrokerError::OperationError(e.to_string()))?;
// Note: In a real implementation, we would republish the original task message
// to the appropriate queue, but that requires storing the original message
Ok(())
}
pub async fn revoke_task(
connection: &MultiplexedConnection,
task_id: &str,
) -> Result<(), BrokerError> {
// Validate input
validation::validate_task_id(task_id)?;
let mut conn = connection.clone();
// Add task to Celery's revoked tasks set
let revoked_key = validation::sanitize_redis_key("revoked")?;
conn.sadd::<_, _, ()>(&revoked_key, task_id)
.await
.map_err(|e| BrokerError::OperationError(e.to_string()))?;
// Update task metadata if it exists
let task_key = validation::sanitize_redis_key(&format!("celery-task-meta-{task_id}"))?;
if let Ok(Some(task_data)) = conn.get::<_, Option<String>>(&task_key).await {
if let Ok(mut task_json) = serde_json::from_str::<Value>(&task_data) {
// Update status to revoked
task_json["status"] = Value::String("REVOKED".to_string());
if let Ok(updated_data) = serde_json::to_string(&task_json) {
let _: Result<(), _> = conn.set(&task_key, updated_data).await;
}
}
}
// Note: In a real implementation with active workers, the workers would
// check the revoked set and terminate any running tasks with this ID
Ok(())
}
pub async fn purge_queue(
connection: &MultiplexedConnection,
queue_name: &str,
) -> Result<u64, BrokerError> {
// Validate input
validation::validate_queue_name(queue_name)?;
let sanitized_queue = validation::sanitize_redis_key(queue_name)?;
let mut conn = connection.clone();
// Get current queue length for reporting
let queue_length: u64 = conn
.llen(&sanitized_queue)
.await
.map_err(|e| BrokerError::OperationError(e.to_string()))?;
// Delete all messages from the queue (Redis LIST)
// Using DEL command to completely remove the list
let deleted: u64 = conn
.del(&sanitized_queue)
.await
.map_err(|e| BrokerError::OperationError(e.to_string()))?;
// Return the number of messages that were purged
if deleted > 0 {
Ok(queue_length)
} else {
Ok(0)
}
}
}
</file>
<file path="src/broker/redis/pool.rs">
use crate::error::BrokerError;
use redis::aio::MultiplexedConnection;
use redis::Client;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, Semaphore};
use tokio::time::sleep;
const DEFAULT_POOL_SIZE: usize = 10;
#[allow(dead_code)]
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
const HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(30);
const MAX_RETRY_ATTEMPTS: u32 = 3;
const INITIAL_BACKOFF: Duration = Duration::from_millis(100);
#[derive(Debug)]
pub struct PooledConnection {
pub connection: MultiplexedConnection,
pub last_used: Instant,
pub is_healthy: bool,
}
impl PooledConnection {
fn new(connection: MultiplexedConnection) -> Self {
Self {
connection,
last_used: Instant::now(),
is_healthy: true,
}
}
pub fn mark_used(&mut self) {
self.last_used = Instant::now();
}
pub async fn health_check(&mut self) -> bool {
// Simple ping to check if connection is alive
let mut conn = self.connection.clone();
match tokio::time::timeout(
Duration::from_secs(1),
redis::cmd("PING").query_async::<_, String>(&mut conn),
)
.await
{
Ok(Ok(_)) => {
self.is_healthy = true;
true
}
_ => {
self.is_healthy = false;
false
}
}
}
}
pub struct ConnectionPool {
client: Client,
connections: Arc<Mutex<Vec<PooledConnection>>>,
semaphore: Arc<Semaphore>,
max_size: usize,
}
impl ConnectionPool {
pub async fn new(url: &str, max_size: Option<usize>) -> Result<Self, BrokerError> {
let client = Client::open(url)
.map_err(|e| BrokerError::InvalidUrl(format!("Invalid Redis URL: {e}")))?;
let max_size = max_size.unwrap_or(DEFAULT_POOL_SIZE);
let connections = Arc::new(Mutex::new(Vec::with_capacity(max_size)));
let semaphore = Arc::new(Semaphore::new(max_size));
let pool = Self {
client,
connections,
semaphore,
max_size,
};
// Pre-populate pool with one connection to test connectivity
pool.create_connection().await?;
Ok(pool)
}
async fn create_connection(&self) -> Result<PooledConnection, BrokerError> {
let connection = self
.client
.get_multiplexed_tokio_connection()
.await
.map_err(|e| {
BrokerError::ConnectionError(format!("Failed to create connection: {e}"))
})?;
Ok(PooledConnection::new(connection))
}
pub async fn get_connection(&self) -> Result<MultiplexedConnection, BrokerError> {
// Acquire semaphore permit first to limit concurrent connections
let _permit = self
.semaphore
.clone()
.acquire_owned()
.await
.map_err(|_| BrokerError::ConnectionError("Pool semaphore error".to_string()))?;
// Try to get an existing healthy connection
let mut connections = self.connections.lock().await;
// Look for a healthy connection
if let Some(index) = connections.iter().position(|conn| conn.is_healthy) {
let mut pooled_conn = connections.remove(index);
pooled_conn.mark_used();
// Quick health check for connections that haven't been used recently
if pooled_conn.last_used.elapsed() > HEALTH_CHECK_INTERVAL
&& !pooled_conn.health_check().await
{
// Connection is unhealthy, create a new one
drop(connections); // Release lock before creating new connection
return self
.create_connection_with_retry()
.await
.map(|conn| conn.connection);
}
let connection = pooled_conn.connection.clone();
connections.push(pooled_conn); // Return to pool
return Ok(connection);
}
// No healthy connections available, create new one if under max size
if connections.len() < self.max_size {
drop(connections); // Release lock before creating new connection
return self
.create_connection_with_retry()
.await
.map(|conn| conn.connection);
}
// Pool is full, return the oldest connection
if let Some(mut pooled_conn) = connections.pop() {
pooled_conn.mark_used();
let connection = pooled_conn.connection.clone();
connections.push(pooled_conn);
Ok(connection)
} else {
// Should not happen, but fallback to creating new connection
drop(connections);
self.create_connection_with_retry()
.await
.map(|conn| conn.connection)
}
}
async fn create_connection_with_retry(&self) -> Result<PooledConnection, BrokerError> {
let mut attempt = 0;
let mut backoff = INITIAL_BACKOFF;
while attempt < MAX_RETRY_ATTEMPTS {
match self.create_connection().await {
Ok(conn) => return Ok(conn),
Err(e) if attempt == MAX_RETRY_ATTEMPTS - 1 => return Err(e),
Err(_) => {
attempt += 1;
sleep(backoff).await;
backoff = std::cmp::min(backoff * 2, Duration::from_secs(5));
}
}
}
Err(BrokerError::ConnectionError(
"Failed to create connection after retries".to_string(),
))
}
#[allow(dead_code)]
pub async fn return_connection(&self, connection: MultiplexedConnection) {
let mut connections = self.connections.lock().await;
if connections.len() < self.max_size {
connections.push(PooledConnection::new(connection));
}
// If pool is full, just drop the connection
}
pub async fn health_check(&self) -> Result<(), BrokerError> {
let mut connections = self.connections.lock().await;
// Check health of all pooled connections
for conn in connections.iter_mut() {
if !conn.health_check().await {
// Mark as unhealthy - it will be replaced on next use
conn.is_healthy = false;
}
}
// Remove unhealthy connections
connections.retain(|conn| conn.is_healthy);
Ok(())
}
#[allow(dead_code)]
pub async fn close(&self) {
let mut connections = self.connections.lock().await;
connections.clear();
}
}
impl Drop for ConnectionPool {
fn drop(&mut self) {
// Note: Cannot run async code in Drop, so connections will be cleaned up
// when they go out of scope. For proper cleanup, call close() explicitly.
}
}
</file>
<file path="src/models/mod.rs">
pub mod queue;
pub mod task;
pub mod worker;
pub use queue::Queue;
pub use task::{Task, TaskStatus};
pub use worker::{Worker, WorkerStatus};
</file>
<file path="src/models/queue.rs">
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Queue {
pub name: String,
pub length: u64,
pub consumers: u32,
}
impl Queue {
/// Basic constructor for creating a new Queue - kept for future API use
#[allow(dead_code)]
pub fn new(name: String) -> Self {
Self {
name,
length: 0,
consumers: 0,
}
}
pub fn is_empty(&self) -> bool {
self.length == 0
}
pub fn has_consumers(&self) -> bool {
self.consumers > 0
}
}
</file>
<file path="src/models/worker.rs">
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Worker {
pub hostname: String,
pub status: WorkerStatus,
pub concurrency: u32,
pub queues: Vec<String>,
pub active_tasks: Vec<String>,
pub processed: u64,
pub failed: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum WorkerStatus {
Online,
Offline,
}
impl Worker {
/// Basic constructor for creating a new Worker - kept for future API use
#[allow(dead_code)]
pub fn new(hostname: String) -> Self {
Self {
hostname,
status: WorkerStatus::Offline,
concurrency: 1,
queues: Vec::new(),
active_tasks: Vec::new(),
processed: 0,
failed: 0,
}
}
pub fn utilization(&self) -> f32 {
if self.concurrency == 0 {
0.0
} else {
(self.active_tasks.len() as f32 / self.concurrency as f32) * 100.0
}
}
}
</file>
<file path="src/ui/modals.rs">
use ratatui::{
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
Frame,
};
use super::layout::centered_rect;
use crate::app::App;
/// Draw the help modal overlay
pub fn draw_help(f: &mut Frame) {
let area = centered_rect(60, 60, f.area());
f.render_widget(Clear, area);
let help_text = vec![
Line::from("LazyCelery - Keyboard Shortcuts"),
Line::from(""),
Line::from("Navigation:"),
Line::from(" Tab - Switch between tabs"),
Line::from(" โ/k - Move up"),
Line::from(" โ/j - Move down"),
Line::from(" Enter/d - View details (in Tasks tab)"),
Line::from(" Esc - Go back"),
Line::from(""),
Line::from("Actions:"),
Line::from(" / - Search"),
Line::from(" p - Purge queue (in Queues tab)"),
Line::from(" r - Retry task (in Tasks tab)"),
Line::from(" x - Revoke task (in Tasks tab)"),
Line::from(""),
Line::from("General:"),
Line::from(" ? - Toggle this help"),
Line::from(" q - Quit application"),
Line::from(""),
Line::from("Press any key to close this help..."),
];
let help = Paragraph::new(help_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Help ")
.style(Style::default().bg(Color::Black)),
)
.wrap(Wrap { trim: true });
f.render_widget(help, area);
}
/// Draw the confirmation dialog modal
pub fn draw_confirmation_dialog(f: &mut Frame, app: &App) {
let area = centered_rect(50, 30, f.area());
f.render_widget(Clear, area);
let confirmation_text = vec![
Line::from(""),
Line::from(app.confirmation_message.clone()),
Line::from(""),
Line::from("Press [y/Enter] to confirm or [n/Esc] to cancel"),
];
let confirmation = Paragraph::new(confirmation_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Confirmation ")
.style(Style::default().bg(Color::Black).fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(confirmation, area);
}
/// Draw the detailed task information modal
pub fn draw_task_details_modal(f: &mut Frame, app: &App) {
if let Some(task) = &app.selected_task_details {
let popup_area = centered_rect(80, 70, f.area());
// Clear background
f.render_widget(Clear, popup_area);
// Draw modal background
f.render_widget(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Task Details ")
.style(Style::default().bg(Color::Black)),
popup_area,
);
let inner_area = Layout::default()
.margin(1)
.constraints([Constraint::Percentage(100)])
.split(popup_area)[0];
// Create task details content
let details_lines = build_task_details_content(task);
let paragraph = Paragraph::new(details_lines)
.wrap(Wrap { trim: true })
.scroll((0, 0));
f.render_widget(paragraph, inner_area);
}
}
/// Build the content lines for task details modal
fn build_task_details_content(task: &crate::models::Task) -> Vec<Line> {
let mut details_lines = vec![
Line::from(vec![
Span::styled(
"ID: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(task.id.clone()),
]),
Line::from(vec![
Span::styled(
"Name: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(task.name.clone()),
]),
Line::from(vec![
Span::styled(
"Status: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:?}", task.status),
Style::default().fg(get_status_color(&task.status)),
),
]),
Line::from(vec![
Span::styled(
"Worker: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(task.worker.as_deref().unwrap_or("Unknown").to_string()),
]),
Line::from(vec![
Span::styled(
"Queue: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("default".to_string()),
]),
Line::from(vec![
Span::styled(
"Timestamp: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(task.timestamp.to_string()),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Arguments: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(task.args.as_str()),
Line::from(""),
Line::from(vec![Span::styled(
"Keyword Arguments: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(task.kwargs.as_str()),
Line::from(""),
Line::from(vec![Span::styled(
"Result: ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(task.result.as_deref().unwrap_or("None")),
];
// Add traceback if available and task failed
if task.status == crate::models::TaskStatus::Failure {
if let Some(traceback) = &task.traceback {
details_lines.push(Line::from(""));
details_lines.push(Line::from(vec![Span::styled(
"Traceback: ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)]));
// Split traceback into lines and add them
for line in traceback.lines() {
details_lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(Color::Red),
)));
}
}
}
// Add footer
details_lines.push(Line::from(""));
details_lines.push(Line::from(vec![Span::styled(
"Press any key to close",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
)]));
details_lines
}
/// Get the appropriate color for a task status
fn get_status_color(status: &crate::models::TaskStatus) -> Color {
match status {
crate::models::TaskStatus::Success => Color::Green,
crate::models::TaskStatus::Failure => Color::Red,
crate::models::TaskStatus::Retry => Color::Yellow,
crate::models::TaskStatus::Pending => Color::Blue,
crate::models::TaskStatus::Revoked => Color::Magenta,
_ => Color::White,
}
}
</file>
<file path="src/lib.rs">
pub mod app;
pub mod broker;
pub mod config;
pub mod error;
pub mod models;
pub mod ui;
pub mod utils;
</file>
<file path="tests/redis_test_utils.rs">
//! Redis Test Utilities
//!
//! This module provides shared utilities for Redis broker testing to eliminate
//! duplication and improve test isolation and maintainability.
use anyhow::Result;
use base64::Engine;
use lazycelery::broker::{redis::RedisBroker, Broker};
use lazycelery::models::{TaskStatus, WorkerStatus};
use redis::{AsyncCommands, Client};
use serde_json::json;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
// Global counter for database isolation
static DB_COUNTER: AtomicU8 = AtomicU8::new(2);
/// Test database configuration that ensures isolation between tests
#[derive(Clone)]
pub struct TestDatabase {
pub database_id: u8,
pub url: String,
pub client: Option<Client>,
}
impl TestDatabase {
/// Create a new isolated test database
pub async fn new() -> Result<Self> {
let db_id = DB_COUNTER.fetch_add(1, Ordering::SeqCst);
if db_id > 15 {
// Redis supports databases 0-15 by default
return Err(anyhow::anyhow!("Too many concurrent tests"));
}
let url = format!("redis://127.0.0.1:6379/{db_id}");
Ok(TestDatabase {
database_id: db_id,
url,
client: None,
})
}
/// Setup and initialize the test database
pub async fn setup(&mut self) -> Result<Client> {
let client = Client::open(self.url.as_str())?;
let mut conn = client.get_multiplexed_tokio_connection().await?;
// Clear the database to ensure clean state
let _: () = redis::cmd("FLUSHDB").query_async(&mut conn).await?;
self.client = Some(client.clone());
Ok(client)
}
/// Get database client, setting up if needed
pub async fn client(&mut self) -> Result<Client> {
if let Some(ref client) = self.client {
Ok(client.clone())
} else {
self.setup().await
}
}
/// Create a broker instance for this test database
pub async fn broker(&mut self) -> Result<RedisBroker> {
match RedisBroker::connect(&self.url).await {
Ok(broker) => Ok(broker),
Err(_) => Err(anyhow::anyhow!("Redis not available for testing")),
}
}
/// Cleanup the test database
pub async fn cleanup(&mut self) -> Result<()> {
if let Some(ref client) = self.client {
let mut conn = client.get_multiplexed_tokio_connection().await?;
let _: () = redis::cmd("FLUSHDB").query_async(&mut conn).await?;
}
Ok(())
}
}
/// Builder for creating various types of test data
pub struct TestDataBuilder {
client: Client,
}
#[allow(dead_code)]
impl TestDataBuilder {
pub fn new(client: Client) -> Self {
Self { client }
}
/// Add basic test task metadata
pub async fn add_basic_tasks(&self) -> Result<()> {
let mut conn = self.client.get_multiplexed_tokio_connection().await?;
// Success task
let success_task = json!({
"task": "myapp.tasks.process_data",
"args": "[1, 2, 3]",
"kwargs": "{\"timeout\": 30}",
"status": "SUCCESS",
"hostname": "worker-1",
"result": "\"Processing completed\"",
"traceback": null
});
let _: () = conn
.set("celery-task-meta-basic-success-1", success_task.to_string())
.await?;
// Failed task
let failed_task = json!({
"task": "myapp.tasks.failing_task",
"args": "[]",
"kwargs": "{}",
"status": "FAILURE",
"hostname": "worker-2",
"result": null,
"traceback": "Traceback (most recent call last):\n File...\nZeroDivisionError: division by zero"
});
let _: () = conn
.set("celery-task-meta-basic-failure-1", failed_task.to_string())
.await?;
Ok(())
}
/// Add real Celery protocol formatted data
pub async fn add_real_celery_data(&self) -> Result<()> {
let mut conn = self.client.get_multiplexed_tokio_connection().await?;
// Real Celery task metadata (without hostname - as Celery actually stores)
let successful_task = json!({
"status": "SUCCESS",
"result": 42,
"traceback": null,
"children": [],
"date_done": "2024-01-15T10:30:00.123456+00:00",
"task_id": "real-success-task"
});
let failed_task = json!({
"status": "FAILURE",
"result": null,
"traceback": "Traceback (most recent call last):\n File \"test.py\", line 1\n raise ValueError(\"Test error\")\nValueError: Test error",
"children": [],
"date_done": "2024-01-15T10:35:00.123456+00:00",
"task_id": "real-failure-task"
});
let pending_task = json!({
"status": "PENDING",
"result": null,
"traceback": null,
"children": [],
"task_id": "real-pending-task"
});
// Store task metadata
let _: () = conn
.set(
"celery-task-meta-real-success-task",
successful_task.to_string(),
)
.await?;
let _: () = conn
.set(
"celery-task-meta-real-failure-task",
failed_task.to_string(),
)
.await?;
let _: () = conn
.set(
"celery-task-meta-real-pending-task",
pending_task.to_string(),
)
.await?;
// Add real Celery queue message
let task_message = json!({
"body": base64::engine::general_purpose::STANDARD.encode(r#"[[1, 2], {"timeout": 30}, {"callbacks": null, "errbacks": null, "chain": null, "chord": null}]"#),
"content-encoding": "utf-8",
"content-type": "application/json",
"headers": {
"lang": "py",
"task": "myapp.tasks.add_numbers",
"id": "real-queue-task",
"shadow": null,
"eta": null,
"expires": null,
"group": null,
"group_index": null,
"retries": 0,
"timelimit": [null, null],
"root_id": "real-queue-task",
"parent_id": null,
"argsrepr": "(1, 2)",
"kwargsrepr": "{\"timeout\": 30}",
"origin": "gen123@worker-host-1",
"ignore_result": false,
"replaced_task_nesting": 0,
"stamped_headers": null,
"stamps": {}
},
"properties": {
"correlation_id": "real-queue-task",
"reply_to": "reply-queue",
"delivery_mode": 2,
"delivery_info": {"exchange": "", "routing_key": "celery"},
"priority": 0,
"body_encoding": "base64",
"delivery_tag": "delivery-tag-123"
}
});
// Add to queue
let _: () = conn.lpush("celery", task_message.to_string()).await?;
// Add kombu bindings
let _: () = conn.set("_kombu.binding.celery", "").await?;
let _: () = conn.set("_kombu.binding.priority", "").await?;
// Add revoked tasks
let _: () = conn.sadd("revoked", "revoked-task-1").await?;
Ok(())
}
/// Add queue test data
pub async fn add_queue_data(&self) -> Result<()> {
let mut conn = self.client.get_multiplexed_tokio_connection().await?;
// Add items to queues
let _: () = conn.lpush("celery", "task1").await?;
let _: () = conn.lpush("celery", "task2").await?;
let _: () = conn.lpush("priority", "urgent_task").await?;
Ok(())
}
/// Add performance test data (many tasks)
pub async fn add_performance_data(&self, count: usize) -> Result<()> {
let mut conn = self.client.get_multiplexed_tokio_connection().await?;
for i in 0..count {
let task_data = json!({
"status": match i % 3 {
0 => "SUCCESS",
1 => "FAILURE",
_ => "PENDING"
},
"result": i,
"task_id": format!("perf-task-{}", i)
});
let _: () = conn
.set(
format!("celery-task-meta-perf-task-{i}"),
task_data.to_string(),
)
.await?;
}
Ok(())
}
/// Add malformed data for edge case testing
pub async fn add_malformed_data(&self) -> Result<()> {
let mut conn = self.client.get_multiplexed_tokio_connection().await?;
// Various malformed data scenarios
let _: () = conn
.set("celery-task-meta-malformed", "invalid json")
.await?;
let _: () = conn.set("celery-task-meta-empty", "").await?;
let _: () = conn.set("celery-task-meta-null", "null").await?;
// Incomplete task (missing fields)
let incomplete_task = json!({
"status": "SUCCESS"
// Missing other fields
});
let _: () = conn
.set("celery-task-meta-incomplete", incomplete_task.to_string())
.await?;
Ok(())
}
/// Add specific task for retry testing
pub async fn add_retry_test_task(&self, task_id: &str) -> Result<()> {
let mut conn = self.client.get_multiplexed_tokio_connection().await?;
let failed_task = json!({
"status": "FAILURE",
"result": null,
"traceback": "Test error for retry",
"task_id": task_id,
"retries": 0
});
let _: () = conn
.set(
format!("celery-task-meta-{task_id}"),
failed_task.to_string(),
)
.await?;
Ok(())
}
/// Add custom task with specific properties
pub async fn add_custom_task(
&self,
task_id: &str,
properties: HashMap<&str, serde_json::Value>,
) -> Result<()> {
let mut conn = self.client.get_multiplexed_tokio_connection().await?;
let mut task_data = json!({
"task": "custom.task",
"status": "PENDING",
"args": "[]",
"kwargs": "{}",
"task_id": task_id
});
// Merge custom properties
for (key, value) in properties {
task_data[key] = value;
}
let _: () = conn
.set(format!("celery-task-meta-{task_id}"), task_data.to_string())
.await?;
Ok(())
}
}
/// Assertion helpers for test validation
pub struct TestAssertions;
#[allow(dead_code)]
impl TestAssertions {
/// Assert that a task has expected properties
pub fn assert_task_properties(
tasks: &[lazycelery::models::Task],
task_id: &str,
expected_status: TaskStatus,
should_have_result: bool,
should_have_traceback: bool,
) {
let task = tasks
.iter()
.find(|t| t.id == task_id)
.unwrap_or_else(|| panic!("Task {} not found in {} tasks", task_id, tasks.len()));
assert_eq!(
task.status, expected_status,
"Task {} should have status {:?}, but has {:?}",
task_id, expected_status, task.status
);
if should_have_result {
assert!(task.result.is_some(), "Task {task_id} should have a result");
} else {
assert!(
task.result.is_none() || task.result.as_ref().unwrap().is_empty(),
"Task {task_id} should not have a result"
);
}
if should_have_traceback {
assert!(
task.traceback.is_some(),
"Task {task_id} should have a traceback"
);
}
}
/// Assert worker properties
pub fn assert_worker_properties(
workers: &[lazycelery::models::Worker],
min_count: usize,
should_have_activity: bool,
) {
assert!(
workers.len() >= min_count,
"Expected at least {} workers, found {}",
min_count,
workers.len()
);
if !workers.is_empty() {
let worker = &workers[0];
assert!(
!worker.hostname.is_empty(),
"Worker should have non-empty hostname"
);
assert!(
matches!(worker.status, WorkerStatus::Online | WorkerStatus::Offline),
"Worker should have valid status, got {:?}",
worker.status
);
assert!(
worker.concurrency > 0,
"Worker should have positive concurrency"
);
if should_have_activity {
assert!(
worker.processed > 0 || worker.failed > 0,
"Worker should show some activity (processed: {}, failed: {})",
worker.processed,
worker.failed
);
}
}
}
/// Assert queue properties
pub fn assert_queue_properties(
queues: &[lazycelery::models::Queue],
min_count: usize,
expected_queue: Option<(&str, usize)>,
) {
assert!(
queues.len() >= min_count,
"Expected at least {} queues, found {}",
min_count,
queues.len()
);
if let Some((queue_name, min_length)) = expected_queue {
let queue = queues
.iter()
.find(|q| q.name == queue_name)
.unwrap_or_else(|| panic!("Queue {queue_name} not found"));
assert!(
queue.length >= min_length as u64,
"Queue {} should have at least {} items, has {}",
queue_name,
min_length,
queue.length
);
}
}
/// Assert Redis operation was successful
pub async fn assert_redis_key_exists(
client: &Client,
key: &str,
should_exist: bool,
) -> Result<()> {
let mut conn = client.get_multiplexed_tokio_connection().await?;
let exists: bool = conn.exists(key).await?;
if should_exist {
assert!(exists, "Redis key {key} should exist");
} else {
assert!(!exists, "Redis key {key} should not exist");
}
Ok(())
}
/// Assert task is in revoked set
pub async fn assert_task_revoked(
client: &Client,
task_id: &str,
should_be_revoked: bool,
) -> Result<()> {
let mut conn = client.get_multiplexed_tokio_connection().await?;
let is_revoked: bool = conn.sismember("revoked", task_id).await?;
if should_be_revoked {
assert!(is_revoked, "Task {task_id} should be in revoked set");
} else {
assert!(!is_revoked, "Task {task_id} should not be in revoked set");
}
Ok(())
}
/// Assert task metadata was updated
pub async fn assert_task_metadata_updated(
client: &Client,
task_id: &str,
expected_status: &str,
should_have_retries: bool,
) -> Result<()> {
let mut conn = client.get_multiplexed_tokio_connection().await?;
let key = format!("celery-task-meta-{task_id}");
let data: String = conn.get(&key).await?;
let task_json: serde_json::Value = serde_json::from_str(&data)?;
assert_eq!(
task_json["status"], expected_status,
"Task {} should have status {}, got {}",
task_id, expected_status, task_json["status"]
);
if should_have_retries {
let retries = task_json["retries"].as_i64().unwrap_or(0);
assert!(retries > 0, "Task {task_id} should have retries > 0");
}
Ok(())
}
}
/// Skip test if Redis is not available
pub fn skip_if_redis_unavailable(result: Result<()>) -> Result<()> {
match result {
Err(e) if e.to_string().contains("Redis not available") => {
eprintln!("Skipping integration test: Redis not available");
Ok(())
}
other => other,
}
}
/// Create test database with automatic cleanup
pub async fn with_test_db<F, Fut, T>(test_fn: F) -> Result<T>
where
F: FnOnce(TestDatabase) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut db = TestDatabase::new().await?;
let _client = db
.setup()
.await
.map_err(|_| anyhow::anyhow!("Redis not available"))?;
let result = test_fn(db.clone()).await;
let _ = db.cleanup().await; // Best effort cleanup
result
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_database_isolation() -> Result<()> {
let mut db1 = TestDatabase::new().await?;
let mut db2 = TestDatabase::new().await?;
// Ensure different databases
assert_ne!(db1.database_id, db2.database_id);
assert_ne!(db1.url, db2.url);
// Test that setup works
if db1.setup().await.is_ok() && db2.setup().await.is_ok() {
let client1 = db1.client().await?;
let client2 = db2.client().await?;
let builder1 = TestDataBuilder::new(client1);
let builder2 = TestDataBuilder::new(client2);
// Add different data to each database
builder1.add_basic_tasks().await?;
builder2.add_real_celery_data().await?;
// Verify isolation (each database should only have its own data)
let broker1 = db1.broker().await?;
let broker2 = db2.broker().await?;
let tasks1 = broker1.get_tasks().await?;
let tasks2 = broker2.get_tasks().await?;
// Each should have different tasks
let has_basic = tasks1.iter().any(|t| t.id.starts_with("basic-"));
let has_real = tasks2.iter().any(|t| t.id.starts_with("real-"));
if has_basic && has_real {
// Verify they don't cross-contaminate
assert!(!tasks1.iter().any(|t| t.id.starts_with("real-")));
assert!(!tasks2.iter().any(|t| t.id.starts_with("basic-")));
}
}
Ok(())
}
#[tokio::test]
async fn test_test_data_builder() -> Result<()> {
skip_if_redis_unavailable(
async {
with_test_db(|mut db| async move {
let client = db.client().await?;
let builder = TestDataBuilder::new(client.clone());
// Test basic data creation
builder.add_basic_tasks().await?;
let broker = db.broker().await?;
let tasks = broker.get_tasks().await?;
// Should find the basic tasks
assert!(tasks.iter().any(|t| t.id == "basic-success-1"));
assert!(tasks.iter().any(|t| t.id == "basic-failure-1"));
Ok(())
})
.await
}
.await,
)
}
}
</file>
<file path="tests/test_config.rs">
use lazycelery::config::{BrokerConfig, Config, UiConfig};
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.broker.url, "redis://localhost:6379/0");
assert_eq!(config.broker.timeout, 30);
assert_eq!(config.broker.retry_attempts, 3);
assert_eq!(config.ui.refresh_interval, 1000);
assert_eq!(config.ui.theme, "dark");
}
#[test]
fn test_config_from_file() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("test_config.toml");
let config_content = r#"
[broker]
url = "redis://192.168.1.100:6379/1"
timeout = 60
retry_attempts = 5
[ui]
refresh_interval = 2000
theme = "light"
"#;
fs::write(&config_path, config_content).unwrap();
let config = Config::from_file(config_path).unwrap();
assert_eq!(config.broker.url, "redis://192.168.1.100:6379/1");
assert_eq!(config.broker.timeout, 60);
assert_eq!(config.broker.retry_attempts, 5);
assert_eq!(config.ui.refresh_interval, 2000);
assert_eq!(config.ui.theme, "light");
}
#[test]
fn test_partial_config_from_file() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("partial_config.toml");
let config_content = r#"
[broker]
url = "redis://custom:6379/0"
[ui]
refresh_interval = 500
"#;
fs::write(&config_path, config_content).unwrap();
// This should fail because required fields are missing
let result = Config::from_file(config_path);
assert!(result.is_err());
}
#[test]
fn test_invalid_config_file() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("invalid_config.toml");
let config_content = "invalid toml content {{";
fs::write(&config_path, config_content).unwrap();
let result = Config::from_file(config_path);
assert!(result.is_err());
}
#[test]
fn test_nonexistent_config_file() {
let config_path = PathBuf::from("/nonexistent/path/config.toml");
let result = Config::from_file(config_path);
assert!(result.is_err());
}
#[test]
fn test_config_serialization() {
let config = Config {
broker: BrokerConfig {
url: "amqp://guest:guest@localhost:5672//".to_string(),
timeout: 45,
retry_attempts: 2,
},
ui: UiConfig {
refresh_interval: 3000,
theme: "custom".to_string(),
},
};
let toml_str = toml::to_string(&config).unwrap();
let deserialized: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(config.broker.url, deserialized.broker.url);
assert_eq!(config.broker.timeout, deserialized.broker.timeout);
assert_eq!(config.ui.refresh_interval, deserialized.ui.refresh_interval);
assert_eq!(config.ui.theme, deserialized.ui.theme);
}
</file>
<file path="tests/test_error.rs">
use lazycelery::error::{AppError, BrokerError};
#[test]
fn test_broker_error_display() {
let err = BrokerError::ConnectionError("Failed to connect".to_string());
assert_eq!(err.to_string(), "Connection failed: Failed to connect");
let err = BrokerError::AuthError;
assert_eq!(err.to_string(), "Authentication failed");
let err = BrokerError::OperationError("Operation failed".to_string());
assert_eq!(err.to_string(), "Broker operation failed: Operation failed");
let err = BrokerError::InvalidUrl("bad url".to_string());
assert_eq!(err.to_string(), "Invalid broker URL: bad url");
let err = BrokerError::Timeout;
assert_eq!(err.to_string(), "Timeout occurred");
let err = BrokerError::NotImplemented;
assert_eq!(err.to_string(), "Not implemented");
}
#[test]
fn test_app_error_from_broker_error() {
let broker_err = BrokerError::ConnectionError("test".to_string());
let app_err: AppError = broker_err.into();
match app_err {
AppError::Broker(_) => {}
_ => panic!("Expected AppError::Broker variant"),
}
}
#[test]
fn test_app_error_display() {
let broker_err = BrokerError::AuthError;
let app_err = AppError::Broker(broker_err);
assert_eq!(app_err.to_string(), "Broker error: Authentication failed");
let app_err = AppError::Ui("UI crashed".to_string());
assert_eq!(app_err.to_string(), "UI error: UI crashed");
let app_err = AppError::Config("Invalid config".to_string());
assert_eq!(app_err.to_string(), "Configuration error: Invalid config");
}
</file>
<file path="tests/test_models.rs">
use chrono::Utc;
use lazycelery::models::{Queue, Task, TaskStatus, Worker, WorkerStatus};
#[test]
fn test_worker_creation() {
let worker = Worker {
hostname: "test-worker".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec!["default".to_string()],
active_tasks: vec![],
processed: 100,
failed: 5,
};
assert_eq!(worker.hostname, "test-worker");
assert_eq!(worker.status, WorkerStatus::Online);
assert_eq!(worker.concurrency, 4);
assert_eq!(worker.utilization(), 0.0);
}
#[test]
fn test_worker_utilization() {
let mut worker = Worker {
hostname: "test-worker".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec![],
active_tasks: vec!["task1".to_string(), "task2".to_string()],
processed: 0,
failed: 0,
};
assert_eq!(worker.utilization(), 50.0);
worker.active_tasks.push("task3".to_string());
worker.active_tasks.push("task4".to_string());
assert_eq!(worker.utilization(), 100.0);
// Test edge case: zero concurrency
worker.concurrency = 0;
assert_eq!(worker.utilization(), 0.0);
}
#[test]
fn test_worker_serialization() {
let worker = Worker {
hostname: "worker-1".to_string(),
status: WorkerStatus::Online,
concurrency: 2,
queues: vec!["queue1".to_string()],
active_tasks: vec![],
processed: 50,
failed: 2,
};
let json = serde_json::to_string(&worker).unwrap();
let deserialized: Worker = serde_json::from_str(&json).unwrap();
assert_eq!(worker.hostname, deserialized.hostname);
assert_eq!(worker.processed, deserialized.processed);
}
#[test]
fn test_task_creation() {
let task = Task {
id: "abc123".to_string(),
name: "send_email".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Active,
worker: Some("worker-1".to_string()),
timestamp: Utc::now(),
result: None,
traceback: None,
};
assert_eq!(task.id, "abc123");
assert_eq!(task.name, "send_email");
assert_eq!(task.status, TaskStatus::Active);
}
#[test]
fn test_task_duration() {
let past_time = Utc::now() - chrono::Duration::minutes(5);
let task = Task {
id: "test".to_string(),
name: "test_task".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Success,
worker: None,
timestamp: past_time,
result: None,
traceback: None,
};
let duration = task.duration_since(Utc::now());
assert!(duration.num_minutes() >= 4);
assert!(duration.num_minutes() <= 6);
}
#[test]
fn test_task_serialization() {
let task = Task {
id: "123".to_string(),
name: "process_data".to_string(),
args: "[1, 2, 3]".to_string(),
kwargs: r#"{"key": "value"}"#.to_string(),
status: TaskStatus::Failure,
worker: Some("worker-2".to_string()),
timestamp: Utc::now(),
result: Some("error result".to_string()),
traceback: Some("traceback here".to_string()),
};
let json = serde_json::to_string(&task).unwrap();
let deserialized: Task = serde_json::from_str(&json).unwrap();
assert_eq!(task.id, deserialized.id);
assert_eq!(task.status, deserialized.status);
assert_eq!(task.traceback, deserialized.traceback);
}
#[test]
fn test_queue_creation() {
let queue = Queue {
name: "default".to_string(),
length: 42,
consumers: 3,
};
assert_eq!(queue.name, "default");
assert_eq!(queue.length, 42);
assert!(!queue.is_empty());
assert!(queue.has_consumers());
}
#[test]
fn test_queue_empty_state() {
let queue = Queue {
name: "empty".to_string(),
length: 0,
consumers: 0,
};
assert!(queue.is_empty());
assert!(!queue.has_consumers());
}
#[test]
fn test_queue_serialization() {
let queue = Queue {
name: "priority".to_string(),
length: 100,
consumers: 5,
};
let json = serde_json::to_string(&queue).unwrap();
let deserialized: Queue = serde_json::from_str(&json).unwrap();
assert_eq!(queue.name, deserialized.name);
assert_eq!(queue.length, deserialized.length);
assert_eq!(queue.consumers, deserialized.consumers);
}
#[test]
fn test_task_status_variants() {
let statuses = vec![
TaskStatus::Pending,
TaskStatus::Active,
TaskStatus::Success,
TaskStatus::Failure,
TaskStatus::Retry,
TaskStatus::Revoked,
];
for status in statuses {
let json = serde_json::to_string(&status).unwrap();
let deserialized: TaskStatus = serde_json::from_str(&json).unwrap();
assert_eq!(status, deserialized);
}
}
</file>
<file path="tests/test_utils.rs">
use chrono::{Duration, TimeZone, Utc};
use lazycelery::utils::formatting::{format_duration, format_timestamp, truncate_string};
#[test]
fn test_format_duration() {
let duration = Duration::seconds(45);
assert_eq!(format_duration(duration), "00:45");
let duration = Duration::minutes(5) + Duration::seconds(30);
assert_eq!(format_duration(duration), "05:30");
let duration = Duration::hours(2) + Duration::minutes(15) + Duration::seconds(45);
assert_eq!(format_duration(duration), "02:15:45");
let duration = Duration::hours(10) + Duration::minutes(5) + Duration::seconds(3);
assert_eq!(format_duration(duration), "10:05:03");
}
#[test]
fn test_format_timestamp() {
let timestamp = Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 45).unwrap();
assert_eq!(format_timestamp(timestamp), "2024-01-15 14:30:45");
let timestamp = Utc.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap();
assert_eq!(format_timestamp(timestamp), "2023-12-31 23:59:59");
}
#[test]
fn test_truncate_string() {
assert_eq!(truncate_string("hello", 10), "hello");
assert_eq!(truncate_string("hello world", 8), "hello...");
assert_eq!(
truncate_string("this is a very long string", 10),
"this is..."
);
assert_eq!(truncate_string("short", 5), "short");
assert_eq!(truncate_string("exactly", 7), "exactly");
// Edge case: max_len < 3
assert_eq!(truncate_string("hello", 2), "...");
}
</file>
<file path="Dockerfile">
# Build stage
FROM rust:1.88.0-alpine AS builder
# Install build dependencies
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
# Create app directory
WORKDIR /usr/src/lazycelery
# Copy manifests
COPY Cargo.toml Cargo.lock ./
# Copy source code
COPY src ./src
# Build the application
RUN cargo build --release --target x86_64-unknown-linux-musl
# Runtime stage
FROM alpine:3.19
# Install runtime dependencies
RUN apk add --no-cache ca-certificates
# Copy the binary from builder
COPY --from=builder /usr/src/lazycelery/target/x86_64-unknown-linux-musl/release/lazycelery /usr/local/bin/lazycelery
# Create non-root user
RUN addgroup -g 1000 lazycelery && \
adduser -D -u 1000 -G lazycelery lazycelery
# Switch to non-root user
USER lazycelery
# Set the entrypoint
ENTRYPOINT ["lazycelery"]
# Default command (can be overridden)
CMD ["--help"]
</file>
<file path="ROADMAP.md">
# LazyCelery Project Roadmap
## Project Status
- **Current Phase**: MVP Core Features Complete (v0.2.0)
- **Architecture**: Fully implemented with comprehensive test coverage
- **Real Celery Integration**: โ
Complete with Redis broker
- **Infrastructure**: CI/CD, Docker, documentation ready
- **Next Major Milestone**: Enhanced Monitoring Features (v0.3.0)
## MVP Core Features (v0.2.0) โ
COMPLETE
### Worker Foundation โ
COMPLETE
- [x] Implement async Redis broker client with connection pooling
- [x] Build worker discovery and status monitoring from real Celery data
- [x] Create basic TUI layout with worker list widget
- [x] Add real-time worker status updates (1-second intervals)
- [x] **BONUS**: Real worker statistics from task metadata analysis
### Queue & Task Basics โ
COMPLETE
- [x] Queue monitoring with message counts and consumption rates
- [x] Task listing with status filtering (pending/active/success/failure/retry/revoked)
- [x] Basic task details view (args, kwargs, timestamps, results, tracebacks)
- [x] Search and filter functionality for tasks
- [x] **BONUS**: Dynamic queue discovery from kombu bindings
- [x] **BONUS**: Real Celery task metadata parsing with timestamp support
### Core Actions โ
COMPLETE
- [x] Task retry functionality for failed tasks with proper Celery protocol
- [x] Task revocation for running tasks with revoked set management
- [x] Configuration file support (TOML format)
- [x] Queue purge operations with confirmation dialogs
### Enhanced UX โ
COMPLETE
- [x] Advanced navigation (vim-style keybindings: j/k/h/l/g/G)
- [x] Help system and keyboard shortcut overlays (? key)
- [x] Error handling with user-friendly messages
- [x] Basic metrics display (success rates, queue lengths, worker stats)
- [x] **BONUS**: Search mode with live filtering
- [x] **BONUS**: Tab-based navigation between workers/tasks/queues
### Polish & Reliability โ
COMPLETE
- [x] Connection resilience (auto-reconnect) via Redis multiplexed connection
- [x] Performance optimization for large datasets (limit to 100 tasks for UI responsiveness)
- [x] Memory usage optimization (efficient async operations)
- [x] Comprehensive error recovery (custom error types with thiserror)
- [x] **BONUS**: Comprehensive test suite (75+ tests including stress tests)
- [x] **BONUS**: Base64 decoding for Celery task message bodies
## ๐ v0.2.0 COMPLETE - Major Technical Achievements
### Real Celery Protocol Integration
- โ
**Worker Discovery**: Real worker detection from task metadata and queue message origins
- โ
**Task Management**: Functional retry/revoke operations following Celery protocol
- โ
**Queue Discovery**: Dynamic queue detection from kombu bindings
- โ
**Data Parsing**: Full compatibility with Redis-based Celery broker
### Testing & Quality Assurance
- โ
**75+ Tests**: Comprehensive coverage including unit, integration, and stress tests
- โ
**Real Celery Simulation**: Tests with actual Celery message formats and Redis structures
- โ
**Performance Validation**: Stress tested with 500+ tasks
- โ
**Error Resilience**: Robust handling of malformed data and edge cases
### UI/UX Excellence
- โ
**Terminal UI**: Professional ratatui-based interface with 10 FPS optimization
- โ
**Vim-style Navigation**: Intuitive keyboard controls for power users
- โ
**Real-time Updates**: Live monitoring with 1-second refresh intervals
- โ
**Search & Filter**: Advanced filtering capabilities across all data types
### Architecture & Performance
- โ
**Async Foundation**: Fully async Tokio-based architecture
- โ
**Efficient Redis**: Multiplexed connections with connection pooling
- โ
**Memory Optimized**: Smart pagination and data limiting
- โ
**Production Ready**: Error handling, logging, and configuration management
## Phase 2: Enhanced Monitoring (Next Priority)
### Immediate Next Steps (v0.3.0)
- [ ] AMQP/RabbitMQ broker support (extend beyond Redis)
- [ ] Enhanced task name display (extract from queue messages)
- [ ] Real-time task progress indicators
- [ ] Worker heartbeat detection via inspect commands
### Advanced Worker Management (v0.4.0)
- [ ] Worker control (start/stop/restart) with proper permissions
- [ ] Worker pool scaling controls
- [ ] Worker resource monitoring (CPU, memory)
- [ ] Worker heartbeat visualization
- [ ] Historical worker performance data
### Enhanced Queue Features
- [ ] Priority queue visualization
- [ ] Queue routing visualization
- [ ] Dead letter queue support
- [ ] Queue performance analytics
- [ ] Bulk message operations
### Task Enhancements
- [ ] Task dependency visualization (chains, groups, chords)
- [ ] Task result backend support
- [ ] Bulk task operations
- [ ] Task scheduling preview (for Celery Beat)
- [ ] Export task data (CSV, JSON)
## Phase 3: Debugging & Analysis Tools (v0.5.0+)
### Basic Debugging
- [ ] Task execution timeline
- [ ] Parent-child task relationships
- [ ] Basic error grouping
- [ ] Task replay functionality
- [ ] Enhanced search (regex, date ranges)
### Performance Analysis
- [ ] Task duration heatmaps
- [ ] Worker load distribution
- [ ] Queue throughput graphs
- [ ] SLA monitoring
- [ ] Performance trends over time
### Integration Features
- [ ] Flower API compatibility
- [ ] Prometheus metrics export
- [ ] Webhook notifications
- [ ] REST API for external tools
## Phase 4: Advanced Features (v0.7.0+)
### Distributed Tracing (If Needed)
- [ ] OpenTelemetry integration
- [ ] Basic span visualization
- [ ] Cross-service correlation
- [ ] Trace sampling
### Advanced Error Analysis
- [ ] Error pattern detection
- [ ] Similar error clustering
- [ ] Error frequency trends
- [ ] Root cause suggestions
### Multi-Environment Support
- [ ] Multiple broker connections
- [ ] Environment switching
- [ ] Cluster view
- [ ] Cross-environment task search
## Phase 5: Enterprise Features (v0.9.0+)
### Security & Compliance
- [ ] Audit logging
- [ ] Data masking for sensitive info
- [ ] Compliance reporting
### Automation
- [ ] Alert rules
- [ ] Auto-retry policies
- [ ] Task routing rules
- [ ] Scheduled reports
### Platform Support
- [ ] Homebrew formula
- [ ] Package managers (apt, yum, pacman)
---
## ๐ Current Status Summary
| Component | Status | Progress | Notes |
|-----------|--------|----------|-------|
| **Core Architecture** | โ
Complete | 100% | Async Tokio + Ratatui |
| **Redis Broker** | โ
Complete | 100% | Real Celery protocol integration |
| **Worker Discovery** | โ
Complete | 100% | From task metadata analysis |
| **Task Management** | โ
Complete | 100% | Retry/Revoke with Celery protocol |
| **Queue Monitoring** | โ
Complete | 100% | Dynamic discovery + real-time data |
| **Terminal UI** | โ
Complete | 100% | Professional TUI with vim navigation |
| **Testing Suite** | โ
Complete | 100% | 75+ tests including stress/integration |
| **AMQP Support** | โ Pending | 0% | Redis-only currently |
| **Advanced Features** | โ Pending | 0% | Worker control, analytics, etc. |
### ๐ฏ **v0.2.0 Achievement**
The project has successfully completed all MVP core features and demonstrates full Celery protocol compatibility with Redis broker.
### ๐ฃ๏ธ **Path to v1.0.0**
- **v0.3.0**: AMQP support + Enhanced monitoring
- **v0.4.0**: Advanced worker management
- **v0.5.0**: Debugging and analysis tools
- **v0.6.0**: Performance optimization and polish
- **v0.7.0**: Advanced features and integrations
- **v0.8.0**: Enterprise features
- **v0.9.0**: Pre-release stability and documentation
- **v1.0.0**: Production-ready release
### ๐ **Next Development Focus (v0.3.0)**
1. AMQP/RabbitMQ broker support for broader compatibility
2. Queue purge operations and enhanced task name display
3. Real-time task progress indicators and worker heartbeat detection
</file>
<file path=".githooks/pre-commit">
#!/bin/sh
#
# Pre-commit hook for LazyCelery
# Runs formatting, linting, and tests before allowing commit
#
set -e
echo "๐ Running pre-commit checks..."
# Check if mise is available
if ! command -v mise &> /dev/null; then
echo "โ mise is required but not installed. Please install mise first."
exit 1
fi
# 1. Check formatting
echo "๐ Checking code formatting..."
if ! mise run fmt; then
echo "โ Code formatting check failed!"
echo "๐ก Run 'mise run fmt' to fix formatting issues"
exit 1
fi
echo "โ
Code formatting is correct"
# 2. Run linting
echo "๐ Running clippy lints..."
if ! mise run lint; then
echo "โ Linting failed!"
echo "๐ก Fix the clippy warnings above"
exit 1
fi
echo "โ
All linting checks passed"
# 3. Run tests
echo "๐งช Running tests..."
if ! mise run test; then
echo "โ Tests failed!"
echo "๐ก Fix the failing tests above"
exit 1
fi
echo "โ
Tests passed"
# 4. Run security audit
echo "๐ Running security audit..."
if ! mise run audit; then
echo "โ Security audit failed!"
echo "๐ก Fix the security issues above"
exit 1
fi
echo "โ
Security audit passed"
echo "๐ All pre-commit checks passed! Proceeding with commit..."
</file>
<file path="src/broker/redis/mod.rs">
pub mod facade;
pub mod operations;
pub mod pool;
pub mod protocol;
use crate::broker::Broker;
use crate::error::BrokerError;
use crate::models::{Queue, Task, Worker};
use async_trait::async_trait;
use tracing::{debug, info};
// Re-export for backward compatibility
pub use facade::BrokerFacade;
/// Redis broker implementation using the improved facade pattern
pub struct RedisBroker {
facade: BrokerFacade,
}
#[async_trait]
impl Broker for RedisBroker {
async fn connect(url: &str) -> Result<Self, BrokerError> {
info!("Connecting to Redis broker using facade pattern");
debug!(
"Redis URL: {}",
url.split('@').next_back().unwrap_or("hidden")
);
let facade = BrokerFacade::new(url).await?;
// Perform initial health check
facade.health_check().await?;
info!("Redis broker connected successfully");
Ok(Self { facade })
}
async fn get_workers(&self) -> Result<Vec<Worker>, BrokerError> {
self.facade.get_workers().await
}
async fn get_tasks(&self) -> Result<Vec<Task>, BrokerError> {
self.facade.get_tasks().await
}
async fn get_queues(&self) -> Result<Vec<Queue>, BrokerError> {
self.facade.get_queues().await
}
async fn retry_task(&self, task_id: &str) -> Result<(), BrokerError> {
self.facade.retry_task(task_id).await
}
async fn revoke_task(&self, task_id: &str) -> Result<(), BrokerError> {
self.facade.revoke_task(task_id).await
}
async fn purge_queue(&self, queue_name: &str) -> Result<u64, BrokerError> {
self.facade.purge_queue(queue_name).await
}
}
</file>
<file path="src/models/task.rs">
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: String,
pub name: String,
pub args: String, // JSON string
pub kwargs: String, // JSON string
pub status: TaskStatus,
pub worker: Option<String>,
pub timestamp: DateTime<Utc>,
pub result: Option<String>,
pub traceback: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TaskStatus {
Pending,
Active,
Success,
Failure,
Retry,
Revoked,
}
impl Task {
/// Basic constructor for creating a new Task - kept for future API use
#[allow(dead_code)]
pub fn new(id: String, name: String) -> Self {
Self {
id,
name,
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Pending,
worker: None,
timestamp: Utc::now(),
result: None,
traceback: None,
}
}
pub fn duration_since(&self, now: DateTime<Utc>) -> chrono::Duration {
now - self.timestamp
}
}
</file>
<file path="src/ui/widgets/mod.rs">
pub mod base;
pub mod queues;
pub mod tasks;
pub mod workers;
pub use base::Widget;
pub use queues::QueueWidget;
pub use tasks::TaskWidget;
pub use workers::WorkerWidget;
</file>
<file path="src/ui/widgets/queues.rs">
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Gauge, List, ListItem, Paragraph},
Frame,
};
use super::base::{helpers, Widget};
use crate::app::App;
pub struct QueueWidget;
impl Widget for QueueWidget {
fn draw(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Draw queue list on the left
Self::draw_list(f, app, chunks[0]);
// Draw queue details on the right
Self::draw_details(f, app, chunks[1]);
}
fn draw_list(f: &mut Frame, app: &App, area: Rect) {
let queues: Vec<ListItem> = app
.queues
.iter()
.enumerate()
.map(|(idx, queue)| {
let status_color = if queue.length > 100 {
Color::Red
} else if queue.length > 50 {
Color::Yellow
} else {
Color::Green
};
let content = Line::from(vec![
Span::raw(&queue.name),
Span::raw(" "),
Span::styled(queue.length.to_string(), Style::default().fg(status_color)),
]);
if idx == app.selected_queue {
ListItem::new(content).style(helpers::selection_style())
} else {
ListItem::new(content)
}
})
.collect();
let title = format!("Queues ({})", app.queues.len());
let queues_list = List::new(queues)
.block(helpers::titled_block(&title))
.highlight_style(helpers::selection_style());
f.render_widget(queues_list, area);
}
fn draw_details(f: &mut Frame, app: &App, area: Rect) {
if app.queues.is_empty() {
f.render_widget(helpers::no_data_message("queues"), area);
return;
}
if let Some(queue) = app.queues.get(app.selected_queue) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Length(3),
Constraint::Min(0),
])
.split(area);
// Queue info
let info_lines = vec![
helpers::highlighted_field_line("Queue Name", &queue.name, Color::Cyan),
helpers::status_line(
"Messages",
&queue.length.to_string(),
if queue.length > 100 {
Color::Red
} else if queue.length > 50 {
Color::Yellow
} else {
Color::Green
},
),
helpers::field_line("Consumers", &queue.consumers.to_string()),
helpers::status_line(
"Status",
if queue.has_consumers() {
"Active"
} else if queue.is_empty() {
"Empty"
} else {
"No consumers"
},
if queue.has_consumers() {
Color::Green
} else if queue.is_empty() {
Color::Gray
} else {
Color::Yellow
},
),
Line::from(""),
Line::from(vec![Span::styled(
"[p] Purge queue (requires confirmation)",
Style::default().fg(Color::DarkGray),
)]),
];
let info = Paragraph::new(info_lines).block(helpers::titled_block("Queue Details"));
f.render_widget(info, chunks[0]);
// Queue fill gauge
let max_queue_size = 1000; // Configurable max for visualization
let ratio = (queue.length as f64 / max_queue_size as f64).min(1.0);
let gauge = Gauge::default()
.block(helpers::titled_block("Queue Fill"))
.gauge_style(Style::default().fg(if queue.length > 100 {
Color::Red
} else if queue.length > 50 {
Color::Yellow
} else {
Color::Green
}))
.ratio(ratio)
.label(format!("{}/{}", queue.length, max_queue_size));
f.render_widget(gauge, chunks[1]);
// Additional info or actions
let actions = Paragraph::new(vec![
Line::from("Available Actions:"),
Line::from(""),
Line::from("- View messages (coming soon)"),
Line::from("- Purge queue (coming soon)"),
Line::from("- Export messages (coming soon)"),
])
.block(helpers::titled_block("Actions"));
f.render_widget(actions, chunks[2]);
}
}
}
</file>
<file path="src/ui/widgets/workers.rs">
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{List, ListItem, Paragraph, Row, Table},
Frame,
};
use super::base::{helpers, Widget};
use crate::app::App;
use crate::models::WorkerStatus;
pub struct WorkerWidget;
impl Widget for WorkerWidget {
fn draw(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Draw worker list on the left
Self::draw_list(f, app, chunks[0]);
// Draw worker details on the right
Self::draw_details(f, app, chunks[1]);
}
fn draw_list(f: &mut Frame, app: &App, area: Rect) {
let workers: Vec<ListItem> = app
.workers
.iter()
.enumerate()
.map(|(idx, worker)| {
let status_symbol = match worker.status {
WorkerStatus::Online => "โ",
WorkerStatus::Offline => "โ",
};
let status_color = match worker.status {
WorkerStatus::Online => Color::Green,
WorkerStatus::Offline => Color::Red,
};
let content = Line::from(vec![
Span::styled(status_symbol, Style::default().fg(status_color)),
Span::raw(" "),
Span::raw(&worker.hostname),
]);
if idx == app.selected_worker {
ListItem::new(content).style(helpers::selection_style())
} else {
ListItem::new(content)
}
})
.collect();
let title = format!("Workers ({})", app.workers.len());
let workers_list = List::new(workers)
.block(helpers::titled_block(&title))
.highlight_style(helpers::selection_style());
f.render_widget(workers_list, area);
}
fn draw_details(f: &mut Frame, app: &App, area: Rect) {
if app.workers.is_empty() {
f.render_widget(helpers::no_data_message("workers"), area);
return;
}
if let Some(worker) = app.workers.get(app.selected_worker) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(10), Constraint::Min(0)])
.split(area);
// Worker info section
let info_lines = vec![
helpers::highlighted_field_line("Hostname", &worker.hostname, Color::Cyan),
helpers::status_line(
"Status",
match worker.status {
WorkerStatus::Online => "Online",
WorkerStatus::Offline => "Offline",
},
match worker.status {
WorkerStatus::Online => Color::Green,
WorkerStatus::Offline => Color::Red,
},
),
helpers::field_line("Concurrency", &worker.concurrency.to_string()),
helpers::field_line(
"Active Tasks",
&format!("{}/{}", worker.active_tasks.len(), worker.concurrency),
),
helpers::field_line("Utilization", &format!("{:.1}%", worker.utilization())),
helpers::highlighted_field_line(
"Processed",
&worker.processed.to_string(),
Color::Green,
),
helpers::highlighted_field_line("Failed", &worker.failed.to_string(), Color::Red),
helpers::field_line("Queues", &worker.queues.join(", ")),
];
let info = Paragraph::new(info_lines).block(helpers::titled_block("Worker Details"));
f.render_widget(info, chunks[0]);
// Active tasks section
if !worker.active_tasks.is_empty() {
let task_rows: Vec<Row> = worker
.active_tasks
.iter()
.map(|task_id| Row::new(vec![task_id.clone()]))
.collect();
let tasks_table = Table::new(task_rows, [Constraint::Percentage(100)])
.block(helpers::titled_block("Active Tasks"))
.header(
Row::new(vec!["Task ID"])
.style(Style::default().fg(Color::Yellow))
.bottom_margin(1),
);
f.render_widget(tasks_table, chunks[1]);
} else {
let no_tasks =
Paragraph::new("No active tasks").block(helpers::titled_block("Active Tasks"));
f.render_widget(no_tasks, chunks[1]);
}
}
}
}
</file>
<file path="src/ui/events.rs">
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use std::time::Duration;
#[allow(dead_code)]
pub enum AppEvent {
Key(KeyEvent),
Tick,
Refresh,
}
pub async fn next_event(tick_rate: Duration) -> Result<AppEvent, std::io::Error> {
if event::poll(tick_rate)? {
match event::read()? {
Event::Key(key) => Ok(AppEvent::Key(key)),
_ => Ok(AppEvent::Tick),
}
} else {
Ok(AppEvent::Tick)
}
}
pub fn handle_key_event(key: KeyEvent, app: &mut crate::app::App) {
if app.is_searching {
match key.code {
KeyCode::Esc => app.stop_search(),
KeyCode::Enter => app.stop_search(),
KeyCode::Char(c) => app.search_query.push(c),
KeyCode::Backspace => {
app.search_query.pop();
}
_ => {}
}
return;
}
if app.show_confirmation {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
// Confirmation dialog will be handled in main loop
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.hide_confirmation_dialog();
}
_ => {}
}
return;
}
if app.show_help {
app.toggle_help();
return;
}
if app.show_task_details {
app.hide_task_details();
return;
}
// Clear status message on any key press (except actions that set new status)
match key.code {
KeyCode::Char('p')
| KeyCode::Char('r')
| KeyCode::Char('x')
| KeyCode::Enter
| KeyCode::Char('d') => {
// These will set their own status messages or open modals
}
_ => {
app.clear_status_message();
}
}
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('?') => app.toggle_help(),
KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(),
KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
KeyCode::Down | KeyCode::Char('j') => app.select_next(),
KeyCode::Char('/') => app.start_search(),
KeyCode::Char('p') => app.initiate_purge_queue(),
KeyCode::Char('r') => app.initiate_retry_task(),
KeyCode::Char('x') => app.initiate_revoke_task(),
KeyCode::Enter | KeyCode::Char('d') => app.show_task_details(),
_ => {}
}
}
</file>
<file path="src/utils/formatting.rs">
use chrono::{DateTime, Duration, Utc};
/// Format duration as HH:MM:SS or MM:SS - utility function for future UI features
#[allow(dead_code)]
pub fn format_duration(duration: Duration) -> String {
let hours = duration.num_hours();
let minutes = duration.num_minutes() % 60;
let seconds = duration.num_seconds() % 60;
if hours > 0 {
format!("{hours:02}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes:02}:{seconds:02}")
}
}
/// Format timestamp for display - utility function for future UI features
#[allow(dead_code)]
pub fn format_timestamp(timestamp: DateTime<Utc>) -> String {
timestamp.format("%Y-%m-%d %H:%M:%S").to_string()
}
/// Truncate string with ellipsis - utility function for UI text overflow
#[allow(dead_code)]
pub fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else if max_len <= 3 {
"...".to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
</file>
<file path="src/error.rs">
use thiserror::Error;
#[derive(Debug, Error)]
#[allow(dead_code)]
pub enum BrokerError {
#[error("Connection failed: {0}")]
ConnectionError(String),
#[error("Authentication failed")]
AuthError,
#[error("Broker operation failed: {0}")]
OperationError(String),
#[error("Invalid broker URL: {0}")]
InvalidUrl(String),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Timeout occurred")]
Timeout,
#[error("Not implemented")]
NotImplemented,
}
#[derive(Debug, Error)]
#[allow(dead_code)]
pub enum AppError {
#[error("Broker error: {0}")]
Broker(#[from] BrokerError),
#[error("UI error: {0}")]
Ui(String),
#[error("Configuration error: {0}")]
Config(String),
}
</file>
<file path="rustfmt.toml">
# Rust formatting configuration (stable channel only)
edition = "2021"
max_width = 100
hard_tabs = false
tab_spaces = 4
newline_style = "Unix"
use_field_init_shorthand = true
use_try_shorthand = true
reorder_imports = true
reorder_modules = true
remove_nested_parens = true
match_arm_leading_pipes = "Never"
merge_derives = true
use_small_heuristics = "Default"
</file>
<file path=".github/dependabot.yml">
version: 2
updates:
# Enable version updates for Cargo
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
</file>
<file path="packaging/homebrew/lazycelery.rb">
class Lazycelery < Formula
desc "A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker/lazygit"
homepage "https://github.com/Fguedes90/lazycelery"
version "0.4.5"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-macos-aarch64.tar.gz"
sha256 "97b1ad921373e1f8b71a309be1bc67dc47ec1a555b3c7f8ab49211e9c56b7352"
else
url "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-macos-x86_64.tar.gz"
sha256 "4a6fc2e614968860c259973427ff02d8431b96f091640bf1f1c146d2c2a8f726"
end
end
on_linux do
url "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-linux-x86_64.tar.gz"
sha256 "71201741d7d920ea417491bf490d4f33006e3f3da2ff9139e4f73019b6145472"
end
def install
bin.install "lazycelery"
end
test do
assert_match "lazycelery", shell_output("#{bin}/lazycelery --help")
end
end
</file>
<file path="packaging/scoop/lazycelery.json">
{
"version": "0.4.5",
"description": "A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker/lazygit",
"homepage": "https://github.com/Fguedes90/lazycelery",
"license": "MIT",
"architecture": {
"64bit": {
"url": "https://github.com/Fguedes90/lazycelery/releases/download/v0.4.5/lazycelery-windows-x86_64.zip",
"hash": "PLACEHOLDER_SHA256",
"extract_dir": ""
}
},
"bin": "lazycelery.exe",
"checkver": {
"github": "https://github.com/Fguedes90/lazycelery"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/Fguedes90/lazycelery/releases/download/v$version/lazycelery-windows-x86_64.zip"
}
}
}
}
</file>
<file path="src/broker/mod.rs">
pub mod result_backend;
pub mod amqp;
pub mod redis;
use crate::error::BrokerError;
use crate::models::{Queue, Task, Worker};
use async_trait::async_trait;
#[async_trait]
#[allow(dead_code)]
pub trait Broker: Send + Sync {
async fn connect(url: &str) -> Result<Self, BrokerError>
where
Self: Sized;
async fn get_workers(&self) -> Result<Vec<Worker>, BrokerError>;
async fn get_tasks(&self) -> Result<Vec<Task>, BrokerError>;
async fn get_queues(&self) -> Result<Vec<Queue>, BrokerError>;
async fn retry_task(&self, task_id: &str) -> Result<(), BrokerError>;
async fn revoke_task(&self, task_id: &str) -> Result<(), BrokerError>;
async fn purge_queue(&self, queue_name: &str) -> Result<u64, BrokerError>;
}
/// Create a broker based on the URL scheme
pub async fn create_broker(url: &str) -> Result<Box<dyn Broker>, BrokerError> {
if url.starts_with("redis://") {
Ok(Box::new(redis::RedisBroker::connect(url).await?))
} else if url.starts_with("amqp://") || url.starts_with("rabbitmq://") {
Ok(Box::new(amqp::AmqpBroker::connect(url).await?))
} else {
Err(BrokerError::InvalidUrl(url.to_string()))
}
}
</file>
<file path="src/ui/widgets/tasks.rs">
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap},
Frame,
};
use super::base::{helpers, Widget};
use crate::app::App;
use crate::models::TaskStatus;
use chrono::Utc;
pub struct TaskWidget;
impl Widget for TaskWidget {
fn draw(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
// Draw task list
Self::draw_list(f, app, chunks[0]);
// Draw task details
Self::draw_details(f, app, chunks[1]);
}
fn draw_list(f: &mut Frame, app: &App, area: Rect) {
let filtered_tasks = app.get_filtered_tasks();
let header = Row::new(vec!["ID", "Name", "Status", "Worker", "Duration"])
.style(Style::default().fg(Color::Yellow))
.bottom_margin(1);
// Calculate viewport
let height = area.height.saturating_sub(4) as usize; // Account for borders and header
if filtered_tasks.is_empty() {
let no_tasks = Row::new(vec![
Cell::from(""),
Cell::from("No tasks found"),
Cell::from(""),
Cell::from(""),
Cell::from(""),
])
.style(Style::default().fg(Color::DarkGray));
let table = Table::new(
vec![no_tasks],
[
Constraint::Percentage(20),
Constraint::Percentage(30),
Constraint::Percentage(15),
Constraint::Percentage(20),
Constraint::Percentage(15),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(" Tasks (0) "));
f.render_widget(table, area);
return;
}
let selected = app
.selected_task
.min(filtered_tasks.len().saturating_sub(1));
// Calculate the start of the viewport to ensure selected item is visible
let start = if selected >= height && height > 0 {
selected.saturating_sub(height / 2)
} else {
0
};
let end = (start + height).min(filtered_tasks.len());
let visible_tasks = &filtered_tasks[start..end];
let rows: Vec<Row> = visible_tasks
.iter()
.enumerate()
.map(|(idx, task)| {
let actual_idx = start + idx;
let status_color = match task.status {
TaskStatus::Success => Color::Green,
TaskStatus::Failure => Color::Red,
TaskStatus::Active => Color::Yellow,
TaskStatus::Pending => Color::Gray,
TaskStatus::Retry => Color::Magenta,
TaskStatus::Revoked => Color::DarkGray,
};
let duration = task.duration_since(Utc::now());
let duration_str = format!(
"{:02}:{:02}:{:02}",
duration.num_hours(),
duration.num_minutes() % 60,
duration.num_seconds() % 60
);
let row = Row::new(vec![
Cell::from(task.id.clone()),
Cell::from(task.name.clone()),
Cell::from(format!("{:?}", task.status))
.style(Style::default().fg(status_color)),
Cell::from(task.worker.as_deref().unwrap_or("-")),
Cell::from(duration_str),
]);
if actual_idx == app.selected_task {
row.style(helpers::selection_style())
} else {
row
}
})
.collect();
// Add scroll indicator to title
let scroll_info = if filtered_tasks.len() > height {
format!(" [{}/{}]", app.selected_task + 1, filtered_tasks.len())
} else {
String::new()
};
let title = if app.is_searching {
format!(
" Tasks (filtered: {}/{}){} ",
filtered_tasks.len(),
app.tasks.len(),
scroll_info
)
} else {
format!(" Tasks ({}){} ", app.tasks.len(), scroll_info)
};
let table = Table::new(
rows,
[
Constraint::Percentage(20),
Constraint::Percentage(30),
Constraint::Percentage(15),
Constraint::Percentage(20),
Constraint::Percentage(15),
],
)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title))
.row_highlight_style(helpers::selection_style());
f.render_widget(table, area);
}
fn draw_details(f: &mut Frame, app: &App, area: Rect) {
let filtered_tasks = app.get_filtered_tasks();
if filtered_tasks.is_empty() {
f.render_widget(helpers::no_data_message("tasks"), area);
return;
}
let selected = app
.selected_task
.min(filtered_tasks.len().saturating_sub(1));
if let Some(task) = filtered_tasks.get(selected) {
let mut lines = vec![
helpers::highlighted_field_line("ID", &task.id, Color::Cyan),
helpers::highlighted_field_line("Name", &task.name, Color::Yellow),
helpers::status_line(
"Status",
&format!("{:?}", task.status),
match task.status {
TaskStatus::Success => Color::Green,
TaskStatus::Failure => Color::Red,
TaskStatus::Active => Color::Yellow,
TaskStatus::Pending => Color::Gray,
TaskStatus::Retry => Color::Magenta,
TaskStatus::Revoked => Color::DarkGray,
},
),
helpers::field_line("Worker", task.worker.as_deref().unwrap_or("None")),
helpers::field_line(
"Timestamp",
&task.timestamp.format("%Y-%m-%d %H:%M:%S").to_string(),
),
];
if !task.args.is_empty() && task.args != "[]" {
lines.push(helpers::field_line("Args", &task.args));
}
if !task.kwargs.is_empty() && task.kwargs != "{}" {
lines.push(helpers::field_line("Kwargs", &task.kwargs));
}
if let Some(result) = &task.result {
lines.push(Line::from(""));
lines.push(helpers::highlighted_field_line(
"Result",
result,
Color::Green,
));
}
if let Some(traceback) = &task.traceback {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"Traceback:",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)]));
for line in traceback.lines() {
lines.push(Line::from(vec![Span::styled(
line,
Style::default().fg(Color::Red),
)]));
}
}
let details = Paragraph::new(lines)
.block(helpers::titled_block("Task Details"))
.wrap(Wrap { trim: false });
f.render_widget(details, area);
}
}
}
</file>
<file path="src/config.rs">
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub broker: BrokerConfig,
pub ui: UiConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokerConfig {
pub url: String,
pub timeout: u32,
pub retry_attempts: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiConfig {
pub refresh_interval: u64, // milliseconds
pub theme: String,
}
impl Default for Config {
fn default() -> Self {
Self {
broker: BrokerConfig {
url: "redis://localhost:6379/0".to_string(),
timeout: 30,
retry_attempts: 3,
},
ui: UiConfig {
refresh_interval: 1000,
theme: "dark".to_string(),
},
}
}
}
impl Config {
pub fn from_file(path: PathBuf) -> Result<Self> {
let contents = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
pub fn load_or_create_default() -> Result<Self> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
.join("lazycelery");
let config_path = config_dir.join("config.toml");
if config_path.exists() {
Self::from_file(config_path)
} else {
// Create default config
let default_config = Self::default();
// Try to create config directory and file
if let Err(e) = std::fs::create_dir_all(&config_dir) {
eprintln!("โ ๏ธ Could not create config directory: {e}");
} else {
let toml_string = toml::to_string_pretty(&default_config)?;
if let Err(e) = std::fs::write(&config_path, toml_string) {
eprintln!("โ ๏ธ Could not create config file: {e}");
} else {
eprintln!("โ
Created default config at: {}", config_path.display());
}
}
Ok(default_config)
}
}
}
</file>
<file path="tests/test_app.rs">
use lazycelery::app::{App, Tab};
use lazycelery::models::{Queue, Task, TaskStatus, Worker, WorkerStatus};
mod test_broker_utils;
use test_broker_utils::MockBrokerBuilder;
#[test]
fn test_app_creation() {
let broker = MockBrokerBuilder::empty().build();
let app = App::new(broker);
assert_eq!(app.selected_tab, Tab::Workers);
assert!(!app.should_quit);
assert_eq!(app.selected_worker, 0);
assert_eq!(app.selected_task, 0);
assert_eq!(app.selected_queue, 0);
assert!(!app.show_help);
assert!(!app.is_searching);
assert_eq!(app.search_query, "");
}
#[test]
fn test_tab_navigation() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
assert_eq!(app.selected_tab, Tab::Workers);
app.next_tab();
assert_eq!(app.selected_tab, Tab::Queues);
app.next_tab();
assert_eq!(app.selected_tab, Tab::Tasks);
app.next_tab();
assert_eq!(app.selected_tab, Tab::Workers);
app.previous_tab();
assert_eq!(app.selected_tab, Tab::Tasks);
app.previous_tab();
assert_eq!(app.selected_tab, Tab::Queues);
app.previous_tab();
assert_eq!(app.selected_tab, Tab::Workers);
}
#[tokio::test]
async fn test_app_refresh_data() {
let test_workers = vec![Worker {
hostname: "worker-1".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec!["default".to_string()],
active_tasks: vec![],
processed: 100,
failed: 5,
}];
let test_tasks = vec![Task {
id: "task-1".to_string(),
name: "send_email".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Success,
worker: Some("worker-1".to_string()),
timestamp: chrono::Utc::now(),
result: None,
traceback: None,
}];
let test_queues = vec![Queue {
name: "default".to_string(),
length: 10,
consumers: 2,
}];
let broker = MockBrokerBuilder::new()
.with_workers(test_workers.clone())
.with_tasks(test_tasks.clone())
.with_queues(test_queues.clone())
.build();
let mut app = App::new(broker);
app.refresh_data().await.unwrap();
assert_eq!(app.workers.len(), 1);
assert_eq!(app.tasks.len(), 1);
assert_eq!(app.queues.len(), 1);
assert_eq!(app.workers[0].hostname, "worker-1");
assert_eq!(app.tasks[0].id, "task-1");
assert_eq!(app.queues[0].name, "default");
}
#[test]
fn test_item_selection() {
let broker = MockBrokerBuilder::new()
.with_workers(vec![
Worker {
hostname: "worker-1".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec![],
active_tasks: vec![],
processed: 0,
failed: 0,
},
Worker {
hostname: "worker-2".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec![],
active_tasks: vec![],
processed: 0,
failed: 0,
},
])
.build();
let mut app = App::new(broker);
app.workers = vec![
Worker {
hostname: "worker-1".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec![],
active_tasks: vec![],
processed: 0,
failed: 0,
},
Worker {
hostname: "worker-2".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec![],
active_tasks: vec![],
processed: 0,
failed: 0,
},
];
assert_eq!(app.selected_worker, 0);
app.select_next();
assert_eq!(app.selected_worker, 1);
app.select_next();
assert_eq!(app.selected_worker, 0); // Wraps around
app.select_previous();
assert_eq!(app.selected_worker, 1); // Wraps around
app.select_previous();
assert_eq!(app.selected_worker, 0);
}
#[test]
fn test_help_toggle() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
assert!(!app.show_help);
app.toggle_help();
assert!(app.show_help);
app.toggle_help();
assert!(!app.show_help);
}
#[test]
fn test_search_functionality() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
app.tasks = vec![
Task {
id: "abc123".to_string(),
name: "send_email".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Success,
worker: None,
timestamp: chrono::Utc::now(),
result: None,
traceback: None,
},
Task {
id: "def456".to_string(),
name: "process_data".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Success,
worker: None,
timestamp: chrono::Utc::now(),
result: None,
traceback: None,
},
];
assert!(!app.is_searching);
assert_eq!(app.search_query, "");
app.start_search();
assert!(app.is_searching);
assert_eq!(app.search_query, "");
app.search_query = "email".to_string();
let filtered = app.get_filtered_tasks();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "send_email");
app.search_query = "abc".to_string();
let filtered = app.get_filtered_tasks();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "abc123");
app.stop_search();
assert!(!app.is_searching);
assert_eq!(app.search_query, "");
assert_eq!(app.get_filtered_tasks().len(), 2);
}
#[test]
fn test_empty_state_selection() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker);
// Should not panic when selecting with empty lists
app.select_next();
app.select_previous();
assert_eq!(app.selected_worker, 0);
assert_eq!(app.selected_task, 0);
assert_eq!(app.selected_queue, 0);
}
</file>
<file path="tests/test_event_handling.rs">
use chrono::Utc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use lazycelery::app::{App, Tab};
use lazycelery::models::{Task, TaskStatus, Worker, WorkerStatus};
use lazycelery::ui::events::{handle_key_event, AppEvent};
mod test_broker_utils;
use test_broker_utils::MockBrokerBuilder;
fn create_test_app() -> App {
// Use consolidated mock broker with custom test data for navigation
let broker = MockBrokerBuilder::new()
.with_not_implemented_operations()
.build();
let mut app = App::new(broker);
// Manually populate data for tests (sync version)
app.workers = vec![
Worker {
hostname: "worker-1".to_string(),
status: WorkerStatus::Online,
concurrency: 4,
queues: vec!["default".to_string()],
active_tasks: vec![],
processed: 100,
failed: 5,
},
Worker {
hostname: "worker-2".to_string(),
status: WorkerStatus::Offline,
concurrency: 8,
queues: vec!["celery".to_string()],
active_tasks: vec![],
processed: 250,
failed: 12,
},
];
app.tasks = vec![
Task {
id: "task-1".to_string(),
name: "myapp.tasks.process_data".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Success,
worker: Some("worker-1".to_string()),
timestamp: Utc::now(),
result: None,
traceback: None,
},
Task {
id: "task-2".to_string(),
name: "myapp.tasks.another_task".to_string(),
args: "[]".to_string(),
kwargs: "{}".to_string(),
status: TaskStatus::Failure,
worker: Some("worker-2".to_string()),
timestamp: Utc::now(),
result: None,
traceback: None,
},
];
app.queues = vec![
lazycelery::models::Queue {
name: "default".to_string(),
length: 10,
consumers: 2,
},
lazycelery::models::Queue {
name: "priority".to_string(),
length: 5,
consumers: 1,
},
];
app
}
fn create_key_event(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn test_quit_key_handling() {
let mut app = create_test_app();
assert!(!app.should_quit);
handle_key_event(create_key_event(KeyCode::Char('q')), &mut app);
assert!(app.should_quit);
}
#[test]
fn test_help_toggle() {
let mut app = create_test_app();
assert!(!app.show_help);
// Toggle help on
handle_key_event(create_key_event(KeyCode::Char('?')), &mut app);
assert!(app.show_help);
// Any key should toggle help off when it's showing
handle_key_event(create_key_event(KeyCode::Char('a')), &mut app);
assert!(!app.show_help);
}
#[test]
fn test_tab_navigation() {
let mut app = create_test_app();
assert_eq!(app.selected_tab, Tab::Workers);
// Forward tab navigation
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
assert_eq!(app.selected_tab, Tab::Queues);
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
assert_eq!(app.selected_tab, Tab::Tasks);
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
assert_eq!(app.selected_tab, Tab::Workers); // Wrap around
// Backward tab navigation
handle_key_event(create_key_event(KeyCode::BackTab), &mut app);
assert_eq!(app.selected_tab, Tab::Tasks);
handle_key_event(create_key_event(KeyCode::BackTab), &mut app);
assert_eq!(app.selected_tab, Tab::Queues);
handle_key_event(create_key_event(KeyCode::BackTab), &mut app);
assert_eq!(app.selected_tab, Tab::Workers); // Wrap around
}
#[test]
fn test_item_navigation_workers_tab() {
let mut app = create_test_app();
app.selected_tab = Tab::Workers;
assert_eq!(app.selected_worker, 0);
// Navigate down
handle_key_event(create_key_event(KeyCode::Down), &mut app);
assert_eq!(app.selected_worker, 1);
// Navigate down at end (should wrap to beginning)
handle_key_event(create_key_event(KeyCode::Down), &mut app);
assert_eq!(app.selected_worker, 0); // Wraps around
// Navigate up from beginning (should wrap to end)
handle_key_event(create_key_event(KeyCode::Up), &mut app);
assert_eq!(app.selected_worker, 1); // Wraps to last item
}
#[test]
fn test_item_navigation_vim_keys() {
let mut app = create_test_app();
app.selected_tab = Tab::Tasks;
assert_eq!(app.selected_task, 0);
// Test vim-style navigation with 'j' (down)
handle_key_event(create_key_event(KeyCode::Char('j')), &mut app);
assert_eq!(app.selected_task, 1);
// Test vim-style navigation with 'k' (up)
handle_key_event(create_key_event(KeyCode::Char('k')), &mut app);
assert_eq!(app.selected_task, 0);
}
#[test]
fn test_item_navigation_queues_tab() {
let mut app = create_test_app();
app.selected_tab = Tab::Queues;
assert_eq!(app.selected_queue, 0);
handle_key_event(create_key_event(KeyCode::Down), &mut app);
assert_eq!(app.selected_queue, 1);
handle_key_event(create_key_event(KeyCode::Up), &mut app);
assert_eq!(app.selected_queue, 0);
}
#[test]
fn test_search_mode_activation() {
let mut app = create_test_app();
assert!(!app.is_searching);
assert!(app.search_query.is_empty());
// Start search
handle_key_event(create_key_event(KeyCode::Char('/')), &mut app);
assert!(app.is_searching);
}
#[test]
fn test_search_mode_character_input() {
let mut app = create_test_app();
app.is_searching = true;
// Add characters to search query
handle_key_event(create_key_event(KeyCode::Char('t')), &mut app);
handle_key_event(create_key_event(KeyCode::Char('e')), &mut app);
handle_key_event(create_key_event(KeyCode::Char('s')), &mut app);
handle_key_event(create_key_event(KeyCode::Char('t')), &mut app);
assert_eq!(app.search_query, "test");
}
#[test]
fn test_search_mode_backspace() {
let mut app = create_test_app();
app.is_searching = true;
app.search_query = "hello".to_string();
// Remove characters with backspace
handle_key_event(create_key_event(KeyCode::Backspace), &mut app);
assert_eq!(app.search_query, "hell");
handle_key_event(create_key_event(KeyCode::Backspace), &mut app);
assert_eq!(app.search_query, "hel");
// Backspace on empty string should not panic
app.search_query.clear();
handle_key_event(create_key_event(KeyCode::Backspace), &mut app);
assert_eq!(app.search_query, "");
}
#[test]
fn test_search_mode_escape() {
let mut app = create_test_app();
app.is_searching = true;
app.search_query = "test query".to_string();
// Escape should exit search mode
handle_key_event(create_key_event(KeyCode::Esc), &mut app);
assert!(!app.is_searching);
// Query should be preserved for re-use
}
#[test]
fn test_search_mode_enter() {
let mut app = create_test_app();
app.is_searching = true;
app.search_query = "process".to_string();
// Enter should exit search mode
handle_key_event(create_key_event(KeyCode::Enter), &mut app);
assert!(!app.is_searching);
}
#[test]
fn test_search_mode_blocks_other_keys() {
let mut app = create_test_app();
app.is_searching = true;
let original_tab = app.selected_tab;
let original_should_quit = app.should_quit;
// Normal navigation keys should be ignored in search mode
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
assert_eq!(app.selected_tab, original_tab);
handle_key_event(create_key_event(KeyCode::Char('q')), &mut app);
assert_eq!(app.should_quit, original_should_quit);
// But 'q' should be added to search query
assert!(app.search_query.contains('q'));
handle_key_event(create_key_event(KeyCode::Up), &mut app);
// Up arrow should be ignored in search mode, no navigation change expected
}
#[test]
fn test_help_mode_blocks_other_keys() {
let mut app = create_test_app();
app.show_help = true;
let original_tab = app.selected_tab;
let original_should_quit = app.should_quit;
// All keys should toggle help off when help is showing
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
assert!(!app.show_help);
assert_eq!(app.selected_tab, original_tab); // Navigation should not occur
app.show_help = true;
handle_key_event(create_key_event(KeyCode::Char('q')), &mut app);
assert!(!app.show_help);
assert_eq!(app.should_quit, original_should_quit); // Quit should not occur
}
#[test]
fn test_key_event_precedence() {
let mut app = create_test_app();
// Help mode has highest precedence - any key calls toggle_help() which flips the state
app.show_help = true;
app.is_searching = false; // Need to test help precedence without search mode interference
handle_key_event(create_key_event(KeyCode::Char('a')), &mut app);
assert!(!app.show_help); // Help should be toggled off (true -> false)
assert!(!app.is_searching); // Search mode should remain off
// Search mode has next precedence
app.show_help = false;
app.is_searching = true;
handle_key_event(create_key_event(KeyCode::Char('q')), &mut app);
assert!(!app.should_quit); // Quit should not happen
assert!(app.search_query.contains('q')); // Character should be added to query
}
#[test]
fn test_empty_data_navigation() {
let broker = MockBrokerBuilder::empty().build();
let mut app = App::new(broker); // Empty app
// Navigation on empty data should not crash
handle_key_event(create_key_event(KeyCode::Up), &mut app);
handle_key_event(create_key_event(KeyCode::Down), &mut app);
// Selected indices should remain at 0
assert_eq!(app.selected_worker, 0);
assert_eq!(app.selected_task, 0);
assert_eq!(app.selected_queue, 0);
}
#[test]
fn test_app_event_types() {
// Test that AppEvent variants can be created
let key_event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
let _app_event_key = AppEvent::Key(key_event);
let _app_event_tick = AppEvent::Tick;
let _app_event_refresh = AppEvent::Refresh;
}
#[test]
fn test_key_modifiers_handling() {
let mut app = create_test_app();
// Test that keys with modifiers are handled - current implementation ignores modifiers
let key_with_ctrl = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
let key_with_shift = KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::SHIFT);
let key_normal = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
// Current implementation only checks KeyCode, not modifiers, so all 'q' chars quit
handle_key_event(key_with_ctrl, &mut app);
assert!(app.should_quit); // Actually quits because modifiers are ignored
// Reset for next test
app.should_quit = false;
handle_key_event(key_with_shift, &mut app);
assert!(!app.should_quit); // 'Q' is different from 'q' in KeyCode, so no quit
// Reset for next test
app.should_quit = false;
handle_key_event(key_normal, &mut app);
assert!(app.should_quit);
}
mod navigation_edge_cases {
use super::*;
#[test]
fn test_navigation_bounds_checking() {
let mut app = create_test_app();
// Test with single item lists
app.workers = vec![app.workers[0].clone()];
app.tasks = vec![app.tasks[0].clone()];
app.queues = vec![app.queues[0].clone()];
// Navigation should not go out of bounds
app.selected_tab = Tab::Workers;
handle_key_event(create_key_event(KeyCode::Down), &mut app);
assert_eq!(app.selected_worker, 0); // Should stay at 0
app.selected_tab = Tab::Tasks;
handle_key_event(create_key_event(KeyCode::Down), &mut app);
assert_eq!(app.selected_task, 0);
app.selected_tab = Tab::Queues;
handle_key_event(create_key_event(KeyCode::Down), &mut app);
assert_eq!(app.selected_queue, 0);
}
#[test]
fn test_tab_cycling_consistency() {
let mut app = create_test_app();
let starting_tab = app.selected_tab;
// Full forward cycle should return to start
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
handle_key_event(create_key_event(KeyCode::Tab), &mut app);
assert_eq!(app.selected_tab, starting_tab);
// Full backward cycle should return to start
handle_key_event(create_key_event(KeyCode::BackTab), &mut app);
handle_key_event(create_key_event(KeyCode::BackTab), &mut app);
handle_key_event(create_key_event(KeyCode::BackTab), &mut app);
assert_eq!(app.selected_tab, starting_tab);
}
#[test]
fn test_search_with_special_characters() {
let mut app = create_test_app();
app.is_searching = true;
// Test various special characters
let special_chars = vec![
'!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '=', '+',
];
for ch in special_chars {
app.search_query.clear();
handle_key_event(create_key_event(KeyCode::Char(ch)), &mut app);
assert_eq!(app.search_query, ch.to_string());
}
}
#[test]
fn test_rapid_key_sequence() {
let mut app = create_test_app();
// Rapid sequence of navigation keys
let keys = vec![
KeyCode::Down,
KeyCode::Down,
KeyCode::Up,
KeyCode::Down,
KeyCode::Tab,
KeyCode::Down,
KeyCode::Up,
KeyCode::BackTab,
];
for key in keys {
handle_key_event(create_key_event(key), &mut app);
// App should remain in consistent state
assert!(app.selected_worker < app.workers.len() || app.workers.is_empty());
assert!(app.selected_task < app.tasks.len() || app.tasks.is_empty());
assert!(app.selected_queue < app.queues.len() || app.queues.is_empty());
}
}
}
</file>
<file path="src/main.rs">
mod app;
mod broker;
mod config;
mod error;
mod models;
mod ui;
mod utils;
use anyhow::Result;
use clap::Parser;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::{io, time::Duration};
use tokio::time;
use crate::app::App;
use crate::broker::{create_broker, Broker};
use crate::config::Config;
use crate::ui::events::{handle_key_event, next_event, AppEvent};
use clap::Subcommand;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Broker URL (e.g., redis://localhost:6379/0)
#[arg(short, long, global = true)]
broker: Option<String>,
/// Result backend URL
#[arg(long, global = true)]
result_backend: Option<String>,
/// Configuration file path
#[arg(short, long, global = true)]
config: Option<std::path::PathBuf>,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Initialize configuration with interactive setup
Init,
/// Show current configuration
Config,
/// Set broker URL in configuration
SetBroker {
/// Broker URL (e.g., redis://localhost:6379/0)
url: String,
},
/// Set UI refresh interval in milliseconds
SetRefresh {
/// Refresh interval in milliseconds
interval: u64,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Handle subcommands
match cli.command {
Some(Commands::Init) => {
run_init_command().await?;
return Ok(());
}
Some(Commands::Config) => {
show_config()?;
return Ok(());
}
Some(Commands::SetBroker { url }) => {
set_broker_url(&url)?;
return Ok(());
}
Some(Commands::SetRefresh { interval }) => {
set_refresh_interval(interval)?;
return Ok(());
}
None => {
// Run the main TUI application
run_tui_app(cli.broker, cli.config).await?;
}
}
Ok(())
}
async fn run_tui_app(
broker_arg: Option<String>,
config_arg: Option<std::path::PathBuf>,
) -> Result<()> {
// Load configuration
let config = if let Some(config_path) = config_arg {
Config::from_file(config_path)?
} else {
Config::load_or_create_default()?
};
// Determine broker URL
let broker_url = broker_arg.unwrap_or_else(|| config.broker.url.clone());
// Connect to broker
let broker: Box<dyn Broker> = match create_broker(&broker_url).await {
Ok(broker) => broker,
Err(e) => {
let (broker_type, url_hint) = if broker_url.starts_with("redis://") {
("Redis", "redis://localhost:6379/0")
} else if broker_url.starts_with("amqp://") {
("RabbitMQ", "amqp://guest:guest@localhost:5672//")
} else {
("Unknown", "redis://localhost:6379/0 or amqp://localhost:5672//")
};
eprintln!("\nโ Failed to connect to {broker_type} broker at {broker_url}");
eprintln!("\n{e}");
eprintln!("\n๐ Quick Setup Guide:");
eprintln!("1. For Redis:");
eprintln!(" - Docker: docker run -d -p 6379:6379 redis");
eprintln!(" - macOS: brew services start redis");
eprintln!(" - Verify: redis-cli ping");
eprintln!("\n2. For RabbitMQ:");
eprintln!(" - Docker: docker run -d -p 5672:5672 rabbitmq");
eprintln!(" - Verify: amqp://guest:guest@localhost:5672//");
eprintln!("\n3. Run lazycelery:");
eprintln!(" lazycelery --broker {}", url_hint);
eprintln!("\n๐ก For more help: https://github.com/Fgudes90/lazycelery");
std::process::exit(1);
}
};
// Create app state
let mut app = App::new(broker);
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Run the app
let res = run_app(&mut terminal, &mut app, &config).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("Error: {err}");
}
Ok(())
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
config: &Config,
) -> Result<()> {
// Initial data fetch
app.refresh_data().await?;
// Set up refresh interval
let mut refresh_interval = time::interval(Duration::from_millis(config.ui.refresh_interval));
let tick_rate = Duration::from_millis(50); // 20 FPS max
loop {
// Draw UI
terminal.draw(|f| ui::draw(f, app))?;
// Handle events
tokio::select! {
// Handle user input
event = next_event(tick_rate) => {
match event? {
AppEvent::Key(key) => {
// Check if confirmation dialog needs execution
let should_execute = app.show_confirmation && matches!(
key.code,
crossterm::event::KeyCode::Char('y') |
crossterm::event::KeyCode::Char('Y') |
crossterm::event::KeyCode::Enter
);
handle_key_event(key, app);
// Execute pending action if confirmed
if should_execute {
app.execute_pending_action().await?;
}
if app.should_quit {
return Ok(());
}
}
AppEvent::Tick => {}
AppEvent::Refresh => {
app.refresh_data().await?;
}
}
}
// Auto-refresh data
_ = refresh_interval.tick() => {
app.refresh_data().await?;
}
}
}
}
async fn run_init_command() -> Result<()> {
use std::io::{self, Write};
println!("๐ Welcome to LazyCelery Setup!\n");
// Get config directory
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
.join("lazycelery");
let config_path = config_dir.join("config.toml");
// Check if config already exists
if config_path.exists() {
print!(
"โ ๏ธ Configuration already exists at {}. Overwrite? (y/N): ",
config_path.display()
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("โ Setup cancelled.");
return Ok(());
}
}
// Ask for broker URL
print!("๐ก Enter your Celery broker URL (default: redis://localhost:6379/0): ");
io::stdout().flush()?;
let mut broker_url = String::new();
io::stdin().read_line(&mut broker_url)?;
let broker_url = broker_url.trim();
let broker_url = if broker_url.is_empty() {
"redis://localhost:6379/0"
} else {
broker_url
};
// Validate broker URL
if !broker_url.starts_with("redis://") && !broker_url.starts_with("amqp://") {
eprintln!("โ Invalid broker URL. Must start with redis:// or amqp://");
return Ok(());
}
// Ask for refresh interval
print!("๐ Enter UI refresh interval in milliseconds (default: 1000): ");
io::stdout().flush()?;
let mut refresh_input = String::new();
io::stdin().read_line(&mut refresh_input)?;
let refresh_interval: u64 = refresh_input.trim().parse().unwrap_or(1000);
// Create config
let config = Config {
broker: crate::config::BrokerConfig {
url: broker_url.to_string(),
timeout: 30,
retry_attempts: 3,
},
ui: crate::config::UiConfig {
refresh_interval,
theme: "dark".to_string(),
},
};
// Save config
std::fs::create_dir_all(&config_dir)?;
let toml_string = toml::to_string_pretty(&config)?;
std::fs::write(&config_path, toml_string)?;
println!("\nโ
Configuration saved to: {}", config_path.display());
println!("\n๐ You can now run 'lazycelery' to start monitoring your Celery workers!");
// Test connection
print!("\n๐ Test connection to broker? (Y/n): ");
io::stdout().flush()?;
let mut test_input = String::new();
io::stdin().read_line(&mut test_input)?;
if !test_input.trim().eq_ignore_ascii_case("n") {
print!("๐ Testing connection to {}... ", config.broker.url);
io::stdout().flush()?;
match test_broker_connection(&config.broker.url).await {
Ok(_) => println!("โ
Success!"),
Err(e) => println!("โ Failed: {e}"),
}
}
Ok(())
}
fn show_config() -> Result<()> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
.join("lazycelery");
let config_path = config_dir.join("config.toml");
if !config_path.exists() {
eprintln!("โ No configuration found. Run 'lazycelery init' to create one.");
return Ok(());
}
let config = Config::from_file(config_path.clone())?;
println!("๐ Current Configuration");
println!("๐ Location: {}", config_path.display());
println!("\n[broker]");
println!(" url = \"{}\"", config.broker.url);
println!(" timeout = {}", config.broker.timeout);
println!(" retry_attempts = {}", config.broker.retry_attempts);
println!("\n[ui]");
println!(" refresh_interval = {}", config.ui.refresh_interval);
println!(" theme = \"{}\"", config.ui.theme);
Ok(())
}
fn set_broker_url(url: &str) -> Result<()> {
// Validate URL
if !url.starts_with("redis://") && !url.starts_with("amqp://") {
eprintln!("โ Invalid broker URL. Must start with redis:// or amqp://");
return Ok(());
}
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
.join("lazycelery");
let config_path = config_dir.join("config.toml");
// Load existing config or create default
let mut config = if config_path.exists() {
Config::from_file(config_path.clone())?
} else {
std::fs::create_dir_all(&config_dir)?;
Config::default()
};
// Update broker URL
config.broker.url = url.to_string();
// Save config
let toml_string = toml::to_string_pretty(&config)?;
std::fs::write(&config_path, toml_string)?;
println!("โ
Broker URL updated to: {url}");
println!("๐ Configuration saved to: {}", config_path.display());
Ok(())
}
fn set_refresh_interval(interval: u64) -> Result<()> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?
.join("lazycelery");
let config_path = config_dir.join("config.toml");
// Load existing config or create default
let mut config = if config_path.exists() {
Config::from_file(config_path.clone())?
} else {
std::fs::create_dir_all(&config_dir)?;
Config::default()
};
// Update refresh interval
config.ui.refresh_interval = interval;
// Save config
let toml_string = toml::to_string_pretty(&config)?;
std::fs::write(&config_path, toml_string)?;
println!("โ
Refresh interval updated to: {interval}ms");
println!("๐ Configuration saved to: {}", config_path.display());
Ok(())
}
async fn test_broker_connection(url: &str) -> Result<()> {
create_broker(url).await?;
Ok(())
}
</file>
<file path="tests/integration_test.rs">
use lazycelery::app::App;
mod test_broker_utils;
use test_broker_utils::MockBrokerBuilder;
#[test]
fn test_navigation_and_selection() {
let broker = MockBrokerBuilder::for_integration_tests();
let mut app = App::new(broker);
// Test initial state
assert_eq!(app.selected_worker, 0);
assert_eq!(app.selected_task, 0);
assert_eq!(app.selected_queue, 0);
// Test tab navigation
app.next_tab();
app.next_tab();
// Test item selection
app.select_next();
app.select_previous();
// Verify state is maintained
assert_eq!(app.selected_worker, 0);
}
#[tokio::test]
async fn test_full_application_flow() {
let broker = MockBrokerBuilder::for_integration_tests();
let mut app = App::new(broker);
// Test data refresh
app.refresh_data().await.unwrap();
// Verify we have the expected integration test data
assert_eq!(app.workers.len(), 3);
assert_eq!(app.tasks.len(), 5);
assert_eq!(app.queues.len(), 4);
// Verify specific worker data
assert_eq!(app.workers[0].hostname, "celery@worker-prod-1");
assert_eq!(app.workers[0].processed, 15234);
// Verify specific task data
assert_eq!(app.tasks[0].id, "task-001");
assert_eq!(app.tasks[0].name, "app.tasks.send_welcome_email");
// Verify specific queue data
assert_eq!(app.queues[0].name, "default");
assert_eq!(app.queues[0].length, 42);
// Test navigation: Workers -> Queues -> Tasks -> Queues
app.next_tab(); // Workers -> Queues
app.next_tab(); // Queues -> Tasks
app.previous_tab(); // Tasks -> Queues
// Should be on Queues tab now, so test queue selection
app.select_next();
assert_eq!(app.selected_queue, 1);
app.select_previous();
assert_eq!(app.selected_queue, 0);
// Go to Tasks tab to test task selection
app.next_tab(); // Queues -> Tasks
app.select_next();
assert_eq!(app.selected_task, 1);
app.select_previous();
assert_eq!(app.selected_task, 0);
// Go back to Workers tab to test worker selection
app.next_tab(); // Tasks -> Workers
app.select_next();
assert_eq!(app.selected_worker, 1);
app.select_previous();
assert_eq!(app.selected_worker, 0);
}
</file>
<file path=".gitignore">
# Rust
/target
**/*.rs.bk
# Editor directories and files
.idea/
.vscode/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Environment
.env
.env.*
.mise.local.toml
# Logs
*.log
# Temporary files
*.tmp
*.temp
# Output files
repomix-output.txt
repomix-output.md
# Coverage
*.profraw
cobertura.xml
coverage/
tarpaulin-report.html
# Benchmarks
/target/criterion
# Documentation
/target/doc
# Backup files
*.bak
*.orig
# Development and testing files
test_env/
__pycache__/
*.pyc
*.pyo
# test_*.py
# test_*.rs
*.pid
MVP_DEVELOPMENT_PLAN.md
celery_worker.log
</file>
<file path="CLAUDE.md">
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
LazyCelery is a terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker/lazygit. This is a Rust project with a fully functional architecture and UI framework. Currently implementing real Celery protocol integration to replace mock data systems.
## Development Commands
```bash
# Core development commands using mise:
mise run dev # Run with auto-reload (auto-starts Redis)
mise run test # Run all tests
mise run test-watch # Run tests in watch mode
mise run fmt # Format code
mise run lint # Lint code (clippy)
mise run audit # Security audit
mise run pre-commit # Run all checks before committing
# Setup and environment:
mise run setup # Setup development environment
mise run redis-start # Start Redis server via Docker
mise run redis-stop # Stop Redis server
# Git hooks setup (run once after cloning):
git config core.hooksPath .githooks # Enable pre-commit quality checks
# Specific commands for development:
cargo test --test integration # Run specific test file
cargo test worker::tests # Run specific module tests
cargo run -- --broker redis://localhost:6379/0 # Run with specific broker
```
## Architecture Overview
### Core Design Principles
1. **Single App State**: All application state lives in `src/app.rs` in the `App` struct
2. **Async Broker Operations**: All broker interactions use async/await with Tokio
3. **Trait-Based Broker Interface**: Common trait for Redis/AMQP implementations
4. **Widget-Based UI**: Each UI component is a separate widget with render() and handle_key()
### Data Flow Architecture
```
Broker (Redis/AMQP) โ Async Broker Client โ App State โ UI Widgets โ Terminal
โ โ
โโโ Background refresh task (1 second interval)
```
### Key Architectural Decisions
- **Error Handling**: Custom error types with `thiserror` for broker operations, `anyhow::Result` for main()
- **State Updates**: Background task updates data every second, UI thread only handles rendering
- **Event Loop**: Separate UI event handling from data updates to prevent blocking
- **UI Refresh**: Limited to 10 FPS to reduce CPU usage
### Module Responsibilities
- `broker/`: Async broker clients implementing common `Broker` trait (Redis fully functional, AMQP placeholder)
- `models/`: Complete data structures (Worker, Task, Queue) with serde serialization
- `ui/widgets/`: Fully implemented widgets (workers.rs, tasks.rs, queues.rs) with navigation and search
- `ui/events.rs`: Keyboard event handling including search mode and vim-style navigation
- `app.rs`: Central state management with tab navigation, selection, and search functionality
- `main.rs`: Complete CLI parsing, async tokio runtime, and event loop coordination
- `config.rs`: TOML configuration support with broker connection settings
- `error.rs`: Custom error types using `thiserror` for broker and application errors
## Current Implementation Status
**Fully Implemented (100%):**
- Complete terminal UI with workers/queues/tasks widgets and navigation
- Application architecture with centralized state management
- Configuration system with TOML support
- Error handling with custom error types
- Complete data models with serialization
- **Redis broker client with real Celery protocol integration**
- **Task retry/revoke functionality implemented**
- **Queue purge operations with confirmation dialogs**
- **Comprehensive CI/CD pipeline with automated releases**
- **Performance optimizations and caching**
- **Pre-commit hooks and quality gates**
**Partially Implemented (75%):**
- AMQP/RabbitMQ broker client (placeholder structure exists, needs real implementation)
- Advanced queue management features (basic operations work)
**Not Implemented (0%):**
- Real-time worker monitoring and stats collection
- Advanced task scheduling and workflow management
- Plugin system for custom brokers
## Current Development Focus
The project has successfully completed the MVP phase for Redis integration. Current status:
- **Branch**: `feature/mvp-core-monitoring` (ready for merge)
- **Redis Integration**: โ
Complete with real Celery protocol parsing
- **Task Management**: โ
Retry, revoke, and queue purge operations
- **CI/CD**: โ
Fully automated releases and quality checks
- **Performance**: โ
Optimized with aggressive caching
**Next priorities for future development:**
1. Complete AMQP/RabbitMQ broker implementation
2. Add real-time worker monitoring and health checks
3. Implement advanced task filtering and search
4. Add support for task routing and scheduling
5. Create plugin architecture for custom brokers
## Testing Strategy
**Comprehensive test suite exists for:**
- Model serialization/deserialization (complete)
- UI widget state management and navigation (complete)
- Configuration loading from TOML files (complete)
- Error handling across all modules (complete)
- **Redis broker functionality with real Celery protocol parsing (complete)**
- **Task operations (retry, revoke, queue purge) (complete)**
- **Integration tests with Redis service containers (complete)**
- **Cross-platform CI testing (Linux, macOS, Windows) (complete)**
**Test execution:**
- `mise run test` - Run all tests (enforced by pre-commit hooks)
- `mise run test-watch` - Continuous testing during development
- `cargo test --test integration` - Integration tests specifically
- `cargo test broker::tests::redis` - Redis broker tests
- **`cargo test --lib --bins` - Unit tests only (used in CI)**
**Test Coverage Status:**
- โ
**Redis Celery protocol integration tests (100%)**
- โ
**Task action tests with real Redis operations (100%)**
- โ
**UI interaction and navigation tests (100%)**
- โ
**Configuration and error handling tests (100%)**
- โ ๏ธ **AMQP broker implementation tests (placeholder only)**
- โ ๏ธ **End-to-end tests with actual Celery workers (manual testing only)**
**CI/CD Testing:**
- All tests run automatically on every PR
- Redis integration tests run with Docker service containers
- One integration test temporarily disabled due to CI interference issues
- Security audit runs on all dependencies
- Cross-platform testing ensures compatibility
## Performance Considerations
Current optimizations in place:
- 10 FPS UI refresh limit to reduce CPU usage
- Pagination for task lists over 100 items
- Async broker operations with proper error handling
- Efficient terminal rendering with ratatui
**Areas for future optimization:**
- Connection pooling for high-volume Redis operations
- Caching worker/queue data between refreshes
- Batching Redis requests to reduce network overhead
## Release Management and CI/CD
### ๐จ CRITICAL: Automated Release System
This project has **100% AUTOMATED RELEASES** configured. Any push to `main` branch will automatically trigger a release if there are new commits since the last tag.
### Release Process (FULLY AUTOMATED)
1. **Automatic Trigger**: When PR is merged to `main` branch
2. **Smart Detection**: Analyzes commits to determine release type:
- `feat:` or `feature:` โ **MINOR** release (0.2.0 โ 0.3.0)
- `feat!:` or `BREAKING CHANGE` โ **MAJOR** release (0.2.0 โ 1.0.0)
- Any other commit โ **PATCH** release (0.2.0 โ 0.2.1)
3. **Automatic Actions**:
- Auto-bump version in Cargo.toml
- Generate updated changelog using git-cliff
- Create commit with `[skip ci]` to avoid loops
- Create and push git tag
- Publish to crates.io automatically
- Create GitHub release with cross-platform binaries
### ๐ก๏ธ Release Safety Rules
**NEVER manually edit these without understanding the impact:**
- `Cargo.toml` version field (managed by automated releases)
- Git tags (created automatically by release workflow)
- CHANGELOG.md (generated automatically by git-cliff)
**To SKIP automatic release, use one of these in commit message:**
- `[skip ci]` - Skip all CI including release
- `chore:` prefix - Maintenance commits don't trigger releases
- `docs:` prefix - Documentation-only changes don't trigger releases
### ๐ Workflow Files (DO NOT MODIFY WITHOUT REVIEW)
- `.github/workflows/ci.yml` - Main CI pipeline with optimized caching
- `.github/workflows/release.yml` - Automated release pipeline
- `.githooks/pre-commit` - Local quality gates before commits
### Conventional Commit Format (REQUIRED)
Use conventional commit format to ensure proper release type detection:
```bash
# Examples that trigger releases:
git commit -m "feat(broker): add AMQP support" # โ minor release
git commit -m "fix(ui): resolve task display issue" # โ patch release
git commit -m "feat!: change broker interface" # โ major release
# Examples that DON'T trigger releases:
git commit -m "docs: update README [skip ci]" # โ no release
git commit -m "chore: cleanup code formatting" # โ no release
git commit -m "test: add more integration tests" # โ no release
```
### Quality Gates (ENFORCED)
All commits must pass:
- Code formatting (`cargo fmt`)
- Linting (`cargo clippy` with zero warnings)
- All tests (unit + integration, excluding Redis tests in CI)
- Security audit (`cargo audit`)
**Pre-commit hooks automatically enforce these locally.**
### Version Management
- Current version: **0.2.0** (in Cargo.toml)
- Versions are managed automatically by GitHub Actions
- Manual version bumps will be overridden by automated releases
- Use semantic versioning (semver) strictly
### Emergency Release Controls
If you need to emergency stop releases:
1. Add `[skip ci]` to all commits
2. Or temporarily disable the release workflow in GitHub Actions
3. Or create commits with `chore:` prefix only
### Configuration Secrets
The following repository secrets are required for automated releases:
- `CARGO_REGISTRY_TOKEN` - Token for publishing to crates.io (must be configured)
### Monitoring Releases
- Check GitHub Actions tab for release status
- Monitor crates.io for successful publications: https://crates.io/crates/lazycelery
- GitHub Releases page shows all published versions with binaries
## Claude Code Guidelines
### ๐จ CRITICAL: Release Management
**BEFORE making ANY changes to this repository:**
1. **Understand the automated release system** - This project auto-releases on every push to `main`
2. **Use conventional commits** - Your commit messages determine the release type
3. **Never manually edit versions** - The Cargo.toml version is managed automatically
4. **Test thoroughly** - All changes are automatically published to crates.io
5. **Use `[skip ci]` when appropriate** - For documentation or non-functional changes
### Preferred Commit Message Patterns
```bash
# Use these patterns for commits:
feat(module): add new functionality # โ triggers MINOR release
fix(module): resolve specific issue # โ triggers PATCH release
feat!(module): breaking change # โ triggers MAJOR release
docs: update documentation [skip ci] # โ no release
chore: maintenance tasks [skip ci] # โ no release
test: add more test coverage # โ no release
```
### When Working on Features
1. **Always work on feature branches** - Never commit directly to `main`
2. **Run `mise run pre-commit`** before committing - Ensures quality
3. **Test locally first** - Use `mise run test` to verify changes
4. **Use descriptive commit messages** - They become part of changelog
5. **Consider release impact** - Your commits will trigger automatic releases
### Safe Operations
**These operations are SAFE and won't trigger releases:**
- Creating feature branches
- Making commits on feature branches
- Running tests and development commands
- Editing documentation with `[skip ci]`
- Using `chore:` or `docs:` commit prefixes
**These operations WILL trigger releases:**
- Merging to `main` branch
- Any commit to `main` with `feat:` or `fix:` prefix
- Direct pushes to `main` (should be avoided)
### Emergency Procedures
If you accidentally trigger an unwanted release:
1. **Don't panic** - Releases are versioned and can be yanked from crates.io if needed
2. **Check GitHub Actions** - See if you can cancel the workflow before it publishes
3. **Contact the maintainer** - If a bad release was published
4. **Learn from it** - Use `[skip ci]` or `chore:` prefixes for non-functional changes
### Development Workflow
1. **Create feature branch**: `git checkout -b feature/your-feature`
2. **Make changes with proper testing**: `mise run test`
3. **Use pre-commit checks**: `mise run pre-commit`
4. **Commit with conventional format**: `git commit -m "feat: your change"`
5. **Push and create PR**: Let CI validate before merge
6. **After PR merge**: Automatic release will be triggered
### File Modification Guidelines
**Modify freely:**
- `src/` directory (application code)
- `tests/` directory (test files)
- Documentation files (with `[skip ci]`)
- Configuration files (examples/, configs/)
**Modify with caution:**
- `Cargo.toml` dependencies (test thoroughly)
- `.github/workflows/` (can break CI/CD)
- `.githooks/` (can break quality gates)
**NEVER modify without explicit permission:**
- `Cargo.toml` version field (auto-managed)
- Git tags (auto-created)
- `CHANGELOG.md` (auto-generated)
This guidance ensures smooth collaboration while protecting the automated release system.
</file>
<file path=".mise.toml">
# mise configuration for LazyCelery
# https://mise.jdx.dev/
[env]
RUST_LOG = "info"
RUST_BACKTRACE = "1"
[tools]
rust = "1.88.0"
git-cliff = "latest"
[tasks.build]
description = "Build the project in release mode"
run = "cargo build --release"
[tasks.dev]
description = "Run in development mode with auto-reload"
run = [
"cargo install cargo-watch",
"cargo watch -x 'run -- --broker redis://localhost:6379/0'"
]
[tasks.test]
description = "Run all tests"
run = "cargo test --all-features"
[tasks.test-watch]
description = "Run tests in watch mode"
run = "cargo watch -x test"
[tasks.lint]
description = "Run clippy linter"
run = "cargo clippy --all-targets --all-features -- -D warnings"
[tasks.fmt]
description = "Format code"
run = "cargo fmt --all"
[tasks.check]
description = "Check formatting and linting"
run = [
"cargo fmt --all -- --check",
"cargo clippy --all-targets --all-features -- -D warnings"
]
[tasks.clean]
description = "Clean build artifacts"
run = [
"cargo clean",
"rm -rf target/"
]
[tasks.audit]
description = "Run security audit"
run = [
"cargo install cargo-audit",
"cargo audit"
]
[tasks.coverage]
description = "Generate test coverage report"
run = [
"cargo install cargo-tarpaulin",
"cargo tarpaulin --verbose --all-features --workspace --timeout 120 --out html"
]
[tasks.docs]
description = "Generate and open documentation"
run = "cargo doc --no-deps --open"
[tasks.install]
description = "Install locally"
run = "cargo install --path ."
[tasks.run]
description = "Run with Redis broker"
run = "cargo run -- --broker redis://localhost:6379/0"
depends = ["redis-start"]
[tasks.redis-start]
description = "Start Redis server using Docker"
run = """
if ! docker ps | grep -q lazycelery-redis; then
docker run -d --name lazycelery-redis -p 6379:6379 redis:alpine
echo "Redis started on port 6379"
else
echo "Redis already running"
fi
"""
[tasks.redis-stop]
description = "Stop Redis server"
run = """
docker stop lazycelery-redis 2>/dev/null || true
docker rm lazycelery-redis 2>/dev/null || true
echo "Redis stopped"
"""
[tasks.docker-build]
description = "Build Docker image"
run = "docker build -t lazycelery:latest ."
[tasks.docker-run]
description = "Run Docker container"
run = "docker run -it --rm --network host lazycelery:latest --broker redis://localhost:6379/0"
depends = ["docker-build", "redis-start"]
[tasks.release]
description = "Create a release build"
run = "cargo build --release --locked"
[tasks.pre-commit]
description = "Run pre-commit checks"
run = [
"mise run fmt",
"mise run lint",
"mise run test",
"mise run audit",
"mise run validate-versions"
]
[tasks.changelog]
description = "Generate changelog"
run = "git-cliff -o CHANGELOG.md"
[tasks.version-bump]
description = "Bump version (specify: patch, minor, or major)"
run = """
if [ -z "$1" ]; then
echo "Usage: mise run version-bump [patch|minor|major]"
exit 1
fi
cargo install cargo-edit
cargo set-version --bump $1
NEW_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
echo "Bumped version to $NEW_VERSION"
"""
[tasks.setup]
description = "Setup development environment"
run = [
"rustup component add rustfmt clippy",
"mise install",
"mise run redis-start",
"echo 'Development environment ready!'"
]
[tasks.all]
description = "Run all checks (lint, test, audit)"
depends = ["lint", "test", "audit"]
[tasks.help]
description = "Show available tasks"
run = "mise tasks"
[tasks.validate-versions]
description = "Validate version consistency across project files"
run = "python3 scripts/validate-versions.py"
[tasks.fix-versions]
description = "Fix version inconsistencies automatically"
run = "python3 scripts/validate-versions.py --fix"
</file>
<file path="src/ui/mod.rs">
pub mod events;
pub mod layout;
pub mod modals;
pub mod widgets;
use ratatui::Frame;
use crate::app::{App, Tab};
use crate::ui::layout::{create_main_layout, draw_header, draw_status_bar};
use crate::ui::modals::{draw_confirmation_dialog, draw_help, draw_task_details_modal};
use crate::ui::widgets::{QueueWidget, TaskWidget, Widget, WorkerWidget};
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = create_main_layout(f.area());
// Draw header with tabs
draw_header(f, app, chunks[0]);
// Draw main content based on selected tab
match app.selected_tab {
Tab::Workers => WorkerWidget::draw(f, app, chunks[1]),
Tab::Tasks => TaskWidget::draw(f, app, chunks[1]),
Tab::Queues => QueueWidget::draw(f, app, chunks[1]),
}
// Draw status bar
draw_status_bar(f, app, chunks[2]);
// Draw help overlay if active
if app.show_help {
draw_help(f);
}
// Draw confirmation dialog if active
if app.show_confirmation {
draw_confirmation_dialog(f, app);
}
// Draw task details modal if active
if app.show_task_details {
draw_task_details_modal(f, app);
}
}
</file>
<file path="CHANGELOG.md">
## [0.7.2] - 2025-08-04
### ๐ Bug Fixes
- Add rustfmt and clippy components to crates.io publish workflow
- Clarify Cargo installation method in README
### โ๏ธ Miscellaneous Tasks
- Add success message to rust components installation
## [0.6.0] - 2025-08-04
### ๐ Features
- Add CLI subcommands for improved configuration management
## [0.5.0] - 2025-08-03
### ๐ Features
- Improve onboarding experience with better error messages and auto-config
## [0.4.5] - 2025-08-03
### ๐ Bug Fixes
- Use proper shell syntax for Windows builds
- Use proper shell syntax for Windows builds
## [0.4.4] - 2025-08-03
### ๐ Bug Fixes
- Prevent Prepare Release job from running on tag workflows
## [0.4.3] - 2025-08-03
### ๐ Bug Fixes
- Add desktop.ini to gitignore for Windows compatibility
## [0.4.2] - 2025-08-03
### ๐ Bug Fixes
- Correct workflow release logic to handle version bump scenarios
- Remove complex dependencies from release workflow
- Correct YAML syntax error in release workflow
- Add debugging category to package metadata
- Add write permissions to release workflow
### ๐ผ Other
- *(deps)* Bump base64 from 0.21.7 to 0.22.1
- *(deps)* Bump softprops/action-gh-release from 1 to 2
- *(deps)* Bump tokio from 1.46.1 to 1.47.0
### ๐ Refactor
- Simplify release workflow logic for better reliability
## [0.4.1] - 2025-07-21
### ๐ Bug Fixes
- Correct pre-commit hook configuration and resolve compilation issues
- Add comprehensive behavioral test coverage for untested modules
- Bump version to 0.4.1 for comprehensive test coverage release
### ๐ Refactor
- Complete architectural refactoring for better modularity and maintainability
- Implement comprehensive code quality improvements
### โ๏ธ Miscellaneous Tasks
- Tidy up project by removing unnecessary files
- Comprehensive project tidy and cleanup
- Enable tracking of test files in git
## [0.4.0] - 2025-07-20
### ๐ Features
- *(ui)* Implement comprehensive task details modal
## [0.3.0] - 2025-07-20
### ๐ Features
- Implement LazyCelery - Terminal UI for Celery monitoring
- Add Docker support and update project metadata
- Add comprehensive test suite for critical components
- Add CI/CD workflows and GitHub configuration
- Add professional terminal UI screenshots
- Add comprehensive project roadmap
- Complete MVP core actions with queue purge and confirmation dialogs
- Enhance CI/CD workflows with automated releases
- Add pre-commit hooks for code quality checks
- *(release)* Configure automated crates.io publishing
- Configure 100% automatic releases on PR merge
- Configure complete multi-platform package manager automation
- Complete MVP Core Actions v0.2.0 - Queue Purge & Confirmation Dialogs
### ๐ Bug Fixes
- Resolve clippy warnings and improve code quality
- Resolve mise-action configuration issues
- Resolve CI/CD workflow failures
- Use separate Redis DB for integration tests to avoid CI conflicts
- Use unique task IDs in integration test to avoid interference
- Improve test assertion to focus on our specific test tasks
- Install cargo-audit before running security audit
- *(ci)* Remove unsupported --locked flag from cargo audit command
### ๐ผ Other
- Migrate from Makefile to mise task runner
- Add debug output to integration test to troubleshoot CI failure
- Add timing delay to ensure data persistence in CI environment
### ๐ Refactor
- Rename master branch to main
### ๐ Documentation
- Update documentation and add project configuration
- Update changelog with complete project history
- Update CLAUDE.md with comprehensive release automation guidelines [skip ci]
### โก Performance
- Optimize CI workflow for faster execution
- Implement aggressive caching to eliminate crates.io downloads
### ๐จ Styling
- Fix formatting in test file
- Apply rustfmt formatting
### โ๏ธ Miscellaneous Tasks
- Update workflows to use mise commands
- Remove unnecessary files
- Clean up redundant workflows and update labeler
</file>
<file path="README.md">
# LazyCelery
[](https://github.com/fguedes90/lazycelery/actions/workflows/ci.yml)
[](https://github.com/fguedes90/lazycelery/releases)
[](https://opensource.org/licenses/MIT)
[](https://crates.io/crates/lazycelery)
A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker and lazygit.
## Features
- Real-time worker monitoring
- Queue management with message counts
- Task listing with status tracking
- Search and filter capabilities
- Keyboard-driven interface
- Interactive CLI configuration with subcommands
- Automatic configuration file management
- Helpful error messages and setup guidance
## Screenshots
### Main Dashboard - Workers View

### Queue Management

### Task Monitoring

### Search Mode

### Help Screen

## Installation
Choose your preferred installation method:
### ๐ฆ Cargo (Rust package manager)
```bash
cargo install lazycelery
```
### ๐บ Homebrew (macOS/Linux)
```bash
brew tap Fguedes90/tap
brew install lazycelery
```
### ๐ชฃ Scoop (Windows)
```bash
scoop bucket add lazycelery https://github.com/Fguedes90/scoop-bucket.git
scoop install lazycelery
```
### ๐ฅ Binary Download
Download pre-built binaries from [GitHub Releases](https://github.com/Fguedes90/lazycelery/releases):
- **Linux x86_64**: `lazycelery-linux-x86_64.tar.gz`
- **macOS x86_64**: `lazycelery-macos-x86_64.tar.gz`
- **macOS ARM64**: `lazycelery-macos-aarch64.tar.gz`
- **Windows x86_64**: `lazycelery-windows-x86_64.zip`
### ๐ง From Source
```bash
# Clone the repository
git clone https://github.com/fguedes90/lazycelery.git
cd lazycelery
# Install mise (task runner)
./scripts/install-mise.sh
# Setup development environment
mise run setup
# Build release binary
mise run release
```
## Quick Start
### First Time Setup
```bash
# Run interactive setup
lazycelery init
# Or start with default Redis configuration
lazycelery --broker redis://localhost:6379/0
```
### Configuration Management
LazyCelery provides several subcommands to manage your configuration without editing files:
```bash
# Initialize configuration with interactive setup
lazycelery init
# Show current configuration
lazycelery config
# Update broker URL
lazycelery set-broker redis://localhost:6379/0
# Update refresh interval (milliseconds)
lazycelery set-refresh 2000
```
### Running LazyCelery
```bash
# Use configured settings
lazycelery
# Override broker URL
lazycelery --broker redis://localhost:6379/0
# Use custom config file
lazycelery --config ~/.config/lazycelery/config.toml
```
### Troubleshooting Connection Issues
If you encounter connection errors, LazyCelery provides helpful setup instructions:
1. **Start Redis** (choose one):
```bash
# Docker
docker run -d -p 6379:6379 redis
# macOS
brew services start redis
# Linux
sudo systemctl start redis
```
2. **Verify Redis is running**:
```bash
redis-cli ping
```
3. **Run LazyCelery**:
```bash
lazycelery --broker redis://localhost:6379/0
```
## Keyboard Shortcuts
- `Tab` - Switch between Workers/Queues/Tasks
- `โ/โ` or `j/k` - Navigate items
- `/` - Search mode
- `?` - Show help
- `q` - Quit
## Development
### Prerequisites
- Rust 1.70.0 or later
- Redis (for testing)
- [mise](https://mise.jdx.dev/) (task runner)
### Quick Start
```bash
# Install mise if you haven't already
./scripts/install-mise.sh
# Setup development environment
mise run setup
# Run with auto-reload
mise run dev
# Run tests in watch mode
mise run test-watch
```
### Available Tasks
```bash
mise tasks # Show all available tasks
mise run build # Build release binary
mise run dev # Run with auto-reload
mise run test # Run tests
mise run lint # Run linter
mise run fmt # Format code
mise run audit # Security audit
mise run coverage # Generate coverage report
mise run docs # Generate documentation
```
### Pre-commit Checks
Before committing, run:
```bash
mise run pre-commit
```
This runs formatting, linting, tests, and security audit.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
</file>
<file path=".github/workflows/ci.yml">
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# Aggressive cargo optimizations to avoid re-downloading crates
CARGO_NET_RETRY: 10
# Disable incremental compilation for CI builds (more deterministic)
CARGO_INCREMENTAL: 0
CARGO_NET_GIT_FETCH_WITH_CLI: true
# Use sparse registry for faster index updates
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
# Additional optimizations
CARGO_HTTP_MULTIPLEXING: false
CARGO_NET_OFFLINE: false
# Reduce memory usage in parallel builds
CARGO_BUILD_JOBS: 4
jobs:
# Detect what types of changes were made
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@v5
- name: Detect file changes
uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'src/**'
- 'Cargo.*'
- '.github/workflows/**'
- 'tests/**'
- '.cargo/**'
docs:
- '*.md'
- 'docs/**'
- 'screenshots/**'
# Combine quick checks into one job for faster feedback
quality-checks:
name: Code Quality (Format, Lint, Check)
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.code == 'true'
steps:
- uses: actions/checkout@v5
- name: Setup optimized Rust cache
uses: Swatinem/rust-cache@v2
with:
# Shared key for quality checks
shared-key: "quality-checks"
# Let rust-cache handle standard directories automatically
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }}
# Include Cargo.lock in key for more precise caching
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache workspaces for better incremental builds
workspaces: "."
- name: Install mise (cached)
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache Rustup toolchain
uses: actions/cache@v4
with:
path: |
~/.rustup/settings.toml
~/.rustup/toolchains
~/.rustup/update-hashes
key: ${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain.toml', 'rust-toolchain') }}
restore-keys: |
${{ runner.os }}-rustup-
- name: Install Rust components (cached)
run: |
rustup component add clippy rustfmt
- name: Cache cargo check
run: |
# Use cargo check with cache optimization
if ! cargo check --locked; then
echo "โ Cargo check failed"
exit 1
fi
echo "โ
Cargo check passed"
- name: Check formatting (fast)
run: |
if ! cargo fmt --all --check; then
echo "โ Code is not formatted correctly"
echo "Run 'cargo fmt' to fix formatting"
exit 1
fi
echo "โ
Code formatting is correct"
- name: Run clippy (cached)
run: |
if ! cargo clippy --locked --all-targets --all-features -- -D warnings; then
echo "โ Linting failed"
exit 1
fi
echo "โ
All linting checks passed"
test:
name: Test
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.code == 'true'
services:
redis:
image: redis:alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 3s
--health-timeout 2s
--health-retries 2
steps:
- uses: actions/checkout@v5
- name: Setup optimized Rust cache for tests
uses: Swatinem/rust-cache@v2
with:
# Maximum caching for tests
shared-key: "test-deps"
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }}
# Cache all targets for comprehensive testing
cache-targets: "true"
cache-all-crates: "true"
# Include Cargo.lock in key for more precise caching
key: ${{ runner.os }}-test-${{ hashFiles('**/Cargo.lock') }}
workspaces: "."
- name: Install mise (cached)
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache APT packages
uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: redis-tools
version: 1.0
- name: Run tests with maximum optimization
run: |
echo "Running tests with cache optimization..."
# Use locked deps and optimized flags for faster test execution
if ! cargo test --locked --release --jobs $(nproc) -- --test-threads $(nproc); then
echo "โ Tests failed"
exit 1
fi
echo "โ
All tests passed"
security:
name: Security Audit
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.code == 'true'
steps:
- uses: actions/checkout@v5
- name: Setup optimized Rust cache for security
uses: Swatinem/rust-cache@v2
with:
shared-key: "security-audit"
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install mise (cached)
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache cargo-audit binary
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cargo-audit
key: cargo-audit-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-audit-${{ runner.os }}-
- name: Install cargo-audit if not cached
run: |
if ! command -v cargo-audit &> /dev/null; then
echo "Installing cargo-audit..."
cargo install cargo-audit --locked
else
echo "โ
cargo-audit already cached"
fi
- name: Run security audit
run: |
# Run security audit (cargo audit doesn't support --locked flag)
if ! cargo audit; then
echo "โ Security audit failed"
exit 1
fi
echo "โ
Security audit passed"
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: detect-changes
if: needs.detect-changes.outputs.code == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v5
- name: Setup optimized build cache
uses: Swatinem/rust-cache@v2
with:
shared-key: "build-${{ matrix.os }}"
cache-on-failure: true
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-targets: "true"
cache-all-crates: "true"
- name: Install mise (cached)
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build release with maximum cache optimization
run: |
echo "Building on ${{ matrix.os }}"
# Use locked deps and parallel compilation
cargo build --locked --release --jobs $(nproc || echo 4)
echo "โ
Build successful on ${{ matrix.os }}"
- name: Test binary (Unix)
if: matrix.os != 'windows-latest'
run: |
./target/release/lazycelery --help
echo "โ
Binary works correctly"
- name: Test binary (Windows)
if: matrix.os == 'windows-latest'
run: |
.\target\release\lazycelery.exe --help
echo "โ
Binary works correctly"
# Lightweight docs check for documentation-only changes
docs-check:
name: Documentation Check
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.docs == 'true' && needs.detect-changes.outputs.code == 'false'
steps:
- uses: actions/checkout@v5
- name: Check markdown files
run: |
echo "๐ Checking documentation files..."
# Simple check for broken markdown links (basic validation)
find . -name "*.md" -exec echo "Checking {}" \;
echo "โ
Documentation check completed"
</file>
<file path=".github/workflows/release.yml">
name: Release
on:
# Automatic release on push to main branch
push:
branches: [ main ]
tags:
- 'v*'
# Manual release with version selection
workflow_dispatch:
inputs:
version_type:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
pull-requests: write
env:
CARGO_TERM_COLOR: always
jobs:
# Determine if we should release and what type
check-release:
name: Check Release Needed
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
should_release: ${{ steps.check.outputs.should_release }}
version_type: ${{ steps.check.outputs.version_type }}
skip_ci: ${{ steps.check.outputs.skip_ci }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if release needed
id: check
run: |
# Get the last commit message
LAST_COMMIT=$(git log -1 --pretty=format:"%s")
echo "Last commit: $LAST_COMMIT"
# Skip if this is a release commit to avoid infinite loop
if [[ "$LAST_COMMIT" == *"chore(release):"* ]] || [[ "$LAST_COMMIT" == *"[skip ci]"* ]]; then
echo "skip_ci=true" >> $GITHUB_OUTPUT
echo "should_release=false" >> $GITHUB_OUTPUT
echo "๐ Skipping release - this is a release commit or has [skip ci]"
exit 0
fi
# Simple check: if commit message indicates release need, proceed
if echo "$LAST_COMMIT" | grep -qE "^(feat|fix|refactor)(\(.+\))?:"; then
echo "should_release=true" >> $GITHUB_OUTPUT
echo "โ
Release needed based on commit message: $LAST_COMMIT"
# Determine version bump type based on conventional commits
if echo "$LAST_COMMIT" | grep -qE "^(feat|feature)(\(.+\))?!:|^.+!:|BREAKING CHANGE"; then
echo "version_type=major" >> $GITHUB_OUTPUT
echo "๐ฅ Major release detected (breaking changes)"
elif echo "$LAST_COMMIT" | grep -qE "^(feat|feature)(\(.+\))?:"; then
echo "version_type=minor" >> $GITHUB_OUTPUT
echo "โจ Minor release detected (new features)"
else
echo "version_type=patch" >> $GITHUB_OUTPUT
echo "๐ง Patch release detected (bug fixes/improvements)"
fi
else
echo "should_release=false" >> $GITHUB_OUTPUT
echo "๐ซ No release needed - commit doesn't match release patterns"
fi
# Automatic release preparation
auto-prepare-release:
name: Auto Prepare Release
runs-on: ubuntu-latest
needs: check-release
if: needs.check-release.outputs.should_release == 'true' && !needs.check-release.outputs.skip_ci
outputs:
new_version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Configure git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Install mise
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: |
rustup component add rustfmt clippy
cargo install cargo-edit
echo "โ
Dependencies installed"
- name: Determine and set version
run: |
echo "Determining version for release type: ${{ needs.check-release.outputs.version_type }}"
# Get current version from Cargo.toml
CURRENT_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
echo "Current version in Cargo.toml: $CURRENT_VERSION"
# Get last tag version for comparison
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
LAST_TAG_VERSION=${LAST_TAG#v}
echo "Last tag version: $LAST_TAG_VERSION"
# Check if version was already manually bumped
if [ "$CURRENT_VERSION" != "$LAST_TAG_VERSION" ]; then
echo "โ
Version already manually set to $CURRENT_VERSION"
NEW_VERSION="$CURRENT_VERSION"
else
echo "๐ Auto-bumping version (${{ needs.check-release.outputs.version_type }})"
cargo set-version --bump ${{ needs.check-release.outputs.version_type }}
NEW_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
echo "โ
Version auto-bumped to $NEW_VERSION"
fi
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "๐ฏ Release version: $NEW_VERSION"
id: version
- name: Update version in UI
run: |
sed -i "s/LazyCelery v[0-9]\+\.[0-9]\+\.[0-9]\+/LazyCelery v$NEW_VERSION/g" src/ui/mod.rs
- name: Generate changelog
run: |
echo "# Changelog for v$NEW_VERSION" > CHANGELOG.md
echo "" >> CHANGELOG.md
echo "## Changes" >> CHANGELOG.md
git log --oneline -10 >> CHANGELOG.md
echo "โ
Simple changelog generated"
- name: Run tests before release
run: |
echo "๐งช Running basic tests..."
cargo test --lib --bins
echo "โ
Tests completed"
- name: Commit and tag release
run: |
# Check if there are changes to commit
if git diff --cached --quiet && git diff --quiet; then
echo "๐ No changes to commit, proceeding with tag only"
else
echo "๐ Committing release changes"
git add Cargo.toml Cargo.lock CHANGELOG.md src/ui/mod.rs 2>/dev/null || true
git commit -m "chore(release): prepare for v$NEW_VERSION [skip ci]" || echo "No changes to commit"
fi
# Create and push tag
echo "๐ท๏ธ Creating tag v$NEW_VERSION"
git tag -a "v$NEW_VERSION" -m "Release v$NEW_VERSION" || echo "Tag may already exist"
echo "๐ Pushing changes and tag"
git push origin main || true
git push origin "v$NEW_VERSION" || echo "Tag push may have failed, continuing..."
echo "โ
Release preparation completed for v$NEW_VERSION"
# Manual release preparation (kept for manual workflow_dispatch)
prepare-release:
name: Prepare Release
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/')
outputs:
new_version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Install mise
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Install Rust components and dependencies
run: |
rustup component add rustfmt clippy
cargo install cargo-edit
mise install git-cliff
- name: Bump version
run: |
echo "Bumping version (${{ github.event.inputs.version_type }})"
cargo set-version --bump ${{ github.event.inputs.version_type }}
NEW_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
id: version
- name: Update version in UI
run: |
sed -i "s/LazyCelery v[0-9]\+\.[0-9]\+\.[0-9]\+/LazyCelery v$NEW_VERSION/g" src/ui/mod.rs
- name: Generate changelog
run: |
git-cliff --tag "v$NEW_VERSION" -o CHANGELOG.md
- name: Run tests
run: mise run test
- name: Commit changes
run: |
git add Cargo.toml Cargo.lock CHANGELOG.md src/ui/mod.rs
git commit -m "chore(release): prepare for v$NEW_VERSION"
- name: Create and push tag
run: |
git tag -a "v$NEW_VERSION" -m "Release v$NEW_VERSION"
git push origin main
git push origin "v$NEW_VERSION"
create-release:
name: Create Release
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install mise
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Install git-cliff
run: mise install git-cliff
- name: Get version
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Generate release notes
id: release_notes
run: |
git-cliff --latest --strip header > release_notes.md
echo "notes<<EOF" >> $GITHUB_OUTPUT
cat release_notes.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Run tests before release
run: mise run test
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: LazyCelery v${{ steps.version.outputs.version }}
body: ${{ steps.release_notes.outputs.notes }}
draft: false
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-binaries:
name: Build Binaries
needs: create-release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
name: lazycelery-linux-x86_64
archive: tar.gz
- os: windows-latest
target: x86_64-pc-windows-msvc
name: lazycelery-windows-x86_64.exe
archive: zip
- os: macos-latest
target: x86_64-apple-darwin
name: lazycelery-macos-x86_64
archive: tar.gz
- os: macos-latest
target: aarch64-apple-darwin
name: lazycelery-macos-aarch64
archive: tar.gz
steps:
- uses: actions/checkout@v5
- name: Install mise
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Rust cache for builds
uses: Swatinem/rust-cache@v2
with:
shared-key: "release-build-${{ matrix.target }}"
cache-on-failure: true
key: ${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
workspaces: "."
- name: Add target
run: rustup target add ${{ matrix.target }}
- name: Build binary
run: cargo build --release --target ${{ matrix.target }} --locked
- name: Copy binary (Unix)
if: matrix.os != 'windows-latest'
run: cp target/${{ matrix.target }}/release/lazycelery ${{ matrix.name }}
- name: Copy binary (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: Copy-Item target/${{ matrix.target }}/release/lazycelery.exe ${{ matrix.name }}
- name: Create archive (Unix)
if: matrix.archive == 'tar.gz'
run: |
tar -czf ${{ matrix.name }}.tar.gz ${{ matrix.name }}
echo "ASSET_PATH=${{ matrix.name }}.tar.gz" >> $GITHUB_ENV
- name: Create archive (Windows)
if: matrix.archive == 'zip'
run: |
Compress-Archive -Path ${{ matrix.name }} -DestinationPath ${{ matrix.name }}.zip
echo "ASSET_PATH=${{ matrix.name }}.zip" >> $env:GITHUB_ENV
- name: Upload Release Asset
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: ${{ env.ASSET_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-crate:
name: Publish to crates.io
runs-on: ubuntu-latest
needs: create-release
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v5
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: "publish-crate"
cache-on-failure: true
key: ${{ runner.os }}-publish-${{ hashFiles('**/Cargo.lock') }}
workspaces: "."
- name: Install mise
uses: jdx/mise-action@v3
with:
install: true
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust components
run: |
rustup component add rustfmt clippy
echo "Components installed successfully"
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Publishing version: $VERSION"
- name: Verify Cargo.toml version matches tag
run: |
TAG_VERSION="${{ steps.version.outputs.version }}"
CARGO_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "lazycelery") | .version')
echo "Tag version: $TAG_VERSION"
echo "Cargo.toml version: $CARGO_VERSION"
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "โ Version mismatch: tag=$TAG_VERSION, Cargo.toml=$CARGO_VERSION"
exit 1
fi
echo "โ
Version matches tag"
- name: Run final quality checks before publish
run: |
echo "๐ Running final checks before publishing..."
# Format check
cargo fmt --all --check
# Clippy check
cargo clippy --all-targets --all-features -- -D warnings
# Test check
cargo test --lib --bins
# Verify package can be built
cargo build --release --locked
echo "โ
All checks passed"
- name: Dry run publish (check package)
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
echo "๐ Running dry-run publish to validate package..."
cargo publish --dry-run --locked
echo "โ
Dry-run successful"
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
echo "๐ฆ Publishing lazycelery v${{ steps.version.outputs.version }} to crates.io..."
# Retry logic for publishing (network issues can occur)
for i in {1..3}; do
if cargo publish --locked; then
echo "โ
Successfully published to crates.io on attempt $i"
echo "๐ Available at: https://crates.io/crates/lazycelery"
break
else
echo "โ Publish attempt $i failed"
if [ $i -eq 3 ]; then
echo "โ All publish attempts failed"
exit 1
fi
echo "โณ Waiting 30 seconds before retry..."
sleep 30
fi
done
# Update package managers
update-package-managers:
name: Update Package Managers
runs-on: ubuntu-latest
needs: [create-release, publish-crate]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get release version and assets
id: release
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Get release assets URLs and checksums
LINUX_URL="https://github.com/Fguedes90/lazycelery/releases/download/v$VERSION/lazycelery-linux-x86_64.tar.gz"
MACOS_URL="https://github.com/Fguedes90/lazycelery/releases/download/v$VERSION/lazycelery-macos-x86_64.tar.gz"
WINDOWS_URL="https://github.com/Fguedes90/lazycelery/releases/download/v$VERSION/lazycelery-windows-x86_64.zip"
SOURCE_URL="https://github.com/Fguedes90/lazycelery/archive/v$VERSION.tar.gz"
echo "linux_url=$LINUX_URL" >> $GITHUB_OUTPUT
echo "macos_url=$MACOS_URL" >> $GITHUB_OUTPUT
echo "windows_url=$WINDOWS_URL" >> $GITHUB_OUTPUT
echo "source_url=$SOURCE_URL" >> $GITHUB_OUTPUT
- name: Cache checksums calculation
uses: actions/cache@v4
id: checksum-cache
with:
path: checksums-*.txt
key: checksums-${{ steps.release.outputs.version }}-${{ hashFiles('.github/workflows/release.yml') }}
- name: Calculate checksums
id: checksums
if: steps.checksum-cache.outputs.cache-hit != 'true'
run: |
VERSION=${{ steps.release.outputs.version }}
# Download and calculate checksums for all assets with retry logic
for i in {1..3}; do
curl -sL "${{ steps.release.outputs.linux_url }}" -o linux.tar.gz && break
echo "Retry $i failed, waiting..."
sleep 5
done
curl -sL "${{ steps.release.outputs.macos_url }}" -o macos.tar.gz
curl -sL "${{ steps.release.outputs.windows_url }}" -o windows.zip
curl -sL "${{ steps.release.outputs.source_url }}" -o source.tar.gz
LINUX_SHA=$(sha256sum linux.tar.gz | cut -d' ' -f1)
MACOS_SHA=$(sha256sum macos.tar.gz | cut -d' ' -f1)
WINDOWS_SHA=$(sha256sum windows.zip | cut -d' ' -f1)
SOURCE_SHA=$(sha256sum source.tar.gz | cut -d' ' -f1)
echo "linux_sha=$LINUX_SHA" >> $GITHUB_OUTPUT
echo "macos_sha=$MACOS_SHA" >> $GITHUB_OUTPUT
echo "windows_sha=$WINDOWS_SHA" >> $GITHUB_OUTPUT
echo "source_sha=$SOURCE_SHA" >> $GITHUB_OUTPUT
echo "๐ Checksums calculated:"
echo "Linux: $LINUX_SHA"
echo "macOS: $MACOS_SHA"
echo "Windows: $WINDOWS_SHA"
echo "Source: $SOURCE_SHA"
# Update Homebrew tap
update-homebrew:
name: Update Homebrew Formula
runs-on: ubuntu-latest
needs: update-package-managers
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v5
with:
token: ${{ secrets.HOMEBREW_TAP_TOKEN || secrets.GITHUB_TOKEN }}
repository: Fguedes90/homebrew-tap
path: homebrew-tap
- uses: actions/checkout@v5
with:
path: main-repo
- name: Update Homebrew formula
run: |
VERSION=${GITHUB_REF#refs/tags/v}
SOURCE_URL="https://github.com/Fguedes90/lazycelery/archive/v$VERSION.tar.gz"
# Calculate source checksum
curl -sL "$SOURCE_URL" | sha256sum | cut -d' ' -f1 > checksum.txt
SOURCE_SHA=$(cat checksum.txt)
# Update formula
cd homebrew-tap
cp ../main-repo/packaging/homebrew/lazycelery.rb Formula/lazycelery.rb
# Replace placeholders
sed -i "s/PLACEHOLDER_SHA256/$SOURCE_SHA/g" Formula/lazycelery.rb
sed -i "s/v0.2.0/v$VERSION/g" Formula/lazycelery.rb
sed -i "s/0.2.0/$VERSION/g" Formula/lazycelery.rb
# Commit and push
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/lazycelery.rb
git commit -m "Update lazycelery to v$VERSION"
git push
echo "โ
Homebrew formula updated to v$VERSION"
# Update AUR packages
update-aur:
name: Update AUR Packages
runs-on: ubuntu-latest
needs: update-package-managers
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v5
with:
path: main-repo
- name: Setup SSH for AUR
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
run: |
if [ -n "$AUR_SSH_KEY" ]; then
mkdir -p ~/.ssh
echo "$AUR_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
echo "โ
AUR SSH configured"
echo "AUR_SSH_CONFIGURED=true" >> $GITHUB_ENV
else
echo "โ ๏ธ AUR_SSH_KEY not configured, skipping AUR updates"
echo "AUR_SSH_CONFIGURED=false" >> $GITHUB_ENV
fi
- name: Update AUR source package
if: env.AUR_SSH_CONFIGURED == 'true'
run: |
VERSION=${GITHUB_REF#refs/tags/v}
SOURCE_URL="https://github.com/Fguedes90/lazycelery/archive/v$VERSION.tar.gz"
# Calculate checksum
curl -sL "$SOURCE_URL" | sha256sum | cut -d' ' -f1 > checksum.txt
SOURCE_SHA=$(cat checksum.txt)
# Clone AUR repo
git clone ssh://aur@aur.archlinux.org/lazycelery.git aur-lazycelery
cd aur-lazycelery
# Update PKGBUILD
cp ../main-repo/packaging/aur/PKGBUILD .
sed -i "s/PLACEHOLDER_SHA256/$SOURCE_SHA/g" PKGBUILD
sed -i "s/pkgver=0.2.0/pkgver=$VERSION/g" PKGBUILD
sed -i "s/pkgrel=1/pkgrel=1/g" PKGBUILD
# Update .SRCINFO
makepkg --printsrcinfo > .SRCINFO
# Commit and push
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to v$VERSION"
git push
echo "โ
AUR source package updated to v$VERSION"
- name: Update AUR binary package
if: env.AUR_SSH_CONFIGURED == 'true'
run: |
VERSION=${GITHUB_REF#refs/tags/v}
LINUX_URL="https://github.com/Fguedes90/lazycelery/releases/download/v$VERSION/lazycelery-linux-x86_64.tar.gz"
# Calculate checksum
curl -sL "$LINUX_URL" | sha256sum | cut -d' ' -f1 > checksum.txt
LINUX_SHA=$(cat checksum.txt)
# Clone AUR repo
git clone ssh://aur@aur.archlinux.org/lazycelery-bin.git aur-lazycelery-bin
cd aur-lazycelery-bin
# Update PKGBUILD
cp ../main-repo/packaging/aur/PKGBUILD-bin PKGBUILD
sed -i "s/PLACEHOLDER_SHA256/$LINUX_SHA/g" PKGBUILD
sed -i "s/pkgver=0.2.0/pkgver=$VERSION/g" PKGBUILD
sed -i "s/pkgrel=1/pkgrel=1/g" PKGBUILD
# Update .SRCINFO
makepkg --printsrcinfo > .SRCINFO
# Commit and push
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to v$VERSION"
git push
echo "โ
AUR binary package updated to v$VERSION"
# Update Scoop bucket
update-scoop:
name: Update Scoop Manifest
runs-on: ubuntu-latest
needs: update-package-managers
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v5
with:
token: ${{ secrets.SCOOP_BUCKET_TOKEN || secrets.GITHUB_TOKEN }}
repository: Fguedes90/scoop-bucket
path: scoop-bucket
- uses: actions/checkout@v5
with:
path: main-repo
- name: Update Scoop manifest
run: |
VERSION=${GITHUB_REF#refs/tags/v}
WINDOWS_URL="https://github.com/Fguedes90/lazycelery/releases/download/v$VERSION/lazycelery-windows-x86_64.zip"
# Calculate checksum
curl -sL "$WINDOWS_URL" | sha256sum | cut -d' ' -f1 > checksum.txt
WINDOWS_SHA=$(cat checksum.txt)
# Update manifest
cd scoop-bucket
cp ../main-repo/packaging/scoop/lazycelery.json bucket/lazycelery.json
# Replace placeholders
sed -i "s/PLACEHOLDER_SHA256/$WINDOWS_SHA/g" bucket/lazycelery.json
sed -i "s/0.2.0/$VERSION/g" bucket/lazycelery.json
# Commit and push
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add bucket/lazycelery.json
git commit -m "Update lazycelery to v$VERSION"
git push
echo "โ
Scoop manifest updated to v$VERSION"
# Update Snap package
update-snap:
name: Update Snap Package
runs-on: ubuntu-latest
needs: update-package-managers
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v5
- name: Setup Snapcraft
run: |
sudo snap install snapcraft --classic
# Configure Snapcraft credentials if available
if [ -n "${{ secrets.SNAP_STORE_LOGIN }}" ]; then
echo "${{ secrets.SNAP_STORE_LOGIN }}" | base64 -d > snapcraft-login
snapcraft login --with snapcraft-login
echo "โ
Snapcraft authenticated"
else
echo "โ ๏ธ SNAP_STORE_LOGIN not configured, skipping Snap store upload"
fi
- name: Update and build Snap package
run: |
VERSION=${GITHUB_REF#refs/tags/v}
# Update snapcraft.yaml
cp packaging/snap/snapcraft.yaml .
sed -i "s/version: '0.2.0'/version: '$VERSION'/g" snapcraft.yaml
sed -i "s/source-tag: v0.2.0/source-tag: v$VERSION/g" snapcraft.yaml
# Build snap
snapcraft
# Upload if credentials available
if [ -f snapcraft-login ]; then
snapcraft upload --release=stable lazycelery_${VERSION}_*.snap
echo "โ
Snap uploaded to store"
else
echo "๐ฆ Snap built but not uploaded (no credentials)"
fi
</file>
<file path="Cargo.toml">
[package]
name = "lazycelery"
version = "0.7.2"
edition = "2021"
authors = ["Francisco Guedes <francis@example.com>"]
description = "A terminal UI for monitoring and managing Celery workers and tasks, inspired by lazydocker/lazygit"
license = "MIT"
repository = "https://github.com/Fguedes90/lazycelery"
homepage = "https://github.com/Fguedes90/lazycelery"
documentation = "https://docs.rs/lazycelery"
readme = "README.md"
keywords = ["celery", "tui", "terminal", "monitoring", "redis"]
categories = ["command-line-utilities", "development-tools", "debugging"]
rust-version = "1.88.0"
exclude = [
".github/",
"tests/",
"benches/",
"docs/",
".*",
"*.md",
"screenshots/",
"MVP_DEVELOPMENT_PLAN.md",
]
[[bin]]
name = "lazycelery"
path = "src/main.rs"
[dependencies]
# TUI
ratatui = "0.29"
crossterm = "0.27"
# Async runtime
tokio = { version = "1.47", features = ["full"] }
async-trait = "0.1"
# Broker clients
redis = { version = "0.24", features = ["tokio-comp"] }
lapin = "2.5"
futures-lite = "2.0"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.9"
base64 = "0.22"
# System directories
dirs = "6.0"
# Time handling
chrono = { version = "0.4", features = ["serde"] }
# CLI
clap = { version = "4.4", features = ["derive"] }
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Logging
tracing = "0.1"
[dev-dependencies]
tempfile = "3.8"
</file>
</files>