use clap::{Parser, Subcommand};
use colored::Colorize;
use ralph::checkpoint::Checkpoint;
use ralph::config::Config;
use ralph::errors::RalphError;
use ralph::loop_runner::LoopRunner;
use ralph::memory::MemoryStore;
use ralph::output::{ConfirmResult, OutputConfig, OutputFormat, Phase, Printer};
use ralph::providers::{
deepseek::{DeepSeekProvider, ThinkingMode, DEFAULT_MODEL, PRO_MODEL},
LlmProvider, Message,
};
use ralph::session::Session;
use rustyline::DefaultEditor;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
#[derive(Parser, Debug)]
#[command(
name = "ralph",
version,
about = "Ralph — an agentic code generation CLI",
long_about = "Ralph is an AI-powered code generation agent that operates via an agentic loop.\nUses DeepSeek as the LLM backend (requires DEEPSEEK_API_KEY)."
)]
struct Cli {
prompt: Option<String>,
#[arg(long)]
pro: bool,
#[arg(long, short = 'm')]
model: Option<String>,
#[arg(long, short = 'w', default_value = ".")]
workspace: PathBuf,
#[arg(long)]
max_turns: Option<u32>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_confirm: bool,
#[arg(long)]
no_setup: bool,
#[arg(long)]
resume: Option<Option<String>>,
#[arg(long)]
new_session: bool,
#[arg(long)]
checkpoint: Option<String>,
#[arg(long)]
auto_checkpoint: bool,
#[arg(long)]
context: Option<PathBuf>,
#[arg(long)]
lsp: Vec<String>,
#[arg(long, default_value = "terminal")]
output: String,
#[arg(long)]
verbose: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
Sessions {
#[command(subcommand)]
action: SessionAction,
},
Checkpoint {
#[command(subcommand)]
action: CheckpointAction,
},
Pr {
#[arg(long, short = 't')]
title: String,
#[arg(long, short = 'b', default_value = "")]
body: String,
#[arg(long)]
draft: bool,
#[arg(long)]
base: Option<String>,
},
Ci {
#[arg(long)]
branch: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum SessionAction {
List,
Show { session_id: String },
Clean {
#[arg(long, default_value = "30")]
older_than: u32,
},
}
#[derive(Subcommand, Debug)]
enum CheckpointAction {
List {
#[arg(long)]
session: Option<String>,
},
Create {
name: String,
#[arg(long)]
session: Option<String>,
},
Revert {
name_or_number: String,
#[arg(long)]
session: Option<String>,
#[arg(long)]
files_only: bool,
},
Show {
name_or_number: String,
#[arg(long)]
session: Option<String>,
},
}
#[tokio::main]
async fn main() {
if let Err(e) = run().await {
eprintln!("{} {}", "[ralph]".red().bold(), e.to_string().red());
std::process::exit(match &e {
RalphError::GuardrailViolation(_) | RalphError::BlockedCommand(_) => 2,
RalphError::MaxTurnsReached(_) => 3,
_ => 1,
});
}
}
async fn run() -> Result<(), RalphError> {
let cli = Cli::parse();
let workspace = cli
.workspace
.canonicalize()
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let mut config = Config::load(&workspace).unwrap_or_else(|e| {
eprintln!("{} Config warning: {}", "[ralph]".yellow(), e);
Config::default()
});
if cli.auto_checkpoint {
config.checkpoints.auto_checkpoint_before_destructive = true;
}
let format = if cli.output == "json" {
OutputFormat::Json
} else {
OutputFormat::Terminal
};
let printer = Arc::new(Printer::new(OutputConfig::new(format, cli.verbose)));
if let Some(cmd) = cli.command {
return handle_subcommand(cmd, &workspace, &config, &printer).await;
}
let key = require_env("DEEPSEEK_API_KEY", "deepseek")?;
let model = cli.model.clone().unwrap_or_else(|| {
if cli.pro {
PRO_MODEL.to_string()
} else {
DEFAULT_MODEL.to_string()
}
});
let normal_provider: Arc<dyn LlmProvider> = Arc::new(DeepSeekProvider::new(
key.clone(),
Some(model.clone()),
ThinkingMode::Off,
));
let reasoning_provider: Arc<dyn LlmProvider> = Arc::new(DeepSeekProvider::new(
key,
Some(model.clone()),
ThinkingMode::On {
budget_tokens: 8000,
},
));
let provider = Arc::clone(&normal_provider);
let provider_name = provider.name().to_string();
printer.print(
Phase::Ralph,
&format!("Using {} / {}", provider_name.cyan(), model.cyan()),
);
let mut session = setup_session(&cli, &workspace, &provider_name, &model, &config, &printer)?;
let printer = Arc::new(Printer::new(OutputConfig {
format: if cli.output == "json" {
OutputFormat::Json
} else {
OutputFormat::Terminal
},
verbose: cli.verbose,
session_id: Some(session.meta.session_id.clone()),
}));
if !cli.lsp.is_empty() {
printer.print(
Phase::Ralph,
&format!("LSP enabled: {}", cli.lsp.join(", ")),
);
}
let diff_preview = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pr_enabled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let memory = Arc::new(Mutex::new(MemoryStore::load(&workspace)));
if session.messages.is_empty() {
let search_on = ralph::tools::search_tools::search_available(
&config.search.brave_api_key_env,
&config.search.serp_api_key_env,
);
let ctx = ralph::context::WorkspaceContext::build(&workspace);
let mem_ctx = memory.lock().unwrap().format_for_prompt();
let test_cmd = config.testing.resolved_cmd(&workspace);
let symbol_summary = {
let idx = ralph::symbol_index::SymbolIndex::build(&workspace);
let count = idx.symbol_count();
if count > 0 {
Some(format!(
"Symbol index: {:?} symbols indexed — use `find_symbol` to navigate by name instead of browsing files.",
count
))
} else {
None
}
};
let system = ralph::prompts::system_prompt(
&workspace.display().to_string(),
search_on,
mem_ctx.as_deref(),
test_cmd.as_deref(),
symbol_summary.as_deref(),
);
let combined = format!("{}\n\n---\n\n{}", system, ctx.to_context_string());
session.messages.push(Message::system(combined));
}
if let Some(ref cp_name) = cli.checkpoint {
Checkpoint::create(&session, cp_name, &config.checkpoints).await?;
}
ctrlc::set_handler(move || {
eprintln!("\n{} Interrupted. Session state saved.", "[ralph]".yellow());
std::process::exit(130);
})
.ok();
match cli.prompt.clone() {
Some(prompt) => {
let runner = LoopRunner::new(
normal_provider,
Some(reasoning_provider),
config.clone(),
workspace.clone(),
Arc::clone(&printer),
cli.max_turns, cli.dry_run,
cli.no_confirm,
cli.no_setup,
Arc::clone(&memory),
cli.lsp.clone(),
Arc::clone(&diff_preview),
Arc::clone(&pr_enabled),
);
let full_prompt = if let Some(ref ctx_path) = cli.context {
let ctx =
std::fs::read_to_string(ctx_path).map_err(|e| RalphError::ToolFailed {
tool: "--context".to_string(),
message: e.to_string(),
})?;
format!("## Additional Context\n\n{}\n\n## Task\n\n{}", ctx, prompt)
} else {
prompt
};
let dummy = Arc::new(AtomicBool::new(false));
runner.run(&full_prompt, &mut session, dummy).await
}
None => {
let runner = LoopRunner::new(
normal_provider,
Some(reasoning_provider),
config.clone(),
workspace.clone(),
Arc::clone(&printer),
None, cli.dry_run,
cli.no_confirm,
cli.no_setup,
Arc::clone(&memory),
cli.lsp.clone(),
Arc::clone(&diff_preview),
Arc::clone(&pr_enabled),
);
interactive_mode(
runner,
session,
cli.context.as_deref(),
diff_preview,
pr_enabled,
Arc::clone(&printer),
config.clone(),
)
.await
}
}
}
async fn interactive_mode(
runner: LoopRunner,
mut session: Session,
context_path: Option<&std::path::Path>,
diff_preview: Arc<std::sync::atomic::AtomicBool>,
pr_enabled: Arc<std::sync::atomic::AtomicBool>,
printer: Arc<Printer>,
config: ralph::config::Config,
) -> Result<(), RalphError> {
println!(
"\n{} Interactive mode {} {} {}\n",
"Ralph".cyan().bold(),
"Ctrl+C to cancel task".dimmed(),
"·".dimmed(),
"/h for help · exit to quit".dimmed(),
);
println!(
" {} diff preview is off — type {} to enable\n",
"tip:".dimmed(),
"/dp on".cyan(),
);
let mut rl = DefaultEditor::new().map_err(|e| RalphError::ToolFailed {
tool: "interactive".to_string(),
message: e.to_string(),
})?;
let context_prefix: Option<String> = if let Some(path) = context_path {
let ctx = std::fs::read_to_string(path).map_err(|e| RalphError::ToolFailed {
tool: "--context".to_string(),
message: e.to_string(),
})?;
Some(format!("## Additional Context\n\n{}\n\n## Task\n\n", ctx))
} else {
None
};
let task_cancelled = Arc::new(AtomicBool::new(false));
let ctrlc_flag = Arc::clone(&task_cancelled);
ctrlc::set_handler(move || {
if ctrlc_flag.load(Ordering::SeqCst) {
eprintln!("\n{} Exiting.", "[ralph]".yellow());
std::process::exit(130);
}
ctrlc_flag.store(true, Ordering::SeqCst);
eprintln!(
"\n{} Cancelling task… (Ctrl+C again to exit Ralph)",
"[ralph]".yellow()
);
})
.ok();
loop {
let readline = rl.readline(&format!("{} ", ">>".cyan().bold()));
match readline {
Ok(line) => {
let input = line.trim().to_string();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
println!("{} Goodbye.", "[ralph]".white().bold());
break;
}
if input == "/clear" {
session
.messages
.retain(|m| matches!(m.role, ralph::providers::Role::System));
session.flush().ok();
println!("{} Context cleared.", "[ralph]".white().bold());
println!();
continue;
}
if input == "/dp on"
|| (input == "/dp" && !diff_preview.load(std::sync::atomic::Ordering::Relaxed))
{
diff_preview.store(true, std::sync::atomic::Ordering::Relaxed);
println!(
"{} Diff preview {} — every write_file will show a diff before proceeding.",
"[ralph]".white().bold(),
"ON".green().bold()
);
println!();
continue;
}
if input == "/dp off"
|| (input == "/dp" && diff_preview.load(std::sync::atomic::Ordering::Relaxed))
{
diff_preview.store(false, std::sync::atomic::Ordering::Relaxed);
println!(
"{} Diff preview {}.",
"[ralph]".white().bold(),
"OFF".dimmed()
);
println!();
continue;
}
if input == "/cp" {
handle_cp_command(&mut session, &printer, &config);
continue;
}
if input == "/pr on"
|| (input == "/pr" && !pr_enabled.load(std::sync::atomic::Ordering::Relaxed))
{
pr_enabled.store(true, std::sync::atomic::Ordering::Relaxed);
println!("{} PR/CI tools {} — create_pr and get_ci_status are now available to Ralph.", "[ralph]".white().bold(), "ON".green().bold());
println!();
continue;
}
if input == "/pr off"
|| (input == "/pr" && pr_enabled.load(std::sync::atomic::Ordering::Relaxed))
{
pr_enabled.store(false, std::sync::atomic::Ordering::Relaxed);
println!(
"{} PR/CI tools {}.",
"[ralph]".white().bold(),
"OFF".dimmed()
);
println!();
continue;
}
if input == "/h" || input == "/help" {
print_help();
continue;
}
let _ = rl.add_history_entry(&input);
let prompt = match &context_prefix {
Some(prefix) => format!("{}{}", prefix, input),
None => input,
};
task_cancelled.store(false, Ordering::SeqCst);
match runner
.run(&prompt, &mut session, Arc::clone(&task_cancelled))
.await
{
Ok(()) => {}
Err(RalphError::Interrupted) => {
println!(
"{} Task cancelled. Continuing chat.\n",
"[ralph]".yellow().bold()
);
}
Err(RalphError::MaxTurnsReached(_)) | Err(RalphError::ToolFailed { .. }) => {
}
Err(e) => {
eprintln!("{} {}", "[ralph]".red().bold(), e.to_string().red());
}
}
println!(); }
Err(rustyline::error::ReadlineError::Eof) => {
println!("\n{} Goodbye.", "[ralph]".white().bold());
break;
}
Err(rustyline::error::ReadlineError::Interrupted) => {
println!("\n{} Goodbye.", "[ralph]".white().bold());
break;
}
Err(e) => {
eprintln!("{} Readline error: {}", "[ralph]".red(), e);
break;
}
}
}
Ok(())
}
fn setup_session(
cli: &Cli,
workspace: &PathBuf,
provider_name: &str,
model: &str,
config: &Config,
printer: &Printer,
) -> Result<Session, RalphError> {
if let Some(Some(ref session_id)) = cli.resume {
let session = Session::load(session_id, config)?;
printer.print(
Phase::Session,
&format!(
"Resumed session {} ({} turns)",
session_id, session.meta.turn_count
),
);
return Ok(session);
}
if cli.new_session {
return Session::create(workspace, provider_name, model, config);
}
if config.session.auto_resume {
if let Some(existing) = Session::find_for_workspace(workspace, config) {
let age_min = chrono::Utc::now()
.signed_duration_since(existing.meta.last_active)
.num_minutes();
let age_str = if age_min < 60 {
format!("{} min ago", age_min)
} else {
format!("{:.0} hr ago", age_min as f64 / 60.0)
};
println!(
"{} Found previous session {} ({} turns, last active {}).",
"[ralph]".white().bold(),
existing.meta.session_id.cyan(),
existing.meta.turn_count,
age_str
);
let result = printer.confirm("Resume?", false, false);
if result == ConfirmResult::Yes {
printer.print(
Phase::Session,
&format!("Resumed ({} messages in context)", existing.messages.len()),
);
return Ok(existing);
}
}
}
if let Some(None) = cli.resume {
if let Some(existing) = Session::find_for_workspace(workspace, config) {
printer.print(
Phase::Session,
&format!(
"Resumed session {} ({} turns)",
existing.meta.session_id, existing.meta.turn_count
),
);
return Ok(existing);
}
}
Session::create(workspace, provider_name, model, config)
}
async fn handle_subcommand(
cmd: Commands,
workspace: &PathBuf,
config: &Config,
printer: &Arc<Printer>,
) -> Result<(), RalphError> {
match cmd {
Commands::Sessions { action } => handle_sessions(action, config),
Commands::Checkpoint { action } => {
handle_checkpoint(action, workspace, config, printer).await
}
Commands::Pr {
title,
body,
draft,
base,
} => {
if !ralph::tools::gh_tools::gh_available() {
eprintln!("{} `gh` CLI not found or not authenticated. Install from https://cli.github.com and run `gh auth login`.", "[ralph]".red().bold());
return Err(RalphError::ToolFailed {
tool: "ralph pr".to_string(),
message: "gh CLI unavailable".to_string(),
});
}
match ralph::tools::gh_tools::create_pr(
&title,
&body,
draft,
base.as_deref(),
workspace,
)
.await
{
Ok(url) => {
println!("{} PR created: {}", "[ralph]".white().bold(), url.cyan());
Ok(())
}
Err(e) => Err(e),
}
}
Commands::Ci { branch } => {
if !ralph::tools::gh_tools::gh_available() {
eprintln!(
"{} `gh` CLI not found or not authenticated.",
"[ralph]".red().bold()
);
return Err(RalphError::ToolFailed {
tool: "ralph ci".to_string(),
message: "gh CLI unavailable".to_string(),
});
}
match ralph::tools::gh_tools::get_ci_status(branch.as_deref(), workspace).await {
Ok(output) => {
println!("{}", output);
Ok(())
}
Err(e) => Err(e),
}
}
}
}
fn handle_sessions(action: SessionAction, config: &Config) -> Result<(), RalphError> {
match action {
SessionAction::List => {
let sessions = Session::list_all(config);
if sessions.is_empty() {
println!("No sessions found.");
return Ok(());
}
println!(
"\n{:<24} {:<38} {:<12} {:<6} {}",
"Session ID", "Workspace", "Provider", "Turns", "Status"
);
println!("{}", "-".repeat(95));
for s in sessions {
let ws = truncate(&s.workspace, 36);
println!(
"{:<24} {:<38} {:<12} {:<6} {}",
s.session_id, ws, s.provider, s.turn_count, s.status
);
}
println!();
}
SessionAction::Show { session_id } => {
let session = Session::load(&session_id, config)?;
let m = &session.meta;
println!("\nSession: {}", m.session_id.cyan().bold());
println!(" Workspace: {}", m.workspace);
println!(" Provider: {} / {}", m.provider, m.model);
println!(
" Created: {}",
m.created_at.format("%Y-%m-%d %H:%M:%S UTC")
);
println!(
" Last active: {}",
m.last_active.format("%Y-%m-%d %H:%M:%S UTC")
);
println!(" Turns: {}", m.turn_count);
println!(" Status: {}", m.status);
println!(" Messages: {}", session.messages.len());
let cps = Checkpoint::list(&session);
println!(" Checkpoints: {}", cps.len());
println!();
}
SessionAction::Clean { older_than } => {
Session::clean_old(config, older_than);
println!("Cleaned sessions older than {} days.", older_than);
}
}
Ok(())
}
async fn handle_checkpoint(
action: CheckpointAction,
workspace: &PathBuf,
config: &Config,
printer: &Printer,
) -> Result<(), RalphError> {
match action {
CheckpointAction::List { session } => {
let sess = resolve_session(session, workspace, config)?;
let cps = Checkpoint::list(&sess);
if cps.is_empty() {
println!("No checkpoints found.");
return Ok(());
}
println!(
"\nSession: {} (current)\n",
sess.meta.session_id.cyan().bold()
);
println!(" {:<5} {:<30} {:<6} {}", "#", "Name", "Turn", "Timestamp");
println!(" {}", "-".repeat(60));
for (i, cp) in cps.iter().enumerate() {
println!(
" {:<5} {:<30} {:<6} {}",
i + 1,
cp.name,
cp.turn,
cp.created_at.format("%Y-%m-%d %H:%M:%S")
);
}
println!();
}
CheckpointAction::Create { name, session } => {
let sess = resolve_session(session, workspace, config)?;
Checkpoint::create(&sess, &name, &config.checkpoints).await?;
}
CheckpointAction::Revert {
name_or_number,
session,
files_only,
} => {
let mut sess = resolve_session(session, workspace, config)?;
let cp = Checkpoint::find(&sess, &name_or_number)?;
cp.revert_into(&mut sess, files_only, printer).await?;
}
CheckpointAction::Show {
name_or_number,
session,
} => {
let sess = resolve_session(session, workspace, config)?;
let cp = Checkpoint::find(&sess, &name_or_number)?;
let m = &cp.meta;
println!("\nCheckpoint: {} ({})", m.name.cyan().bold(), m.id);
println!(" Turn: {}", m.turn);
println!(
" Created: {}",
m.created_at.format("%Y-%m-%d %H:%M:%S UTC")
);
println!(" Files: {}", m.modified_files.len());
for f in &m.modified_files {
println!(" - {}", f);
}
println!();
}
}
Ok(())
}
fn resolve_session(
session_id: Option<String>,
workspace: &PathBuf,
config: &Config,
) -> Result<Session, RalphError> {
if let Some(id) = session_id {
Session::load(&id, config)
} else {
Session::find_for_workspace(workspace, config).ok_or_else(|| {
RalphError::SessionNotFound("no active session for this workspace".to_string())
})
}
}
fn print_help() {
println!();
println!(" {} Interactive commands", "Ralph".cyan().bold());
println!();
println!(
" {:<16} {}",
"/cp".cyan(),
"Browse checkpoints and revert interactively"
);
println!(" {:<16} {}", "/dp".cyan(), "Toggle diff preview on/off");
println!(
" {:<16} {}",
"/dp on".cyan(),
"Enable diff preview (show diff before every write)"
);
println!(" {:<16} {}", "/dp off".cyan(), "Disable diff preview");
println!(
" {:<16} {}",
"/pr".cyan(),
"Toggle PR/CI tools on/off (requires gh CLI)"
);
println!(
" {:<16} {}",
"/pr on".cyan(),
"Enable create_pr and get_ci_status tools"
);
println!(" {:<16} {}", "/pr off".cyan(), "Disable PR/CI tools");
println!(
" {:<16} {}",
"/clear".cyan(),
"Clear conversation context (keep system prompt)"
);
println!(" {:<16} {}", "/h, /help".cyan(), "Show this help");
println!(" {:<16} {}", "exit, quit".cyan(), "Exit Ralph");
println!();
}
fn handle_cp_command(session: &mut Session, printer: &Printer, config: &ralph::config::Config) {
use colored::Colorize;
use ralph::checkpoint::Checkpoint;
use ralph::errors::RalphError;
use std::io::Write;
let checkpoints = Checkpoint::list(session);
if checkpoints.is_empty() {
println!(
" {} No checkpoints for this session.\n",
"[ralph]".white().bold()
);
return;
}
println!(
"\n {} Checkpoints — session {}\n",
"[ralph]".white().bold(),
&session.meta.session_id[..8.min(session.meta.session_id.len())]
);
println!(
" {:<4} {:<30} {:<6} {:<6} {}",
"#", "Name", "Turn", "Files", "When"
);
println!(" {}", "─".repeat(58));
for (i, cp) in checkpoints.iter().enumerate() {
let git_tag = if cp.git_sha.is_some() { " *git" } else { "" };
println!(
" {:<4} {:<30} {:<6} {:<6} {}{}",
(i + 1).to_string().cyan(),
cp.name,
cp.turn,
cp.modified_files.len(),
age_string(cp.created_at).dimmed(),
git_tag.dimmed(),
);
}
println!(
" {} entries marked *git were also committed to the repo",
"*".dimmed()
);
println!();
print!(" Enter number to revert, or Enter to cancel: ");
std::io::stdout().flush().ok();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return;
}
let input = input.trim();
if input.is_empty() {
println!();
return;
}
let num: usize = match input.parse::<usize>() {
Ok(n) if n >= 1 && n <= checkpoints.len() => n,
_ => {
println!(" Invalid selection.\n");
return;
}
};
let cp_meta = &checkpoints[num - 1];
println!(
"\n Checkpoint #{} \"{}\" (turn {}, {} file(s))\n",
num,
cp_meta.name,
cp_meta.turn,
cp_meta.modified_files.len()
);
println!(" [f] restore files only — conversation continues as-is");
println!(
" [h] restore files + roll back conversation to turn {}",
cp_meta.turn
);
println!(" [c] cancel");
println!();
print!(" → ");
std::io::stdout().flush().ok();
let mut choice = String::new();
if std::io::stdin().read_line(&mut choice).is_err() {
return;
}
let choice = choice.trim().to_lowercase();
if choice == "c" || choice.is_empty() {
println!(" Cancelled.\n");
return;
}
if choice != "f" && choice != "h" {
println!(" Unknown option '{}'. Cancelled.\n", choice);
return;
}
let files_only = choice == "f";
match Checkpoint::find(session, &num.to_string()) {
Ok(cp) => {
let result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(cp.revert_into(session, files_only, printer))
});
match result {
Ok(()) => {
let _ = session.flush();
println!();
}
Err(RalphError::UserAborted) => println!(" Revert cancelled.\n"),
Err(e) => println!(" Revert failed: {}\n", e),
}
}
Err(e) => println!(" Error loading checkpoint: {}\n", e),
}
}
fn age_string(ts: chrono::DateTime<chrono::Utc>) -> String {
let mins = chrono::Utc::now().signed_duration_since(ts).num_minutes();
if mins < 1 {
"just now".to_string()
} else if mins < 60 {
format!("{}m ago", mins)
} else {
format!("{}h ago", mins / 60)
}
}
fn require_env(env_var: &str, provider: &str) -> Result<String, RalphError> {
std::env::var(env_var).map_err(|_| RalphError::MissingApiKey {
env_var: env_var.to_string(),
provider: provider.to_string(),
})
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("…{}", &s[s.len() - (max - 1)..])
}
}