mod logging;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{
generate,
shells::{Bash, Elvish, Fish, PowerShell, Zsh},
};
use serde_json::{json, Value};
use cardinal_core::{
parse_command, AgendaRange, CalendarView, CardinalCommand, EventCommand, InviteCommand,
ListTarget, MarkState, ParseError, Selector, SyncTarget,
};
use cardinal_tui::{run_tui, validate_runtime_config};
use crate::logging::StructuredLogger;
#[derive(Debug, Parser)]
#[command(name = "cardinal")]
#[command(about = "Command-first terminal mail/calendar workspace")]
struct Cli {
#[command(subcommand)]
command: Option<CliCommand>,
}
#[derive(Debug, Subcommand)]
enum CliCommand {
Tui,
Parse {
input: String,
#[arg(long, value_enum, default_value_t = ParseOutputFormat::Text)]
format: ParseOutputFormat,
#[arg(long, default_value_t = false)]
pretty: bool,
},
Doctor,
#[command(name = "commands")]
CommandList,
Config {
#[command(subcommand)]
command: ConfigSubcommand,
},
Completions { shell: CompletionShell },
Man,
}
#[derive(Debug, Subcommand)]
enum ConfigSubcommand {
Validate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CompletionShell {
Bash,
Elvish,
Fish,
PowerShell,
Zsh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ParseOutputFormat {
Text,
Json,
}
fn main() {
let cli = Cli::parse();
let mut logger = match StructuredLogger::from_env() {
Ok(logger) => logger,
Err(error) => {
eprintln!("error: failed to initialize structured logger: {error}");
std::process::exit(1);
}
};
logger.event("info", "startup", &[("command", describe_command(&cli))]);
let exit_code = run(cli, &mut logger);
logger.event("info", "shutdown", &[("exit_code", exit_code.to_string())]);
if exit_code != 0 {
std::process::exit(exit_code);
}
}
fn run(cli: Cli, logger: &mut StructuredLogger) -> i32 {
match cli.command {
Some(CliCommand::Tui) => run_tui_with_logging(logger),
Some(CliCommand::Parse {
input,
format,
pretty,
}) => {
logger.event("info", "parse_command", &[("input", input.clone())]);
match parse_command(&input) {
Ok(command) => {
match format {
ParseOutputFormat::Text => println!("{command:#?}"),
ParseOutputFormat::Json => {
print_json(&parse_success_json(&input, &command), pretty);
}
}
0
}
Err(error) => {
match format {
ParseOutputFormat::Text => eprintln!("error: {error}"),
ParseOutputFormat::Json => {
print_json(&parse_error_json(&input, &error), pretty);
}
}
logger.event(
"error",
"parse_command_failed",
&[("error", error.to_string())],
);
2
}
}
}
Some(CliCommand::Doctor) => {
let report = validate_runtime_config();
println!("cardinal: pre-alpha scaffold");
println!("core: command parser available");
println!("tui: milestone 1-10 scaffold available");
println!(
"config: {}",
if report.has_errors() {
"validation errors present"
} else {
"validation clean or warnings only"
}
);
println!("sync: external command integration available");
println!("packaging: metadata and generation commands present");
0
}
Some(CliCommand::CommandList) => {
for command in COMMAND_EXAMPLES {
println!("{command}");
}
0
}
Some(CliCommand::Config {
command: ConfigSubcommand::Validate,
}) => {
let report = validate_runtime_config();
println!("{}", report.summary());
for issue in &report.issues {
println!("- {:?}: {}", issue.severity, issue.message);
}
if report.has_errors() {
1
} else {
0
}
}
Some(CliCommand::Completions { shell }) => {
logger.event(
"info",
"generate_completions",
&[("shell", format!("{shell:?}"))],
);
print_completions(shell);
0
}
Some(CliCommand::Man) => match print_man_page() {
Ok(()) => 0,
Err(error) => {
eprintln!("error: failed to render man page: {error}");
logger.event(
"error",
"render_man_page_failed",
&[("error", error.to_string())],
);
1
}
},
None => run_tui_with_logging(logger),
}
}
fn run_tui_with_logging(logger: &mut StructuredLogger) -> i32 {
logger.event("info", "tui_start", &[]);
match run_tui() {
Ok(()) => 0,
Err(error) => {
eprintln!("error: failed to launch TUI: {error}");
logger.event("error", "tui_failed", &[("error", error.to_string())]);
1
}
}
}
fn print_completions(shell: CompletionShell) {
let mut command = Cli::command();
let binary_name = command.get_name().to_owned();
match shell {
CompletionShell::Bash => generate(Bash, &mut command, &binary_name, &mut std::io::stdout()),
CompletionShell::Elvish => {
generate(Elvish, &mut command, &binary_name, &mut std::io::stdout())
}
CompletionShell::Fish => generate(Fish, &mut command, &binary_name, &mut std::io::stdout()),
CompletionShell::PowerShell => generate(
PowerShell,
&mut command,
&binary_name,
&mut std::io::stdout(),
),
CompletionShell::Zsh => generate(Zsh, &mut command, &binary_name, &mut std::io::stdout()),
}
}
fn print_man_page() -> Result<(), std::io::Error> {
let command = Cli::command();
let man = clap_mangen::Man::new(command);
man.render(&mut std::io::stdout())
}
fn describe_command(cli: &Cli) -> String {
match cli.command {
Some(CliCommand::Tui) => "tui".to_owned(),
Some(CliCommand::Parse { .. }) => "parse".to_owned(),
Some(CliCommand::Doctor) => "doctor".to_owned(),
Some(CliCommand::CommandList) => "commands".to_owned(),
Some(CliCommand::Config { .. }) => "config".to_owned(),
Some(CliCommand::Completions { .. }) => "completions".to_owned(),
Some(CliCommand::Man) => "man".to_owned(),
None => "default-tui".to_owned(),
}
}
fn print_json(value: &Value, pretty: bool) {
let rendered = if pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
};
match rendered {
Ok(rendered) => println!("{rendered}"),
Err(error) => eprintln!("error: failed to render JSON output: {error}"),
}
}
fn parse_success_json(input: &str, command: &CardinalCommand) -> Value {
json!({
"schema_version": 1,
"ok": true,
"input": input,
"command": command_json(command),
"isa": {
"intent": command_intent(command),
"domain": command_domain(command),
"target": command_target(command),
"effect": command_effect(command),
"safety": command_safety(command),
}
})
}
fn parse_error_json(input: &str, error: &ParseError) -> Value {
let details = match error {
ParseError::Empty => json!({}),
ParseError::UnknownCommand(command) => json!({ "command": command }),
ParseError::MissingArgument(argument) => json!({ "argument": argument }),
ParseError::InvalidArgument { command, argument } => {
json!({ "command": command, "argument": argument })
}
ParseError::UnexpectedArgument { command, argument } => {
json!({ "command": command, "argument": argument })
}
ParseError::UnterminatedQuote => json!({}),
};
json!({
"schema_version": 1,
"ok": false,
"input": input,
"error": {
"kind": parse_error_kind(error),
"message": error.to_string(),
"details": details
}
})
}
fn command_json(command: &CardinalCommand) -> Value {
match command {
CardinalCommand::List(target) => {
json!({ "kind": "list", "target": list_target_str(target) })
}
CardinalCommand::Open(selector) => {
json!({ "kind": "open", "selector": selector_json(selector) })
}
CardinalCommand::Compose => json!({ "kind": "compose" }),
CardinalCommand::Reply { all } => json!({ "kind": "reply", "all": all }),
CardinalCommand::Forward { recipient } => {
json!({ "kind": "forward", "recipient": recipient })
}
CardinalCommand::Archive => json!({ "kind": "archive" }),
CardinalCommand::Delete => json!({ "kind": "delete" }),
CardinalCommand::Spam => json!({ "kind": "spam" }),
CardinalCommand::Mark(state) => {
json!({ "kind": "mark", "state": mark_state_str(state) })
}
CardinalCommand::Move { target } => json!({ "kind": "move", "target": target }),
CardinalCommand::Send { confirm } => json!({ "kind": "send", "confirm": confirm }),
CardinalCommand::Search { query } => json!({ "kind": "search", "query": query }),
CardinalCommand::Calendar(view) => {
json!({ "kind": "calendar", "view": calendar_view_str(view) })
}
CardinalCommand::Agenda(range) => {
json!({ "kind": "agenda", "range": agenda_range_str(range) })
}
CardinalCommand::Event(event) => match event {
EventCommand::New => json!({ "kind": "event", "action": "new" }),
EventCommand::Open(selector) => {
json!({ "kind": "event", "action": "open", "selector": selector_json(selector) })
}
EventCommand::Edit(selector) => {
json!({ "kind": "event", "action": "edit", "selector": selector_json(selector) })
}
EventCommand::Delete(selector) => {
json!({ "kind": "event", "action": "delete", "selector": selector_json(selector) })
}
EventCommand::Duplicate(selector) => {
json!({ "kind": "event", "action": "duplicate", "selector": selector_json(selector) })
}
EventCommand::Move { calendar } => {
json!({ "kind": "event", "action": "move", "calendar": calendar })
}
},
CardinalCommand::Invite(invite) => {
json!({ "kind": "invite", "action": invite_action_str(invite) })
}
CardinalCommand::Sync(target) => {
json!({ "kind": "sync", "target": sync_target_str(target) })
}
CardinalCommand::Undo => json!({ "kind": "undo" }),
CardinalCommand::Help => json!({ "kind": "help" }),
CardinalCommand::Bindings => json!({ "kind": "bindings" }),
CardinalCommand::Config => json!({ "kind": "config" }),
CardinalCommand::Reload => json!({ "kind": "reload" }),
CardinalCommand::Quit => json!({ "kind": "quit" }),
}
}
fn selector_json(selector: &Selector) -> Value {
match selector {
Selector::Index(index) => json!({ "kind": "index", "index": index }),
Selector::Name(name) => json!({ "kind": "name", "name": name }),
Selector::Current => json!({ "kind": "current" }),
}
}
fn parse_error_kind(error: &ParseError) -> &'static str {
match error {
ParseError::Empty => "empty",
ParseError::UnknownCommand(_) => "unknown_command",
ParseError::MissingArgument(_) => "missing_argument",
ParseError::InvalidArgument { .. } => "invalid_argument",
ParseError::UnexpectedArgument { .. } => "unexpected_argument",
ParseError::UnterminatedQuote => "unterminated_quote",
}
}
fn command_intent(command: &CardinalCommand) -> &'static str {
match command {
CardinalCommand::List(_) => "list",
CardinalCommand::Open(_) => "open",
CardinalCommand::Compose => "compose",
CardinalCommand::Reply { .. } => "reply",
CardinalCommand::Forward { .. } => "forward",
CardinalCommand::Archive => "archive",
CardinalCommand::Delete => "delete",
CardinalCommand::Spam => "spam",
CardinalCommand::Mark(_) => "mark",
CardinalCommand::Move { .. } => "move",
CardinalCommand::Send { .. } => "send",
CardinalCommand::Search { .. } => "search",
CardinalCommand::Calendar(_) => "calendar",
CardinalCommand::Agenda(_) => "agenda",
CardinalCommand::Event(_) => "event",
CardinalCommand::Invite(_) => "invite",
CardinalCommand::Sync(_) => "sync",
CardinalCommand::Undo => "undo",
CardinalCommand::Help => "help",
CardinalCommand::Bindings => "bindings",
CardinalCommand::Config => "config",
CardinalCommand::Reload => "reload",
CardinalCommand::Quit => "quit",
}
}
fn command_domain(command: &CardinalCommand) -> &'static str {
match command {
CardinalCommand::List(target) => match target {
ListTarget::Calendars => "calendar",
ListTarget::Invites => "invite",
ListTarget::Inboxes
| ListTarget::Folders
| ListTarget::Mail
| ListTarget::Unread
| ListTarget::Flagged => "mail",
},
CardinalCommand::Compose
| CardinalCommand::Reply { .. }
| CardinalCommand::Forward { .. }
| CardinalCommand::Archive
| CardinalCommand::Delete
| CardinalCommand::Spam
| CardinalCommand::Mark(_)
| CardinalCommand::Move { .. }
| CardinalCommand::Send { .. }
| CardinalCommand::Undo => "mail",
CardinalCommand::Calendar(_) | CardinalCommand::Agenda(_) | CardinalCommand::Event(_) => {
"calendar"
}
CardinalCommand::Invite(_) => "invite",
CardinalCommand::Open(_)
| CardinalCommand::Search { .. }
| CardinalCommand::Sync(_)
| CardinalCommand::Help
| CardinalCommand::Bindings
| CardinalCommand::Config
| CardinalCommand::Reload
| CardinalCommand::Quit => "app",
}
}
fn command_target(command: &CardinalCommand) -> &'static str {
match command {
CardinalCommand::List(_) => "name",
CardinalCommand::Open(selector) => selector_target(selector),
CardinalCommand::Reply { .. }
| CardinalCommand::Archive
| CardinalCommand::Delete
| CardinalCommand::Spam
| CardinalCommand::Mark(_)
| CardinalCommand::Send { .. }
| CardinalCommand::Undo
| CardinalCommand::Invite(_) => "selected",
CardinalCommand::Forward { .. } | CardinalCommand::Move { .. } => "name",
CardinalCommand::Search { .. } => "query",
CardinalCommand::Calendar(_) | CardinalCommand::Agenda(_) => "range",
CardinalCommand::Sync(_) => "name",
CardinalCommand::Event(event) => match event {
EventCommand::Open(selector)
| EventCommand::Edit(selector)
| EventCommand::Delete(selector)
| EventCommand::Duplicate(selector) => selector_target(selector),
EventCommand::Move { .. } => "name",
EventCommand::New => "none",
},
CardinalCommand::Compose
| CardinalCommand::Help
| CardinalCommand::Bindings
| CardinalCommand::Config
| CardinalCommand::Reload
| CardinalCommand::Quit => "none",
}
}
fn selector_target(selector: &Selector) -> &'static str {
match selector {
Selector::Index(_) => "index",
Selector::Name(_) => "name",
Selector::Current => "selected",
}
}
fn command_effect(command: &CardinalCommand) -> &'static str {
match command {
CardinalCommand::Archive
| CardinalCommand::Delete
| CardinalCommand::Spam
| CardinalCommand::Mark(_)
| CardinalCommand::Move { .. }
| CardinalCommand::Undo
| CardinalCommand::Event(_) => "local-fs",
CardinalCommand::Invite(InviteCommand::Accept | InviteCommand::Tentative) => "local-fs",
CardinalCommand::Sync(_) => "external-command",
CardinalCommand::Send { confirm: true } => "smtp",
CardinalCommand::List(_)
| CardinalCommand::Open(_)
| CardinalCommand::Compose
| CardinalCommand::Reply { .. }
| CardinalCommand::Forward { .. }
| CardinalCommand::Send { confirm: false }
| CardinalCommand::Search { .. }
| CardinalCommand::Calendar(_)
| CardinalCommand::Agenda(_)
| CardinalCommand::Invite(InviteCommand::Decline)
| CardinalCommand::Help
| CardinalCommand::Bindings
| CardinalCommand::Config
| CardinalCommand::Reload
| CardinalCommand::Quit => "none",
}
}
fn command_safety(command: &CardinalCommand) -> &'static str {
match command {
CardinalCommand::Delete | CardinalCommand::Spam => "destructive",
CardinalCommand::Event(EventCommand::Delete(_)) => "destructive",
CardinalCommand::Send { confirm: true } | CardinalCommand::Sync(_) => "network",
CardinalCommand::Compose
| CardinalCommand::Reply { .. }
| CardinalCommand::Forward { .. }
| CardinalCommand::Archive
| CardinalCommand::Mark(_)
| CardinalCommand::Move { .. }
| CardinalCommand::Send { confirm: false }
| CardinalCommand::Event(_)
| CardinalCommand::Invite(InviteCommand::Accept | InviteCommand::Tentative)
| CardinalCommand::Reload
| CardinalCommand::Undo => "reversible",
CardinalCommand::List(_)
| CardinalCommand::Open(_)
| CardinalCommand::Search { .. }
| CardinalCommand::Calendar(_)
| CardinalCommand::Agenda(_)
| CardinalCommand::Invite(InviteCommand::Decline)
| CardinalCommand::Help
| CardinalCommand::Bindings
| CardinalCommand::Config
| CardinalCommand::Quit => "read-only",
}
}
fn list_target_str(target: &ListTarget) -> &'static str {
match target {
ListTarget::Inboxes => "inboxes",
ListTarget::Folders => "folders",
ListTarget::Mail => "mail",
ListTarget::Unread => "unread",
ListTarget::Flagged => "flagged",
ListTarget::Calendars => "calendars",
ListTarget::Invites => "invites",
}
}
fn mark_state_str(state: &MarkState) -> &'static str {
match state {
MarkState::Read => "read",
MarkState::Unread => "unread",
}
}
fn calendar_view_str(view: &CalendarView) -> &'static str {
match view {
CalendarView::Today => "today",
CalendarView::Tomorrow => "tomorrow",
CalendarView::Week => "week",
CalendarView::Month => "month",
}
}
fn agenda_range_str(range: &AgendaRange) -> &'static str {
match range {
AgendaRange::Default => "default",
AgendaRange::Today => "today",
AgendaRange::Tomorrow => "tomorrow",
AgendaRange::Week => "week",
}
}
fn invite_action_str(invite: &InviteCommand) -> &'static str {
match invite {
InviteCommand::Accept => "accept",
InviteCommand::Tentative => "tentative",
InviteCommand::Decline => "decline",
}
}
fn sync_target_str(target: &SyncTarget) -> &'static str {
match target {
SyncTarget::All => "all",
SyncTarget::Mail => "mail",
SyncTarget::Calendar => "calendar",
SyncTarget::Contacts => "contacts",
}
}
const COMMAND_EXAMPLES: &[&str] = &[
":list inboxes",
":list mail",
":open personal",
":open 4",
":reply",
":reply all",
":send",
":send confirm",
":archive",
":delete",
":spam",
":undo",
":calendar today",
":agenda week",
":event new",
":event edit",
":event delete",
":invite accept",
":sync",
":search calendar:work interview",
];