use super::{
BranchAction, CalendarAction, Cli, ClientAction, Commands, ConfigAction, EstimateAction,
GoalAction, IssueAction, ProjectAction, SessionAction, TagAction, TemplateAction,
WorkspaceAction,
};
use crate::cli::formatter::{
ansi_color, format_duration_clean, truncate_string, CliFormatter, StringFormat,
};
use crate::cli::reports::ReportGenerator;
use crate::db::advanced_queries::{
GitBranchQueries, GoalQueries, InsightQueries, TemplateQueries, TimeEstimateQueries,
WorkspaceQueries,
};
use crate::db::queries::{ProjectQueries, SessionEditQueries, SessionQueries, TagQueries};
use crate::db::{get_connection, get_database_path, get_pool_stats, Database};
use crate::models::{Goal, Project, ProjectTemplate, Tag, TimeEstimate, Workspace};
use crate::utils::config::{load_config, save_config};
use crate::utils::ipc::{get_socket_path, is_daemon_running, IpcClient, IpcMessage, IpcResponse};
use crate::utils::paths::{
canonicalize_path, detect_project_name, get_git_hash, is_git_repository, validate_project_path,
};
use crate::utils::validation::{validate_project_description, validate_project_name};
use anyhow::{Context, Result};
use chrono::{TimeZone, Utc};
use serde::Deserialize;
use std::env;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use crate::ui::dashboard::Dashboard;
use crate::ui::history::SessionHistoryBrowser;
use crate::ui::timer::InteractiveTimer;
use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use tokio::runtime::Handle;
pub async fn handle_command(cli: Cli) -> Result<()> {
match cli.command {
Commands::Start => start_daemon().await,
Commands::Stop => stop_daemon().await,
Commands::Restart => restart_daemon().await,
Commands::Status => status_daemon().await,
Commands::Init {
name,
path,
description,
} => init_project(name, path, description).await,
Commands::List { all, tag } => list_projects(all, tag).await,
Commands::Report {
project,
from,
to,
format,
group,
} => generate_report(project, from, to, format, group).await,
Commands::Project { action } => handle_project_action(action).await,
Commands::Session { action } => handle_session_action(action).await,
Commands::Tag { action } => handle_tag_action(action).await,
Commands::Config { action } => handle_config_action(action).await,
Commands::Dashboard => launch_dashboard().await,
Commands::Tui => launch_dashboard().await,
Commands::Timer => launch_timer().await,
Commands::History => launch_history().await,
Commands::Goal { action } => handle_goal_action(action).await,
Commands::Insights { period, project } => show_insights(period, project).await,
Commands::Summary { period, from } => show_summary(period, from).await,
Commands::Compare { projects, from, to } => compare_projects(projects, from, to).await,
Commands::PoolStats => show_pool_stats().await,
Commands::Estimate { action } => handle_estimate_action(action).await,
Commands::Branch { action } => handle_branch_action(action).await,
Commands::Template { action } => handle_template_action(action).await,
Commands::Workspace { action } => handle_workspace_action(action).await,
Commands::Calendar { action } => handle_calendar_action(action).await,
Commands::Issue { action } => handle_issue_action(action).await,
Commands::Client { action } => handle_client_action(action).await,
Commands::Update {
check,
force,
verbose,
} => handle_update(check, force, verbose).await,
Commands::Completions { shell } => {
Cli::generate_completions(shell);
Ok(())
}
Commands::Cat {
files,
show_all,
number_nonblank,
show_ends,
show_ends_only,
number,
squeeze_blank,
show_tabs,
show_tabs_only,
show_nonprinting,
version,
} => {
handle_cat_command(
files,
show_all,
number_nonblank,
show_ends,
show_ends_only,
number,
squeeze_blank,
show_tabs,
show_tabs_only,
show_nonprinting,
version,
)
.await
}
Commands::Seed => handle_seed_command().await,
}
}
async fn handle_seed_command() -> Result<()> {
use crate::db::seed::seed_database;
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
seed_database(&db.connection)?;
println!("Database seeded successfully!");
Ok(())
}
async fn handle_project_action(action: ProjectAction) -> Result<()> {
match action {
ProjectAction::Archive { project } => archive_project(project).await,
ProjectAction::Unarchive { project } => unarchive_project(project).await,
ProjectAction::UpdatePath { project, path } => update_project_path(project, path).await,
ProjectAction::AddTag { project, tag } => add_tag_to_project(project, tag).await,
ProjectAction::RemoveTag { project, tag } => remove_tag_from_project(project, tag).await,
}
}
async fn handle_session_action(action: SessionAction) -> Result<()> {
match action {
SessionAction::Start { project, context } => start_session(project, context).await,
SessionAction::Stop => stop_session().await,
SessionAction::Pause => pause_session().await,
SessionAction::Resume => resume_session().await,
SessionAction::Current => current_session().await,
SessionAction::List { limit, project } => list_sessions(limit, project).await,
SessionAction::Edit {
id,
start,
end,
project,
reason,
} => edit_session(id, start, end, project, reason).await,
SessionAction::Delete { id, force } => delete_session(id, force).await,
SessionAction::Merge {
session_ids,
project,
notes,
} => merge_sessions(session_ids, project, notes).await,
SessionAction::Split {
session_id,
split_times,
notes,
} => split_session(session_id, split_times, notes).await,
}
}
async fn handle_tag_action(action: TagAction) -> Result<()> {
match action {
TagAction::Create {
name,
color,
description,
} => create_tag(name, color, description).await,
TagAction::List => list_tags().await,
TagAction::Delete { name } => delete_tag(name).await,
}
}
async fn handle_config_action(action: ConfigAction) -> Result<()> {
match action {
ConfigAction::Show => show_config().await,
ConfigAction::Set { key, value } => set_config(key, value).await,
ConfigAction::Get { key } => get_config(key).await,
ConfigAction::Reset => reset_config().await,
}
}
async fn start_daemon() -> Result<()> {
if is_daemon_running() {
println!("Daemon is already running");
return Ok(());
}
let version = env!("CARGO_PKG_VERSION");
CliFormatter::print_daemon_start(version);
let current_exe = std::env::current_exe()?;
let daemon_exe = current_exe.with_file_name("tempo-daemon");
if !daemon_exe.exists() {
return Err(anyhow::anyhow!(
"tempo-daemon executable not found at {:?}",
daemon_exe
));
}
let mut cmd = Command::new(&daemon_exe);
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.stdin(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn()?;
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
if is_daemon_running() {
CliFormatter::print_daemon_success(child.id(), "Rust/Actix");
Ok(())
} else {
Err(anyhow::anyhow!("Failed to start daemon"))
}
}
async fn stop_daemon() -> Result<()> {
if !is_daemon_running() {
println!("Daemon is not running");
return Ok(());
}
println!("Stopping tempo daemon...");
if let Ok(socket_path) = get_socket_path() {
if let Ok(mut client) = IpcClient::connect(&socket_path).await {
match client.send_message(&IpcMessage::Shutdown).await {
Ok(_) => {
println!("Daemon stopped successfully");
return Ok(());
}
Err(e) => {
eprintln!("Failed to send shutdown message: {}", e);
}
}
}
}
if let Ok(Some(pid)) = crate::utils::ipc::read_pid_file() {
#[cfg(unix)]
{
let result = Command::new("kill").arg(pid.to_string()).output();
match result {
Ok(_) => println!("Daemon stopped via kill signal"),
Err(e) => eprintln!("Failed to kill daemon: {}", e),
}
}
#[cfg(windows)]
{
let result = Command::new("taskkill")
.args(&["/PID", &pid.to_string(), "/F"])
.output();
match result {
Ok(_) => println!("Daemon stopped via taskkill"),
Err(e) => eprintln!("Failed to kill daemon: {}", e),
}
}
}
Ok(())
}
async fn restart_daemon() -> Result<()> {
println!("Restarting tempo daemon...");
stop_daemon().await?;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
start_daemon().await
}
async fn status_daemon() -> Result<()> {
if !is_daemon_running() {
print_daemon_not_running();
return Ok(());
}
if let Ok(socket_path) = get_socket_path() {
match IpcClient::connect(&socket_path).await {
Ok(mut client) => {
match client.send_message(&IpcMessage::GetStatus).await {
Ok(IpcResponse::Status {
daemon_running: _,
active_session,
uptime,
}) => {
print_daemon_status(uptime, active_session.as_ref());
}
Ok(IpcResponse::Pong) => {
print_daemon_status(0, None); }
Ok(other) => {
println!("Daemon is running (unexpected response: {:?})", other);
}
Err(e) => {
println!("Daemon is running but not responding: {}", e);
}
}
}
Err(e) => {
println!("Daemon appears to be running but cannot connect: {}", e);
}
}
} else {
println!("Cannot determine socket path");
}
Ok(())
}
async fn start_session(project: Option<String>, context: Option<String>) -> Result<()> {
if !is_daemon_running() {
println!("Daemon is not running. Start it with 'tempo start'");
return Ok(());
}
let project_path = if let Some(proj) = project {
PathBuf::from(proj)
} else {
env::current_dir()?
};
let context = context.unwrap_or_else(|| "manual".to_string());
let socket_path = get_socket_path()?;
let mut client = IpcClient::connect(&socket_path).await?;
let message = IpcMessage::StartSession {
project_path: Some(project_path.clone()),
context,
};
match client.send_message(&message).await {
Ok(IpcResponse::Ok) => {
println!("Session started for project at {:?}", project_path);
}
Ok(IpcResponse::Error(message)) => {
println!("Failed to start session: {}", message);
}
Ok(other) => {
println!("Unexpected response: {:?}", other);
}
Err(e) => {
println!("Failed to communicate with daemon: {}", e);
}
}
Ok(())
}
async fn stop_session() -> Result<()> {
if !is_daemon_running() {
println!("Daemon is not running");
return Ok(());
}
let socket_path = get_socket_path()?;
let mut client = IpcClient::connect(&socket_path).await?;
match client.send_message(&IpcMessage::StopSession).await {
Ok(IpcResponse::Ok) => {
println!("Session stopped");
}
Ok(IpcResponse::Error(message)) => {
println!("Failed to stop session: {}", message);
}
Ok(other) => {
println!("Unexpected response: {:?}", other);
}
Err(e) => {
println!("Failed to communicate with daemon: {}", e);
}
}
Ok(())
}
async fn pause_session() -> Result<()> {
if !is_daemon_running() {
println!("Daemon is not running");
return Ok(());
}
let socket_path = get_socket_path()?;
let mut client = IpcClient::connect(&socket_path).await?;
match client.send_message(&IpcMessage::PauseSession).await {
Ok(IpcResponse::Ok) => {
println!("Session paused");
}
Ok(IpcResponse::Error(message)) => {
println!("Failed to pause session: {}", message);
}
Ok(other) => {
println!("Unexpected response: {:?}", other);
}
Err(e) => {
println!("Failed to communicate with daemon: {}", e);
}
}
Ok(())
}
async fn resume_session() -> Result<()> {
if !is_daemon_running() {
println!("Daemon is not running");
return Ok(());
}
let socket_path = get_socket_path()?;
let mut client = IpcClient::connect(&socket_path).await?;
match client.send_message(&IpcMessage::ResumeSession).await {
Ok(IpcResponse::Ok) => {
println!("Session resumed");
}
Ok(IpcResponse::Error(message)) => {
println!("Failed to resume session: {}", message);
}
Ok(other) => {
println!("Unexpected response: {:?}", other);
}
Err(e) => {
println!("Failed to communicate with daemon: {}", e);
}
}
Ok(())
}
async fn current_session() -> Result<()> {
if !is_daemon_running() {
print_daemon_not_running();
return Ok(());
}
let socket_path = get_socket_path()?;
let mut client = IpcClient::connect(&socket_path).await?;
match client.send_message(&IpcMessage::GetActiveSession).await {
Ok(IpcResponse::SessionInfo(session)) => {
print_formatted_session(&session)?;
}
Ok(IpcResponse::Error(message)) => {
print_no_active_session(&message);
}
Ok(other) => {
println!("Unexpected response: {:?}", other);
}
Err(e) => {
println!("Failed to communicate with daemon: {}", e);
}
}
Ok(())
}
async fn generate_report(
project: Option<String>,
from: Option<String>,
to: Option<String>,
format: Option<String>,
group: Option<String>,
) -> Result<()> {
println!("Generating time report...");
let generator = ReportGenerator::new()?;
let report = generator.generate_report(project, from, to, group)?;
match format.as_deref() {
Some("csv") => {
let output_path = PathBuf::from("tempo-report.csv");
generator.export_csv(&report, &output_path)?;
println!("Report exported to: {:?}", output_path);
}
Some("json") => {
let output_path = PathBuf::from("tempo-report.json");
generator.export_json(&report, &output_path)?;
println!("Report exported to: {:?}", output_path);
}
_ => {
print_formatted_report(&report)?;
}
}
Ok(())
}
fn print_formatted_session(session: &crate::utils::ipc::SessionInfo) -> Result<()> {
CliFormatter::print_section_header("Current Session");
CliFormatter::print_status("Active", true);
CliFormatter::print_field_bold("Project", &session.project_name, Some("yellow"));
CliFormatter::print_field_bold(
"Duration",
&format_duration_clean(session.duration),
Some("green"),
);
CliFormatter::print_field(
"Started",
&session.start_time.format("%H:%M:%S").to_string(),
None,
);
CliFormatter::print_field(
"Context",
&session.context,
Some(get_context_color(&session.context)),
);
CliFormatter::print_field(
"Path",
&session.project_path.to_string_lossy(),
Some("gray"),
);
Ok(())
}
fn get_context_color(context: &str) -> &str {
match context {
"terminal" => "cyan",
"ide" => "magenta",
"linked" => "yellow",
"manual" => "blue",
_ => "white",
}
}
fn print_formatted_report(report: &crate::cli::reports::TimeReport) -> Result<()> {
CliFormatter::print_section_header("Time Report");
for (project_name, project_summary) in &report.projects {
CliFormatter::print_project_entry(
project_name,
&format_duration_clean(project_summary.total_duration),
);
for (context, duration) in &project_summary.contexts {
CliFormatter::print_context_entry(context, &format_duration_clean(*duration));
}
println!(); }
CliFormatter::print_summary("Total Time", &format_duration_clean(report.total_duration));
Ok(())
}
fn print_daemon_not_running() {
CliFormatter::print_section_header("Daemon Status");
CliFormatter::print_status("Offline", false);
CliFormatter::print_warning("Daemon is not running.");
CliFormatter::print_info("Start it with: tempo start");
}
fn print_no_active_session(message: &str) {
CliFormatter::print_section_header("Current Session");
CliFormatter::print_status("Idle", false);
CliFormatter::print_empty_state(message);
CliFormatter::print_info("Start tracking: tempo session start");
}
fn print_daemon_status(uptime: u64, active_session: Option<&crate::utils::ipc::SessionInfo>) {
if let Some(session) = active_session {
let is_git = session.project_path.join(".git").exists();
let git_badge = if is_git {
format!(" {}", ansi_color("cyan", "GIT", false))
} else {
"".to_string()
};
let context_display = format!("{}{}", session.context, git_badge);
CliFormatter::print_block_line("Context", &context_display);
let duration = format_duration_clean(session.duration);
let session_val = format!("{} {}", duration, ansi_color("cyan", "●", false));
CliFormatter::print_block_line("Session", &session_val);
} else {
CliFormatter::print_block_line("Status", "Daemon active");
CliFormatter::print_block_line("Uptime", &format_duration_clean(uptime as i64));
}
}
async fn init_project(
name: Option<String>,
path: Option<PathBuf>,
description: Option<String>,
) -> Result<()> {
let validated_name = if let Some(n) = name.as_ref() {
Some(validate_project_name(n).with_context(|| format!("Invalid project name '{}'", n))?)
} else {
None
};
let validated_description = if let Some(d) = description.as_ref() {
Some(validate_project_description(d).with_context(|| "Invalid project description")?)
} else {
None
};
let project_path =
path.unwrap_or_else(|| env::current_dir().expect("Failed to get current directory"));
let canonical_path = validate_project_path(&project_path)
.with_context(|| format!("Invalid project path: {}", project_path.display()))?;
let project_name = validated_name.clone().unwrap_or_else(|| {
let detected = detect_project_name(&canonical_path);
validate_project_name(&detected).unwrap_or_else(|_| "project".to_string())
});
let conn = match get_connection().await {
Ok(conn) => conn,
Err(_) => {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
return init_project_with_db(
validated_name,
Some(canonical_path),
validated_description,
&db.connection,
)
.await;
}
};
if let Some(existing) = ProjectQueries::find_by_path(conn.connection(), &canonical_path)? {
eprintln!(
"\x1b[33m! Warning:\x1b[0m A project named '{}' already exists at this path.",
existing.name
);
eprintln!("Use 'tempo list' to see all projects or choose a different location.");
return Ok(());
}
init_project_with_db(
Some(project_name.clone()),
Some(canonical_path.clone()),
validated_description,
conn.connection(),
)
.await?;
println!(
"\x1b[32m+ Success:\x1b[0m Project '{}' initialized at {}",
project_name,
canonical_path.display()
);
println!("Start tracking time with: \x1b[36mtempo start\x1b[0m");
Ok(())
}
async fn list_projects(include_archived: bool, tag_filter: Option<String>) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let projects = ProjectQueries::list_all(&db.connection, include_archived)?;
if projects.is_empty() {
CliFormatter::print_section_header("No Projects");
CliFormatter::print_empty_state("No projects found.");
println!();
CliFormatter::print_info("Create a project: tempo init [project-name]");
return Ok(());
}
let filtered_projects = if let Some(_tag) = tag_filter {
projects
} else {
projects
};
CliFormatter::print_section_header("Projects");
for project in &filtered_projects {
let status_icon = if project.is_archived { "[A]" } else { "[P]" };
let git_indicator = if project.git_hash.is_some() {
" (git)"
} else {
""
};
let status_color = if project.is_archived {
"gray"
} else {
"clean_blue"
};
let project_name = format!("{} {}{}", status_icon, project.name, git_indicator);
println!(
" {}",
ansi_color(status_color, &project_name, project.is_archived == false)
);
if let Some(description) = &project.description {
println!(" {}", truncate_string(description, 60).dimmed());
}
let path_display = project.path.to_string_lossy();
let home_dir = dirs::home_dir();
let display_path = if let Some(home) = home_dir {
if let Ok(stripped) = project.path.strip_prefix(&home) {
format!("~/{}", stripped.display())
} else {
path_display.to_string()
}
} else {
path_display.to_string()
};
println!(" {}", truncate_string(&display_path, 60).dimmed());
println!();
}
println!(
" {}: {}",
"Total".dimmed(),
ansi_color(
"accent",
&format!("{} projects", filtered_projects.len()),
false
)
);
if include_archived {
let active_count = filtered_projects.iter().filter(|p| !p.is_archived).count();
let archived_count = filtered_projects.iter().filter(|p| p.is_archived).count();
println!(
" {}: {} {}: {}",
"Active".dimmed(),
active_count,
"Archived".dimmed(),
archived_count
);
}
Ok(())
}
async fn create_tag(
name: String,
color: Option<String>,
description: Option<String>,
) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let mut tag = Tag::new(name);
if let Some(c) = color {
tag = tag.with_color(c);
}
if let Some(d) = description {
tag = tag.with_description(d);
}
tag.validate()?;
let existing_tags = TagQueries::list_all(&db.connection)?;
if existing_tags.iter().any(|t| t.name == tag.name) {
println!("\x1b[33m⚠ Tag already exists:\x1b[0m {}", tag.name);
return Ok(());
}
let tag_id = TagQueries::create(&db.connection, &tag)?;
CliFormatter::print_section_header("Tag Created");
CliFormatter::print_field_bold("Name", &tag.name, Some("yellow"));
if let Some(color_val) = &tag.color {
CliFormatter::print_field("Color", color_val, None);
}
if let Some(desc) = &tag.description {
CliFormatter::print_field("Description", desc, Some("gray"));
}
CliFormatter::print_field("ID", &tag_id.to_string(), Some("gray"));
CliFormatter::print_success("Tag created successfully");
Ok(())
}
async fn list_tags() -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let tags = TagQueries::list_all(&db.connection)?;
if tags.is_empty() {
CliFormatter::print_section_header("No Tags");
CliFormatter::print_empty_state("No tags found.");
println!();
CliFormatter::print_info("Create a tag: tempo tag create <name>");
return Ok(());
}
CliFormatter::print_section_header("Tags");
for tag in &tags {
let color_indicator = if let Some(color) = &tag.color {
format!(" ({})", color)
} else {
String::new()
};
let tag_name = format!("{}{}", tag.name, color_indicator);
CliFormatter::print_field("Tag", &tag_name, Some("yellow"));
if let Some(description) = &tag.description {
CliFormatter::print_field("Description", description, None);
}
println!();
}
CliFormatter::print_block_line("Total", &format!("{} tags", tags.len()));
Ok(())
}
async fn delete_tag(name: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
if TagQueries::find_by_name(&db.connection, &name)?.is_none() {
println!("\x1b[31m✗ Tag '{}' not found\x1b[0m", name);
return Ok(());
}
let deleted = TagQueries::delete_by_name(&db.connection, &name)?;
if deleted {
CliFormatter::print_section_header("Tag Deleted");
CliFormatter::print_field_bold("Name", &name, Some("yellow"));
CliFormatter::print_status("Deleted", true);
CliFormatter::print_success("Tag deleted successfully");
} else {
CliFormatter::print_error(&format!("Failed to delete tag '{}'", name));
}
Ok(())
}
async fn show_config() -> Result<()> {
let config = load_config()?;
CliFormatter::print_section_header("Configuration");
CliFormatter::print_field(
"idle_timeout_minutes",
&config.idle_timeout_minutes.to_string(),
Some("yellow"),
);
CliFormatter::print_field(
"auto_pause_enabled",
&config.auto_pause_enabled.to_string(),
Some("yellow"),
);
CliFormatter::print_field("default_context", &config.default_context, Some("yellow"));
CliFormatter::print_field(
"max_session_hours",
&config.max_session_hours.to_string(),
Some("yellow"),
);
CliFormatter::print_field(
"backup_enabled",
&config.backup_enabled.to_string(),
Some("yellow"),
);
CliFormatter::print_field("log_level", &config.log_level, Some("yellow"));
if !config.custom_settings.is_empty() {
println!();
CliFormatter::print_field_bold("Custom Settings", "", None);
for (key, value) in &config.custom_settings {
CliFormatter::print_field(key, value, Some("yellow"));
}
}
Ok(())
}
async fn get_config(key: String) -> Result<()> {
let config = load_config()?;
let value = match key.as_str() {
"idle_timeout_minutes" => Some(config.idle_timeout_minutes.to_string()),
"auto_pause_enabled" => Some(config.auto_pause_enabled.to_string()),
"default_context" => Some(config.default_context),
"max_session_hours" => Some(config.max_session_hours.to_string()),
"backup_enabled" => Some(config.backup_enabled.to_string()),
"log_level" => Some(config.log_level),
_ => config.custom_settings.get(&key).cloned(),
};
match value {
Some(val) => {
CliFormatter::print_section_header("Configuration Value");
CliFormatter::print_field(&key, &val, Some("yellow"));
}
None => {
CliFormatter::print_error(&format!("Configuration key not found: {}", key));
}
}
Ok(())
}
async fn set_config(key: String, value: String) -> Result<()> {
let mut config = load_config()?;
let display_value = value.clone();
match key.as_str() {
"idle_timeout_minutes" => {
config.idle_timeout_minutes = value.parse()?;
}
"auto_pause_enabled" => {
config.auto_pause_enabled = value.parse()?;
}
"default_context" => {
config.default_context = value;
}
"max_session_hours" => {
config.max_session_hours = value.parse()?;
}
"backup_enabled" => {
config.backup_enabled = value.parse()?;
}
"log_level" => {
config.log_level = value;
}
_ => {
config.set_custom(key.clone(), value);
}
}
config.validate()?;
save_config(&config)?;
CliFormatter::print_section_header("Configuration Updated");
CliFormatter::print_field(&key, &display_value, Some("green"));
CliFormatter::print_success("Configuration saved successfully");
Ok(())
}
async fn reset_config() -> Result<()> {
let default_config = crate::models::Config::default();
save_config(&default_config)?;
CliFormatter::print_section_header("Configuration Reset");
CliFormatter::print_success("Configuration reset to defaults");
CliFormatter::print_info("View current config: tempo config show");
Ok(())
}
async fn list_sessions(limit: Option<usize>, project_filter: Option<String>) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let session_limit = limit.unwrap_or(10);
let project_id = if let Some(project_name) = &project_filter {
match ProjectQueries::find_by_name(&db.connection, project_name)? {
Some(project) => Some(project.id.unwrap()),
None => {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
return Ok(());
}
}
} else {
None
};
let sessions = SessionQueries::list_with_filter(
&db.connection,
project_id,
None,
None,
Some(session_limit),
)?;
if sessions.is_empty() {
CliFormatter::print_section_header("No Sessions");
CliFormatter::print_empty_state("No sessions found.");
println!();
CliFormatter::print_info("Start a session: tempo session start");
return Ok(());
}
let filtered_sessions = if let Some(_project) = project_filter {
sessions
} else {
sessions
};
CliFormatter::print_section_header("Recent Sessions");
for session in &filtered_sessions {
let status_icon = if session.end_time.is_some() {
"✅"
} else {
"🔄"
};
let duration = if let Some(end) = session.end_time {
(end - session.start_time).num_seconds() - session.paused_duration.num_seconds()
} else {
(Utc::now() - session.start_time).num_seconds() - session.paused_duration.num_seconds()
};
let context_color = match session.context {
crate::models::SessionContext::Terminal => "cyan",
crate::models::SessionContext::IDE => "magenta",
crate::models::SessionContext::Linked => "yellow",
crate::models::SessionContext::Manual => "blue",
};
println!(
" {} {}",
status_icon,
ansi_color(
"white",
&format!("Session {}", session.id.unwrap_or(0)),
true
)
);
CliFormatter::print_field(
" Duration",
&format_duration_clean(duration),
Some("green"),
);
CliFormatter::print_field(
" Context",
&session.context.to_string(),
Some(context_color),
);
CliFormatter::print_field(
" Started",
&session.start_time.format("%Y-%m-%d %H:%M:%S").to_string(),
None,
);
println!();
}
println!(
" {}: {}",
"Showing".dimmed(),
format!("{} recent sessions", filtered_sessions.len())
);
Ok(())
}
async fn edit_session(
id: i64,
start: Option<String>,
end: Option<String>,
project: Option<String>,
reason: Option<String>,
) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let session = SessionQueries::find_by_id(&db.connection, id)?;
let session = match session {
Some(s) => s,
None => {
println!("\x1b[31m✗ Session {} not found\x1b[0m", id);
return Ok(());
}
};
let original_start = session.start_time;
let original_end = session.end_time;
let mut new_start = original_start;
let mut new_end = original_end;
let mut new_project_id = session.project_id;
if let Some(start_str) = &start {
new_start = match chrono::DateTime::parse_from_rfc3339(start_str) {
Ok(dt) => dt.with_timezone(&chrono::Utc),
Err(_) => match chrono::NaiveDateTime::parse_from_str(start_str, "%Y-%m-%d %H:%M:%S") {
Ok(dt) => chrono::Utc.from_utc_datetime(&dt),
Err(_) => {
return Err(anyhow::anyhow!(
"Invalid start time format. Use RFC3339 or 'YYYY-MM-DD HH:MM:SS'"
))
}
},
};
}
if let Some(end_str) = &end {
if end_str.to_lowercase() == "null" || end_str.to_lowercase() == "none" {
new_end = None;
} else {
new_end = Some(match chrono::DateTime::parse_from_rfc3339(end_str) {
Ok(dt) => dt.with_timezone(&chrono::Utc),
Err(_) => {
match chrono::NaiveDateTime::parse_from_str(end_str, "%Y-%m-%d %H:%M:%S") {
Ok(dt) => chrono::Utc.from_utc_datetime(&dt),
Err(_) => {
return Err(anyhow::anyhow!(
"Invalid end time format. Use RFC3339 or 'YYYY-MM-DD HH:MM:SS'"
))
}
}
}
});
}
}
if let Some(project_name) = &project {
if let Some(proj) = ProjectQueries::find_by_name(&db.connection, project_name)? {
new_project_id = proj.id.unwrap();
} else {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
return Ok(());
}
}
if new_start >= new_end.unwrap_or(chrono::Utc::now()) {
println!("\x1b[31m✗ Start time must be before end time\x1b[0m");
return Ok(());
}
SessionEditQueries::create_edit_record(
&db.connection,
id,
original_start,
original_end,
new_start,
new_end,
reason.clone(),
)?;
SessionQueries::update_session(
&db.connection,
id,
if start.is_some() {
Some(new_start)
} else {
None
},
if end.is_some() { Some(new_end) } else { None },
if project.is_some() {
Some(new_project_id)
} else {
None
},
None,
)?;
CliFormatter::print_section_header("Session Updated");
CliFormatter::print_field("Session", &id.to_string(), Some("white"));
if start.is_some() {
CliFormatter::print_field(
"Start",
&new_start.format("%Y-%m-%d %H:%M:%S").to_string(),
Some("green"),
);
}
if end.is_some() {
let end_str = if let Some(e) = new_end {
e.format("%Y-%m-%d %H:%M:%S").to_string()
} else {
"Ongoing".to_string()
};
CliFormatter::print_field("End", &end_str, Some("green"));
}
if let Some(r) = &reason {
CliFormatter::print_field("Reason", r, Some("gray"));
}
CliFormatter::print_success("Session updated with audit trail");
Ok(())
}
async fn delete_session(id: i64, force: bool) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let session = SessionQueries::find_by_id(&db.connection, id)?;
let session = match session {
Some(s) => s,
None => {
println!("\x1b[31m✗ Session {} not found\x1b[0m", id);
return Ok(());
}
};
if session.end_time.is_none() && !force {
println!("\x1b[33m⚠ Cannot delete active session without --force flag\x1b[0m");
println!(" Use: tempo session delete {} --force", id);
return Ok(());
}
SessionQueries::delete_session(&db.connection, id)?;
CliFormatter::print_section_header("Session Deleted");
CliFormatter::print_field("Session", &id.to_string(), Some("white"));
CliFormatter::print_field("Status", "Deleted", Some("green"));
if session.end_time.is_none() {
CliFormatter::print_field("Type", "Active session (forced)", Some("yellow"));
} else {
CliFormatter::print_field("Type", "Completed session", None);
}
CliFormatter::print_success("Session and audit trail removed");
Ok(())
}
async fn archive_project(project_name: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
Some(p) => p,
None => {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
return Ok(());
}
};
if project.is_archived {
println!(
"\x1b[33m⚠ Project '{}' is already archived\x1b[0m",
project_name
);
return Ok(());
}
let success = ProjectQueries::archive_project(&db.connection, project.id.unwrap())?;
if success {
CliFormatter::print_section_header("Project Archived");
CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
CliFormatter::print_field("Status", "Archived", Some("gray"));
CliFormatter::print_success("Project archived successfully");
} else {
CliFormatter::print_error(&format!("Failed to archive project '{}'", project_name));
}
Ok(())
}
async fn unarchive_project(project_name: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
Some(p) => p,
None => {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
return Ok(());
}
};
if !project.is_archived {
println!(
"\x1b[33m⚠ Project '{}' is not archived\x1b[0m",
project_name
);
return Ok(());
}
let success = ProjectQueries::unarchive_project(&db.connection, project.id.unwrap())?;
if success {
CliFormatter::print_section_header("Project Unarchived");
CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
CliFormatter::print_field("Status", "Active", Some("green"));
CliFormatter::print_success("Project unarchived successfully");
} else {
CliFormatter::print_error(&format!("Failed to unarchive project '{}'", project_name));
}
Ok(())
}
async fn update_project_path(project_name: String, new_path: PathBuf) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
Some(p) => p,
None => {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
return Ok(());
}
};
let canonical_path = canonicalize_path(&new_path)?;
let success =
ProjectQueries::update_project_path(&db.connection, project.id.unwrap(), &canonical_path)?;
if success {
CliFormatter::print_section_header("Project Path Updated");
CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
CliFormatter::print_field("Old Path", &project.path.to_string_lossy(), Some("gray"));
CliFormatter::print_field("New Path", &canonical_path.to_string_lossy(), Some("green"));
CliFormatter::print_success("Path updated successfully");
} else {
CliFormatter::print_error(&format!(
"Failed to update path for project '{}'",
project_name
));
}
Ok(())
}
async fn add_tag_to_project(project_name: String, tag_name: String) -> Result<()> {
println!("\x1b[33m⚠ Project-tag associations not yet implemented\x1b[0m");
println!("Would add tag '{}' to project '{}'", tag_name, project_name);
println!("This requires implementing project_tags table operations.");
Ok(())
}
async fn remove_tag_from_project(project_name: String, tag_name: String) -> Result<()> {
println!("\x1b[33m⚠ Project-tag associations not yet implemented\x1b[0m");
println!(
"Would remove tag '{}' from project '{}'",
tag_name, project_name
);
println!("This requires implementing project_tags table operations.");
Ok(())
}
#[allow(dead_code)]
async fn bulk_update_sessions_project(
session_ids: Vec<i64>,
new_project_name: String,
) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project = match ProjectQueries::find_by_name(&db.connection, &new_project_name)? {
Some(p) => p,
None => {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", new_project_name);
return Ok(());
}
};
let updated =
SessionQueries::bulk_update_project(&db.connection, &session_ids, project.id.unwrap())?;
CliFormatter::print_section_header("Bulk Session Update");
CliFormatter::print_field("Sessions", &updated.to_string(), Some("white"));
CliFormatter::print_field("Project", &new_project_name, Some("yellow"));
CliFormatter::print_success(&format!("{} sessions updated", updated));
Ok(())
}
#[allow(dead_code)]
async fn bulk_delete_sessions(session_ids: Vec<i64>) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let deleted = SessionQueries::bulk_delete(&db.connection, &session_ids)?;
CliFormatter::print_section_header("Bulk Session Delete");
CliFormatter::print_field("Requested", &session_ids.len().to_string(), Some("white"));
CliFormatter::print_field("Deleted", &deleted.to_string(), Some("green"));
CliFormatter::print_success(&format!("{} sessions deleted", deleted));
Ok(())
}
async fn launch_dashboard() -> Result<()> {
if !is_tty() {
return show_dashboard_fallback().await;
}
enable_raw_mode()
.context("Failed to enable raw mode - terminal may not support interactive features")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)
.context("Failed to enter alternate screen - terminal may not support full-screen mode")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("Failed to initialize terminal backend")?;
terminal.clear().context("Failed to clear terminal")?;
let result = async {
let mut dashboard = Dashboard::new().await?;
dashboard.run(&mut terminal).await
};
let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
let cleanup_result = cleanup_terminal(&mut terminal);
if let Err(e) = cleanup_result {
eprintln!("Warning: Failed to restore terminal: {}", e);
}
result
}
fn is_tty() -> bool {
use std::os::unix::io::AsRawFd;
unsafe { libc::isatty(std::io::stdin().as_raw_fd()) == 1 }
}
async fn show_dashboard_fallback() -> Result<()> {
println!("📊 Tempo Dashboard (Text Mode)");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
if is_daemon_running() {
println!("🟢 Daemon Status: Running");
} else {
println!("🔴 Daemon Status: Offline");
println!(" Start with: tempo start");
println!();
return Ok(());
}
let socket_path = get_socket_path()?;
if let Ok(mut client) = IpcClient::connect(&socket_path).await {
match client.send_message(&IpcMessage::GetActiveSession).await {
Ok(IpcResponse::ActiveSession(Some(session))) => {
println!("⏱️ Active Session:");
println!(" Started: {}", session.start_time.format("%H:%M:%S"));
println!(
" Duration: {}",
format_duration_simple(
(chrono::Utc::now().timestamp() - session.start_time.timestamp())
- session.paused_duration.num_seconds()
)
);
println!(" Context: {}", session.context);
println!();
match client
.send_message(&IpcMessage::GetProject(session.project_id))
.await
{
Ok(IpcResponse::Project(Some(project))) => {
println!("📁 Current Project: {}", project.name);
println!(" Path: {}", project.path.display());
println!();
}
_ => {
println!("📁 Project: Unknown");
println!();
}
}
}
_ => {
println!("⏸️ No active session");
println!(" Start tracking with: tempo session start");
println!();
}
}
let today = chrono::Local::now().date_naive();
match client.send_message(&IpcMessage::GetDailyStats(today)).await {
Ok(IpcResponse::DailyStats {
sessions_count,
total_seconds,
avg_seconds,
}) => {
println!("📈 Today's Summary:");
println!(" Sessions: {}", sessions_count);
println!(" Total time: {}", format_duration_simple(total_seconds));
if sessions_count > 0 {
println!(
" Average session: {}",
format_duration_simple(avg_seconds)
);
}
let progress = (total_seconds as f64 / (8.0 * 3600.0)) * 100.0;
println!(" Daily goal (8h): {:.1}%", progress);
println!();
}
_ => {
println!("📈 Today's Summary: No data available");
println!();
}
}
} else {
println!("❌ Unable to connect to daemon");
println!(" Try: tempo restart");
println!();
}
println!("💡 For interactive dashboard, run in a terminal:");
println!(" • Terminal.app, iTerm2, or other terminal emulators");
println!(" • SSH sessions with TTY allocation (ssh -t)");
println!(" • Interactive shell environments");
Ok(())
}
fn format_duration_simple(seconds: i64) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, minutes, secs)
} else if minutes > 0 {
format!("{}m {}s", minutes, secs)
} else {
format!("{}s", secs)
}
}
fn cleanup_terminal<B>(terminal: &mut Terminal<B>) -> Result<()>
where
B: ratatui::backend::Backend + std::io::Write,
{
disable_raw_mode().context("Failed to disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("Failed to leave alternate screen")?;
terminal.show_cursor().context("Failed to show cursor")?;
Ok(())
}
async fn launch_timer() -> Result<()> {
if !is_tty() {
return Err(anyhow::anyhow!(
"Interactive timer requires an interactive terminal (TTY).\n\
\n\
This command needs to run in a proper terminal environment.\n\
Try running this command directly in your terminal application."
));
}
enable_raw_mode().context("Failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("Failed to initialize terminal")?;
terminal.clear().context("Failed to clear terminal")?;
let result = async {
let mut timer = InteractiveTimer::new().await?;
timer.run(&mut terminal).await
};
let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
let cleanup_result = cleanup_terminal(&mut terminal);
if let Err(e) = cleanup_result {
eprintln!("Warning: Failed to restore terminal: {}", e);
}
result
}
async fn merge_sessions(
session_ids_str: String,
project_name: Option<String>,
notes: Option<String>,
) -> Result<()> {
let session_ids: Result<Vec<i64>, _> = session_ids_str
.split(',')
.map(|s| s.trim().parse::<i64>())
.collect();
let session_ids = session_ids.map_err(|_| {
anyhow::anyhow!("Invalid session IDs format. Use comma-separated numbers like '1,2,3'")
})?;
if session_ids.len() < 2 {
return Err(anyhow::anyhow!(
"At least 2 sessions are required for merging"
));
}
let mut target_project_id = None;
if let Some(project) = project_name {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
if let Ok(project_id) = project.parse::<i64>() {
if ProjectQueries::find_by_id(&db.connection, project_id)?.is_some() {
target_project_id = Some(project_id);
}
} else if let Some(proj) = ProjectQueries::find_by_name(&db.connection, &project)? {
target_project_id = proj.id;
}
if target_project_id.is_none() {
return Err(anyhow::anyhow!("Project '{}' not found", project));
}
}
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let merged_id =
SessionQueries::merge_sessions(&db.connection, &session_ids, target_project_id, notes)?;
CliFormatter::print_section_header("Session Merge Complete");
CliFormatter::print_field(
"Merged sessions",
&session_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", "),
Some("yellow"),
);
CliFormatter::print_field("New session ID", &merged_id.to_string(), Some("green"));
CliFormatter::print_success("Sessions successfully merged");
Ok(())
}
async fn split_session(
session_id: i64,
split_times_str: String,
notes: Option<String>,
) -> Result<()> {
let split_time_strings: Vec<&str> = split_times_str.split(',').map(|s| s.trim()).collect();
let mut split_times = Vec::new();
for time_str in split_time_strings {
let datetime = if time_str.contains(':') {
let today = chrono::Local::now().date_naive();
let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M")
.or_else(|_| chrono::NaiveTime::parse_from_str(time_str, "%H:%M:%S"))
.map_err(|_| {
anyhow::anyhow!("Invalid time format '{}'. Use HH:MM or HH:MM:SS", time_str)
})?;
today.and_time(time).and_utc()
} else {
chrono::DateTime::parse_from_rfc3339(time_str)
.map_err(|_| {
anyhow::anyhow!(
"Invalid datetime format '{}'. Use HH:MM or RFC3339 format",
time_str
)
})?
.to_utc()
};
split_times.push(datetime);
}
if split_times.is_empty() {
return Err(anyhow::anyhow!("No valid split times provided"));
}
let notes_list = notes.map(|n| {
n.split(',')
.map(|s| s.trim().to_string())
.collect::<Vec<String>>()
});
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let new_session_ids =
SessionQueries::split_session(&db.connection, session_id, &split_times, notes_list)?;
CliFormatter::print_section_header("Session Split Complete");
CliFormatter::print_field("Original session", &session_id.to_string(), Some("yellow"));
CliFormatter::print_field("Split points", &split_times.len().to_string(), Some("gray"));
CliFormatter::print_field(
"New sessions",
&new_session_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", "),
Some("green"),
);
CliFormatter::print_success("Session successfully split");
Ok(())
}
async fn launch_history() -> Result<()> {
if !is_tty() {
return Err(anyhow::anyhow!(
"Session history browser requires an interactive terminal (TTY).\n\
\n\
This command needs to run in a proper terminal environment.\n\
Try running this command directly in your terminal application."
));
}
enable_raw_mode().context("Failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("Failed to initialize terminal")?;
terminal.clear().context("Failed to clear terminal")?;
let result = async {
let mut browser = SessionHistoryBrowser::new().await?;
browser.run(&mut terminal).await
};
let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
let cleanup_result = cleanup_terminal(&mut terminal);
if let Err(e) = cleanup_result {
eprintln!("Warning: Failed to restore terminal: {}", e);
}
result
}
async fn handle_goal_action(action: GoalAction) -> Result<()> {
match action {
GoalAction::Create {
name,
target_hours,
project,
description,
start_date,
end_date,
} => {
create_goal(
name,
target_hours,
project,
description,
start_date,
end_date,
)
.await
}
GoalAction::List { project } => list_goals(project).await,
GoalAction::Update { id, hours } => update_goal_progress(id, hours).await,
}
}
async fn create_goal(
name: String,
target_hours: f64,
project: Option<String>,
description: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project_id = if let Some(proj_name) = project {
match ProjectQueries::find_by_name(&db.connection, &proj_name)? {
Some(p) => p.id,
None => {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", proj_name);
return Ok(());
}
}
} else {
None
};
let start = start_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
let end = end_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
let mut goal = Goal::new(name.clone(), target_hours);
if let Some(pid) = project_id {
goal = goal.with_project(pid);
}
if let Some(desc) = description {
goal = goal.with_description(desc);
}
goal = goal.with_dates(start, end);
goal.validate()?;
let goal_id = GoalQueries::create(&db.connection, &goal)?;
CliFormatter::print_section_header("Goal Created");
CliFormatter::print_field_bold("Name", &name, Some("yellow"));
CliFormatter::print_field("Target", &format!("{} hours", target_hours), Some("green"));
CliFormatter::print_field("ID", &goal_id.to_string(), Some("gray"));
CliFormatter::print_success("Goal created successfully");
Ok(())
}
async fn list_goals(project: Option<String>) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project_id = if let Some(proj_name) = &project {
match ProjectQueries::find_by_name(&db.connection, proj_name)? {
Some(p) => p.id,
None => {
println!("\x1b[31m✗ Project '{}' not found\x1b[0m", proj_name);
return Ok(());
}
}
} else {
None
};
let goals = GoalQueries::list_by_project(&db.connection, project_id)?;
if goals.is_empty() {
CliFormatter::print_section_header("No Goals");
CliFormatter::print_empty_state("No goals found.");
return Ok(());
}
CliFormatter::print_section_header("Goals");
for goal in &goals {
let progress_pct = goal.progress_percentage();
CliFormatter::print_field("Goal", &goal.name, Some("yellow"));
CliFormatter::print_field(
"Progress",
&format!(
"{}% ({:.1}h / {:.1}h)",
ansi_color("green", &format!("{:.1}", progress_pct), false),
goal.current_progress,
goal.target_hours
),
None,
);
println!();
}
Ok(())
}
async fn update_goal_progress(id: i64, hours: f64) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
GoalQueries::update_progress(&db.connection, id, hours)?;
CliFormatter::print_success(&format!("Updated goal {} progress by {} hours", id, hours));
Ok(())
}
async fn show_insights(period: Option<String>, project: Option<String>) -> Result<()> {
CliFormatter::print_section_header("Productivity Insights");
CliFormatter::print_field("Period", period.as_deref().unwrap_or("all"), Some("yellow"));
if let Some(proj) = project {
CliFormatter::print_field("Project", &truncate_string(&proj, 40), Some("yellow"));
}
println!();
CliFormatter::print_warning("Insights calculation in progress...");
Ok(())
}
async fn show_summary(period: String, from: Option<String>) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let start_date = if let Some(from_str) = from {
chrono::NaiveDate::parse_from_str(&from_str, "%Y-%m-%d")?
} else {
match period.as_str() {
"week" => chrono::Local::now().date_naive() - chrono::Duration::days(7),
"month" => chrono::Local::now().date_naive() - chrono::Duration::days(30),
_ => chrono::Local::now().date_naive(),
}
};
let insight_data = match period.as_str() {
"week" => InsightQueries::calculate_weekly_summary(&db.connection, start_date)?,
"month" => InsightQueries::calculate_monthly_summary(&db.connection, start_date)?,
_ => return Err(anyhow::anyhow!("Invalid period. Use 'week' or 'month'")),
};
CliFormatter::print_section_header(&format!("{} Summary", period));
CliFormatter::print_field(
"Total Hours",
&format!("{:.1}h", insight_data.total_hours),
Some("green"),
);
CliFormatter::print_field(
"Sessions",
&insight_data.sessions_count.to_string(),
Some("yellow"),
);
CliFormatter::print_field(
"Avg Session",
&format!("{:.1}h", insight_data.avg_session_duration),
Some("yellow"),
);
Ok(())
}
async fn compare_projects(
projects: String,
_from: Option<String>,
_to: Option<String>,
) -> Result<()> {
let _project_names: Vec<&str> = projects.split(',').map(|s| s.trim()).collect();
CliFormatter::print_section_header("Project Comparison");
CliFormatter::print_field("Projects", &truncate_string(&projects, 60), Some("yellow"));
println!();
CliFormatter::print_warning("Comparison feature in development");
Ok(())
}
async fn handle_estimate_action(action: EstimateAction) -> Result<()> {
match action {
EstimateAction::Create {
project,
task,
hours,
due_date,
} => create_estimate(project, task, hours, due_date).await,
EstimateAction::Record { id, hours } => record_actual_time(id, hours).await,
EstimateAction::List { project } => list_estimates(project).await,
}
}
async fn create_estimate(
project: String,
task: String,
hours: f64,
due_date: Option<String>,
) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
let due = due_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
let mut estimate = TimeEstimate::new(project_obj.id.unwrap(), task.clone(), hours);
estimate.due_date = due;
let estimate_id = TimeEstimateQueries::create(&db.connection, &estimate)?;
CliFormatter::print_section_header("Time Estimate Created");
CliFormatter::print_field_bold("Task", &task, Some("yellow"));
CliFormatter::print_field("Estimate", &format!("{} hours", hours), Some("green"));
CliFormatter::print_field("ID", &estimate_id.to_string(), Some("gray"));
Ok(())
}
async fn record_actual_time(id: i64, hours: f64) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
TimeEstimateQueries::record_actual(&db.connection, id, hours)?;
println!(
"\x1b[32m✓ Recorded {} hours for estimate {}\x1b[0m",
hours, id
);
Ok(())
}
async fn list_estimates(project: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
let estimates = TimeEstimateQueries::list_by_project(&db.connection, project_obj.id.unwrap())?;
CliFormatter::print_section_header("Time Estimates");
for est in &estimates {
let variance = est.variance();
let variance_str = if let Some(v) = variance {
if v > 0.0 {
ansi_color("red", &format!("+{:.1}h over", v), false)
} else {
ansi_color("green", &format!("{:.1}h under", v.abs()), false)
}
} else {
"N/A".to_string()
};
println!(" 📋 {}", ansi_color("yellow", &est.task_name, true));
let actual_str = est
.actual_hours
.map(|h| format!("{:.1}h", h))
.unwrap_or_else(|| "N/A".to_string());
println!(
" Est: {}h | Actual: {} | {}",
est.estimated_hours, actual_str, variance_str
);
println!();
}
Ok(())
}
async fn handle_branch_action(action: BranchAction) -> Result<()> {
match action {
BranchAction::List { project } => list_branches(project).await,
BranchAction::Stats { project, branch } => show_branch_stats(project, branch).await,
}
}
async fn list_branches(project: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
let branches = GitBranchQueries::list_by_project(&db.connection, project_obj.id.unwrap())?;
CliFormatter::print_section_header("Git Branches");
for branch in &branches {
println!(" 🌿 {}", ansi_color("yellow", &branch.branch_name, true));
println!(
" Time: {}",
ansi_color("green", &format!("{:.1}h", branch.total_hours()), false)
);
println!();
}
Ok(())
}
async fn show_branch_stats(project: String, branch: Option<String>) -> Result<()> {
CliFormatter::print_section_header("Branch Statistics");
CliFormatter::print_field("Project", &project, Some("yellow"));
if let Some(b) = branch {
CliFormatter::print_field("Branch", &b, Some("yellow"));
}
CliFormatter::print_warning("Branch stats in development");
Ok(())
}
async fn handle_template_action(action: TemplateAction) -> Result<()> {
match action {
TemplateAction::Create {
name,
description,
tags,
workspace_path,
} => create_template(name, description, tags, workspace_path).await,
TemplateAction::List => list_templates().await,
TemplateAction::Delete { template } => delete_template(template).await,
TemplateAction::Use {
template,
project_name,
path,
} => use_template(template, project_name, path).await,
}
}
async fn create_template(
name: String,
description: Option<String>,
tags: Option<String>,
workspace_path: Option<PathBuf>,
) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let default_tags = tags
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let mut template = ProjectTemplate::new(name.clone()).with_tags(default_tags);
let desc_clone = description.clone();
if let Some(desc) = description {
template = template.with_description(desc);
}
if let Some(path) = workspace_path {
template = template.with_workspace_path(path);
}
let _template_id = TemplateQueries::create(&db.connection, &template)?;
CliFormatter::print_section_header("Template Created");
CliFormatter::print_field_bold("Name", &name, Some("yellow"));
if let Some(desc) = &desc_clone {
CliFormatter::print_field("Description", desc, Some("gray"));
}
Ok(())
}
async fn list_templates() -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let templates = TemplateQueries::list_all(&db.connection)?;
CliFormatter::print_section_header("Templates");
if templates.is_empty() {
CliFormatter::print_empty_state("No templates found.");
} else {
for template in &templates {
CliFormatter::print_field("Template", &template.name, Some("yellow"));
if let Some(desc) = &template.description {
CliFormatter::print_field("Description", desc, None);
}
println!();
}
}
Ok(())
}
async fn delete_template(_template: String) -> Result<()> {
println!("\x1b[33m⚠ Template deletion not yet implemented\x1b[0m");
Ok(())
}
async fn use_template(template: String, project_name: String, path: Option<PathBuf>) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let templates = TemplateQueries::list_all(&db.connection)?;
let selected_template = templates
.iter()
.find(|t| t.name == template || t.id.map(|id| id.to_string()) == Some(template.clone()))
.ok_or_else(|| anyhow::anyhow!("Template '{}' not found", template))?;
let project_path = path.unwrap_or_else(|| env::current_dir().unwrap());
let canonical_path = canonicalize_path(&project_path)?;
if ProjectQueries::find_by_path(&db.connection, &canonical_path)?.is_some() {
return Err(anyhow::anyhow!("Project already exists at this path"));
}
let git_hash = if is_git_repository(&canonical_path) {
get_git_hash(&canonical_path)
} else {
None
};
let template_desc = selected_template.description.clone();
let mut project = Project::new(project_name.clone(), canonical_path.clone())
.with_git_hash(git_hash)
.with_description(template_desc);
let project_id = ProjectQueries::create(&db.connection, &project)?;
project.id = Some(project_id);
for goal_def in &selected_template.default_goals {
let mut goal =
Goal::new(goal_def.name.clone(), goal_def.target_hours).with_project(project_id);
if let Some(desc) = &goal_def.description {
goal = goal.with_description(desc.clone());
}
GoalQueries::create(&db.connection, &goal)?;
}
CliFormatter::print_section_header("Project Created from Template");
CliFormatter::print_field_bold("Template", &selected_template.name, Some("yellow"));
CliFormatter::print_field_bold("Project", &project_name, Some("yellow"));
Ok(())
}
async fn handle_workspace_action(action: WorkspaceAction) -> Result<()> {
match action {
WorkspaceAction::Create {
name,
description,
path,
} => create_workspace(name, description, path).await,
WorkspaceAction::List => list_workspaces().await,
WorkspaceAction::AddProject { workspace, project } => {
add_project_to_workspace(workspace, project).await
}
WorkspaceAction::RemoveProject { workspace, project } => {
remove_project_from_workspace(workspace, project).await
}
WorkspaceAction::Projects { workspace } => list_workspace_projects(workspace).await,
WorkspaceAction::Delete { workspace } => delete_workspace(workspace).await,
}
}
async fn create_workspace(
name: String,
description: Option<String>,
path: Option<PathBuf>,
) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let mut workspace = Workspace::new(name.clone());
let desc_clone = description.clone();
if let Some(desc) = description {
workspace = workspace.with_description(desc);
}
if let Some(p) = path {
workspace = workspace.with_path(p);
}
let _workspace_id = WorkspaceQueries::create(&db.connection, &workspace)?;
CliFormatter::print_section_header("Workspace Created");
CliFormatter::print_field_bold("Name", &name, Some("yellow"));
if let Some(desc) = &desc_clone {
CliFormatter::print_field("Description", desc, Some("gray"));
}
Ok(())
}
async fn list_workspaces() -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let workspaces = WorkspaceQueries::list_all(&db.connection)?;
CliFormatter::print_section_header("Workspaces");
if workspaces.is_empty() {
CliFormatter::print_empty_state("No workspaces found.");
} else {
for workspace in &workspaces {
CliFormatter::print_field("Workspace", &workspace.name, Some("yellow"));
if let Some(desc) = &workspace.description {
CliFormatter::print_field("Description", desc, None);
}
println!();
}
}
Ok(())
}
async fn add_project_to_workspace(workspace: String, project: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
let workspace_id = workspace_obj
.id
.ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
let project_id = project_obj
.id
.ok_or_else(|| anyhow::anyhow!("Project ID is missing"))?;
if WorkspaceQueries::add_project(&db.connection, workspace_id, project_id)? {
println!(
"\x1b[32m✓\x1b[0m Added project '\x1b[33m{}\x1b[0m' to workspace '\x1b[33m{}\x1b[0m'",
project, workspace
);
} else {
println!("\x1b[33m⚠\x1b[0m Project '\x1b[33m{}\x1b[0m' is already in workspace '\x1b[33m{}\x1b[0m'", project, workspace);
}
Ok(())
}
async fn remove_project_from_workspace(workspace: String, project: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
let workspace_id = workspace_obj
.id
.ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
let project_id = project_obj
.id
.ok_or_else(|| anyhow::anyhow!("Project ID is missing"))?;
if WorkspaceQueries::remove_project(&db.connection, workspace_id, project_id)? {
println!("\x1b[32m✓\x1b[0m Removed project '\x1b[33m{}\x1b[0m' from workspace '\x1b[33m{}\x1b[0m'", project, workspace);
} else {
println!(
"\x1b[33m⚠\x1b[0m Project '\x1b[33m{}\x1b[0m' was not in workspace '\x1b[33m{}\x1b[0m'",
project, workspace
);
}
Ok(())
}
async fn list_workspace_projects(workspace: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
let workspace_id = workspace_obj
.id
.ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
let projects = WorkspaceQueries::list_projects(&db.connection, workspace_id)?;
if projects.is_empty() {
CliFormatter::print_warning(&format!("No projects found in workspace '{}'", workspace));
return Ok(());
}
CliFormatter::print_section_header("Workspace Projects");
CliFormatter::print_field(
"Workspace",
&truncate_string(&workspace, 60),
Some("yellow"),
);
CliFormatter::print_field(
"Projects",
&format!("{} projects", projects.len()),
Some("green"),
);
println!();
for project in &projects {
let status_indicator = if !project.is_archived {
ansi_color("green", "●", false)
} else {
ansi_color("red", "○", false)
};
let project_name = format!("{} {}", status_indicator, project.name);
CliFormatter::print_field("Project", &project_name, Some("cyan"));
if let Some(desc) = &project.description {
CliFormatter::print_field("Description", &truncate_string(desc, 60), None);
}
println!();
}
Ok(())
}
async fn delete_workspace(workspace: String) -> Result<()> {
let db_path = get_database_path()?;
let db = Database::new(&db_path)?;
let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
.ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
let workspace_id = workspace_obj
.id
.ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
let projects = WorkspaceQueries::list_projects(&db.connection, workspace_id)?;
if !projects.is_empty() {
CliFormatter::print_warning(&format!(
"Cannot delete workspace '{}' - it contains {} project(s). Remove projects first.",
workspace,
projects.len()
));
return Ok(());
}
if WorkspaceQueries::delete(&db.connection, workspace_id)? {
CliFormatter::print_success(&format!("Deleted workspace '{}'", workspace));
} else {
CliFormatter::print_error(&format!("Failed to delete workspace '{}'", workspace));
}
Ok(())
}
async fn handle_calendar_action(action: CalendarAction) -> Result<()> {
match action {
CalendarAction::Add {
name,
start,
end,
event_type,
project,
description,
} => add_calendar_event(name, start, end, event_type, project, description).await,
CalendarAction::List { from, to, project } => list_calendar_events(from, to, project).await,
CalendarAction::Delete { id } => delete_calendar_event(id).await,
}
}
async fn add_calendar_event(
_name: String,
_start: String,
_end: Option<String>,
_event_type: Option<String>,
_project: Option<String>,
_description: Option<String>,
) -> Result<()> {
println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
Ok(())
}
async fn list_calendar_events(
_from: Option<String>,
_to: Option<String>,
_project: Option<String>,
) -> Result<()> {
println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
Ok(())
}
async fn delete_calendar_event(_id: i64) -> Result<()> {
println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
Ok(())
}
async fn handle_issue_action(action: IssueAction) -> Result<()> {
match action {
IssueAction::Sync {
project,
tracker_type,
} => sync_issues(project, tracker_type).await,
IssueAction::List { project, status } => list_issues(project, status).await,
IssueAction::Link {
session_id,
issue_id,
} => link_session_to_issue(session_id, issue_id).await,
}
}
async fn sync_issues(_project: String, _tracker_type: Option<String>) -> Result<()> {
println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
Ok(())
}
async fn list_issues(_project: String, _status: Option<String>) -> Result<()> {
println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
Ok(())
}
async fn link_session_to_issue(_session_id: i64, _issue_id: String) -> Result<()> {
println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
Ok(())
}
async fn handle_client_action(action: ClientAction) -> Result<()> {
match action {
ClientAction::Generate {
client,
from,
to,
projects,
format,
} => generate_client_report(client, from, to, projects, format).await,
ClientAction::List { client } => list_client_reports(client).await,
ClientAction::View { id } => view_client_report(id).await,
}
}
async fn generate_client_report(
_client: String,
_from: String,
_to: String,
_projects: Option<String>,
_format: Option<String>,
) -> Result<()> {
println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
Ok(())
}
async fn list_client_reports(_client: Option<String>) -> Result<()> {
println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
Ok(())
}
async fn view_client_report(_id: i64) -> Result<()> {
println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
Ok(())
}
#[allow(dead_code)]
fn should_quit(event: crossterm::event::Event) -> bool {
match event {
crossterm::event::Event::Key(key) if key.kind == crossterm::event::KeyEventKind::Press => {
matches!(
key.code,
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc
)
}
_ => false,
}
}
async fn init_project_with_db(
name: Option<String>,
canonical_path: Option<PathBuf>,
description: Option<String>,
conn: &rusqlite::Connection,
) -> Result<()> {
let canonical_path =
canonical_path.ok_or_else(|| anyhow::anyhow!("Canonical path required"))?;
let project_name = name.unwrap_or_else(|| detect_project_name(&canonical_path));
if let Some(existing) = ProjectQueries::find_by_path(conn, &canonical_path)? {
println!(
"\x1b[33m⚠ Project already exists:\x1b[0m {}",
existing.name
);
return Ok(());
}
let git_hash = if is_git_repository(&canonical_path) {
get_git_hash(&canonical_path)
} else {
None
};
let mut project = Project::new(project_name.clone(), canonical_path.clone())
.with_git_hash(git_hash.clone())
.with_description(description.clone());
let project_id = ProjectQueries::create(conn, &project)?;
project.id = Some(project_id);
let marker_path = canonical_path.join(".tempo");
if !marker_path.exists() {
std::fs::write(
&marker_path,
format!("# Tempo time tracking project\nname: {}\n", project_name),
)?;
}
CliFormatter::print_section_header("Project Initialized");
CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
CliFormatter::print_field("Path", &canonical_path.display().to_string(), Some("gray"));
if let Some(desc) = &description {
CliFormatter::print_field("Description", desc, Some("gray"));
}
if is_git_repository(&canonical_path) {
CliFormatter::print_field("Git", "Repository detected", Some("green"));
if let Some(hash) = &git_hash {
CliFormatter::print_field("Git Hash", &truncate_string(hash, 25), Some("gray"));
}
}
CliFormatter::print_field("ID", &project_id.to_string(), Some("gray"));
Ok(())
}
async fn show_pool_stats() -> Result<()> {
match get_pool_stats() {
Ok(stats) => {
CliFormatter::print_section_header("Database Pool Statistics");
CliFormatter::print_field(
"Total Created",
&stats.total_connections_created.to_string(),
Some("green"),
);
CliFormatter::print_field(
"Active",
&stats.active_connections.to_string(),
Some("yellow"),
);
CliFormatter::print_field(
"Available",
&stats.connections_in_pool.to_string(),
Some("white"),
);
CliFormatter::print_field(
"Total Requests",
&stats.connection_requests.to_string(),
Some("white"),
);
CliFormatter::print_field(
"Timeouts",
&stats.connection_timeouts.to_string(),
Some("red"),
);
}
Err(_) => {
CliFormatter::print_warning("Database pool not initialized or not available");
CliFormatter::print_info("Using direct database connections as fallback");
}
}
Ok(())
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct GitHubRelease {
tag_name: String,
name: String,
body: String,
published_at: String,
prerelease: bool,
}
async fn handle_update(check: bool, force: bool, verbose: bool) -> Result<()> {
let current_version = env!("CARGO_PKG_VERSION");
if verbose {
println!("🔍 Current version: v{}", current_version);
println!("📡 Checking for updates...");
} else {
println!("🔍 Checking for updates...");
}
let client = reqwest::Client::new();
let response = client
.get("https://api.github.com/repos/own-path/vibe/releases/latest")
.header("User-Agent", format!("tempo-cli/{}", current_version))
.send()
.await
.context("Failed to fetch release information")?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to fetch release information: HTTP {}",
response.status()
));
}
let release: GitHubRelease = response
.json()
.await
.context("Failed to parse release information")?;
let latest_version = release.tag_name.trim_start_matches('v');
if verbose {
println!("📦 Latest version: v{}", latest_version);
println!("📅 Released: {}", release.published_at);
}
let current_semver =
semver::Version::parse(current_version).context("Failed to parse current version")?;
let latest_semver =
semver::Version::parse(latest_version).context("Failed to parse latest version")?;
if current_semver >= latest_semver && !force {
println!(
"✅ You're already running the latest version (v{})",
current_version
);
if check {
return Ok(());
}
if !force {
println!("💡 Use --force to reinstall the current version");
return Ok(());
}
}
if check {
if current_semver < latest_semver {
println!(
"📦 Update available: v{} → v{}",
current_version, latest_version
);
println!("🔗 Run `tempo update` to install the latest version");
if verbose && !release.body.is_empty() {
println!("\n📝 Release Notes:");
println!("{}", release.body);
}
}
return Ok(());
}
if current_semver < latest_semver || force {
println!(
"⬇️ Updating tempo from v{} to v{}",
current_version, latest_version
);
if verbose {
println!("🔧 Installing via cargo...");
}
let mut cmd = Command::new("cargo");
cmd.args(["install", "tempo-cli", "--force"]);
if verbose {
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
} else {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
let status = cmd
.status()
.context("Failed to run cargo install command")?;
if status.success() {
println!("✅ Successfully updated tempo to v{}", latest_version);
println!("🎉 You can now use the latest features!");
if !release.body.is_empty() && verbose {
println!("\n📝 What's new in v{}:", latest_version);
println!("{}", release.body);
}
} else {
return Err(anyhow::anyhow!(
"Failed to install update. Try running manually: cargo install tempo-cli --force"
));
}
}
Ok(())
}
async fn handle_cat_command(
files: Vec<PathBuf>,
show_all: bool,
number_nonblank: bool,
show_ends: bool,
show_ends_only: bool,
number: bool,
squeeze_blank: bool,
show_tabs: bool,
show_tabs_only: bool,
show_nonprinting: bool,
version: bool,
) -> Result<()> {
if version {
println!("cat version {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if files.is_empty() {
return read_stdin(
show_all,
number_nonblank,
show_ends || show_ends_only,
number,
squeeze_blank,
show_tabs || show_tabs_only,
show_nonprinting,
)
.await;
}
for file_path in files {
if file_path == PathBuf::from("-") {
read_stdin(
show_all,
number_nonblank,
show_ends || show_ends_only,
number,
squeeze_blank,
show_tabs || show_tabs_only,
show_nonprinting,
)
.await?;
} else {
read_file(
&file_path,
show_all,
number_nonblank,
show_ends || show_ends_only,
number,
squeeze_blank,
show_tabs || show_tabs_only,
show_nonprinting,
)
.await?;
}
}
Ok(())
}
async fn read_stdin(
_show_all: bool,
number_nonblank: bool,
show_ends: bool,
number: bool,
squeeze_blank: bool,
show_tabs: bool,
show_nonprinting: bool,
) -> Result<()> {
use tokio::io::{self, AsyncBufReadExt, BufReader};
let stdin = io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
let mut line_number = 1;
let mut last_was_empty = false;
while let Some(line) = lines.next_line().await? {
if squeeze_blank && line.trim().is_empty() {
if last_was_empty {
continue;
}
last_was_empty = true;
} else {
last_was_empty = false;
}
let processed_line = process_line(&line, show_tabs, show_nonprinting, show_ends);
if number_nonblank && !line.trim().is_empty() {
println!("{:6}\t{}", line_number, processed_line);
line_number += 1;
} else if number && !number_nonblank {
println!("{:6}\t{}", line_number, processed_line);
line_number += 1;
} else {
println!("{}", processed_line);
}
}
Ok(())
}
async fn read_file(
file_path: &PathBuf,
_show_all: bool,
number_nonblank: bool,
show_ends: bool,
number: bool,
squeeze_blank: bool,
show_tabs: bool,
show_nonprinting: bool,
) -> Result<()> {
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
let file = File::open(file_path).await.context(format!(
"cat: {}: No such file or directory",
file_path.display()
))?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut line_number = 1;
let mut last_was_empty = false;
while let Some(line) = lines.next_line().await? {
if squeeze_blank && line.trim().is_empty() {
if last_was_empty {
continue;
}
last_was_empty = true;
} else {
last_was_empty = false;
}
let processed_line = process_line(&line, show_tabs, show_nonprinting, show_ends);
if number_nonblank && !line.trim().is_empty() {
println!("{:6}\t{}", line_number, processed_line);
line_number += 1;
} else if number && !number_nonblank {
println!("{:6}\t{}", line_number, processed_line);
line_number += 1;
} else {
println!("{}", processed_line);
}
}
Ok(())
}
fn process_line(line: &str, show_tabs: bool, show_nonprinting: bool, show_ends: bool) -> String {
let mut result = line.to_string();
if show_tabs {
result = result.replace('\t', "^I");
}
if show_nonprinting {
result = result
.chars()
.map(|c| match c {
'\t' if !show_tabs => c.to_string(),
'\n' => c.to_string(),
c if c.is_control() => {
if c as u8 <= 26 {
format!("^{}", (c as u8 + b'@') as char)
} else {
format!("^?")
}
}
c => c.to_string(),
})
.collect();
}
if show_ends {
result.push('$');
}
result
}