feat(cli): add HeadlessRunner integrating EventBus and DatabaseEngine (GH-22) #51

Merged
KofTwentyTwo merged 1 commits from feature/GH-22-headless-core-runner into main 2026-07-23 00:51:13 +00:00
2 changed files with 178 additions and 40 deletions

No files matched your search

+6 -40
View File
@@ -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(())
}
+172
View File
@@ -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");
}
}