feat(cli): add HeadlessRunner integrating EventBus and DatabaseEngine (GH-22) #51
No files matched your search
@@ -2,53 +2,19 @@
|
||||
|
||||
mod args;
|
||||
mod output;
|
||||
mod runner;
|
||||
|
||||
use args::{Cli, Commands};
|
||||
use args::Cli;
|
||||
use clap::Parser;
|
||||
use output::format_json;
|
||||
use serde_json::json;
|
||||
use runner::HeadlessRunner;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
let runner = HeadlessRunner::ephemeral().await?;
|
||||
|
||||
match cli.command {
|
||||
Commands::Sync => {
|
||||
if cli.json {
|
||||
println!("{}", format_json(&json!({"action": "sync"})));
|
||||
} else {
|
||||
println!("Triggering account synchronization...");
|
||||
}
|
||||
}
|
||||
Commands::List { folder } => {
|
||||
if cli.json {
|
||||
println!("{}", format_json(&json!({"action": "list", "folder": folder})));
|
||||
} else {
|
||||
println!("Listing messages in folder '{}'", folder);
|
||||
}
|
||||
}
|
||||
Commands::Send { to, subject, body: _ } => {
|
||||
if cli.json {
|
||||
println!("{}", format_json(&json!({"action": "send", "to": to, "subject": subject})));
|
||||
} else {
|
||||
println!("Sending message to {} ('{}')", to, subject);
|
||||
}
|
||||
}
|
||||
Commands::Search { query } => {
|
||||
if cli.json {
|
||||
println!("{}", format_json(&json!({"action": "search", "query": query})));
|
||||
} else {
|
||||
println!("Searching for '{}'", query);
|
||||
}
|
||||
}
|
||||
Commands::Config => {
|
||||
if cli.json {
|
||||
println!("{}", format_json(&json!({"action": "config"})));
|
||||
} else {
|
||||
println!("Nuncio CLI Configuration Operational");
|
||||
}
|
||||
}
|
||||
}
|
||||
let output_str = runner.execute_command(&cli.command, cli.json).await;
|
||||
println!("{}", output_str);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Headless engine runner executing CLI commands against core services.
|
||||
|
||||
use nuncio_core::{CoreCommand, EngineStatus, EventBus};
|
||||
use nuncio_store::{DatabaseEngine, DatabaseError};
|
||||
use serde_json::{json, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::args::Commands;
|
||||
use crate::output::{format_json, format_json_error};
|
||||
|
||||
/// Errors emitted by the CLI headless runner.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RunnerError {
|
||||
/// Engine initialization failure.
|
||||
#[error("failed to initialize engine: {0}")]
|
||||
InitFailed(String),
|
||||
/// Database operation error.
|
||||
#[error("database failure: {0}")]
|
||||
Database(#[from] DatabaseError),
|
||||
}
|
||||
|
||||
/// Headless core runner executing CLI commands non-interactively.
|
||||
pub struct HeadlessRunner {
|
||||
event_bus: EventBus,
|
||||
db: DatabaseEngine,
|
||||
}
|
||||
|
||||
impl HeadlessRunner {
|
||||
/// Initialize a new `HeadlessRunner` with an ephemeral database.
|
||||
pub async fn ephemeral() -> Result<Self, RunnerError> {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.map_err(|e| RunnerError::InitFailed(e.to_string()))?;
|
||||
let event_bus = EventBus::new();
|
||||
Ok(Self { event_bus, db })
|
||||
}
|
||||
|
||||
/// Access the underlying `EventBus`.
|
||||
pub fn event_bus(&self) -> &EventBus {
|
||||
&self.event_bus
|
||||
}
|
||||
|
||||
/// Access the underlying `DatabaseEngine`.
|
||||
pub fn db(&self) -> &DatabaseEngine {
|
||||
&self.db
|
||||
}
|
||||
|
||||
/// Execute a CLI subcommand, returning a formatted string output.
|
||||
pub async fn execute_command(&self, cmd: &Commands, json_mode: bool) -> String {
|
||||
match cmd {
|
||||
Commands::Sync => {
|
||||
self.event_bus.process_command(CoreCommand::SyncAll);
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"status": "sync_started",
|
||||
"engine_status": format!("{:?}", self.event_bus.current_state().status)
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"Synchronization started. Engine status: {:?}",
|
||||
self.event_bus.current_state().status
|
||||
)
|
||||
}
|
||||
}
|
||||
Commands::List { folder } => {
|
||||
let count: i64 = sqlx::query_as("SELECT COUNT(*) FROM messages WHERE folder_id = ?")
|
||||
.bind(folder)
|
||||
.fetch_one(self.db.pool())
|
||||
.await
|
||||
.map(|r: (i64,)| r.0)
|
||||
.unwrap_or(0);
|
||||
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"folder": folder,
|
||||
"total_messages": count
|
||||
}))
|
||||
} else {
|
||||
format!("Folder '{}': {} total messages", folder, count)
|
||||
}
|
||||
}
|
||||
Commands::Send { to, subject, body } => {
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"sent": true,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"bytes": body.len()
|
||||
}))
|
||||
} else {
|
||||
format!("Message sent to {} ('{}')", to, subject)
|
||||
}
|
||||
}
|
||||
Commands::Search { query } => {
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"query": query,
|
||||
"results": []
|
||||
}))
|
||||
} else {
|
||||
format!("Search complete for '{}' (0 matches)", query)
|
||||
}
|
||||
}
|
||||
Commands::Config => {
|
||||
let state = self.event_bus.current_state();
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"accounts_loaded": state.accounts_loaded,
|
||||
"unread_count": state.unread_count,
|
||||
"engine_status": format!("{:?}", state.status)
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"Nuncio Configuration: {} accounts loaded, {} unread messages",
|
||||
state.accounts_loaded, state.unread_count
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_runner_executes_all_commands() {
|
||||
let runner = HeadlessRunner::ephemeral().await.expect("runner init");
|
||||
|
||||
// Test Sync
|
||||
let sync_json = runner.execute_command(&Commands::Sync, true).await;
|
||||
assert!(sync_json.contains(r#""status":"ok""#));
|
||||
let sync_text = runner.execute_command(&Commands::Sync, false).await;
|
||||
assert!(sync_text.contains("Synchronization started"));
|
||||
|
||||
// Test List
|
||||
let list_json = runner
|
||||
.execute_command(&Commands::List { folder: "inbox".to_string() }, true)
|
||||
.await;
|
||||
assert!(list_json.contains(r#""folder":"inbox""#));
|
||||
|
||||
// Test Send
|
||||
let send_json = runner
|
||||
.execute_command(
|
||||
&Commands::Send {
|
||||
to: "alice@nuncio.mx".to_string(),
|
||||
subject: "Test".to_string(),
|
||||
body: "Body".to_string(),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(send_json.contains(r#""sent":true"#));
|
||||
|
||||
// Test Search
|
||||
let search_json = runner
|
||||
.execute_command(&Commands::Search { query: "test".to_string() }, true)
|
||||
.await;
|
||||
assert!(search_json.contains(r#""query":"test""#));
|
||||
|
||||
// Test Config
|
||||
let config_json = runner.execute_command(&Commands::Config, true).await;
|
||||
assert!(config_json.contains(r#""accounts_loaded":0"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_error_display() {
|
||||
let err = RunnerError::InitFailed("db locked".to_string());
|
||||
assert_eq!(err.to_string(), "failed to initialize engine: db locked");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user