1use super::{
2 BranchAction, CalendarAction, Cli, ClientAction, Commands, ConfigAction, EstimateAction,
3 GoalAction, IssueAction, ProjectAction, SessionAction, TagAction, TemplateAction,
4 WorkspaceAction,
5};
6use crate::cli::formatter::{format_duration_clean, truncate_string, CliFormatter, StringFormat, ansi_color};
7use crate::cli::reports::ReportGenerator;
8use crate::db::advanced_queries::{
9 GitBranchQueries, GoalQueries, InsightQueries, TemplateQueries, TimeEstimateQueries,
10 WorkspaceQueries,
11};
12use crate::db::queries::{ProjectQueries, SessionEditQueries, SessionQueries, TagQueries};
13use crate::db::{get_connection, get_database_path, get_pool_stats, Database};
14use crate::models::{Goal, Project, ProjectTemplate, Tag, TimeEstimate, Workspace};
15use crate::utils::config::{load_config, save_config};
16use crate::utils::ipc::{get_socket_path, is_daemon_running, IpcClient, IpcMessage, IpcResponse};
17use crate::utils::paths::{
18 canonicalize_path, detect_project_name, get_git_hash, is_git_repository, validate_project_path,
19};
20use crate::utils::validation::{validate_project_description, validate_project_name};
21use anyhow::{Context, Result};
22use chrono::{TimeZone, Utc};
23use serde::Deserialize;
24use std::env;
25use std::path::PathBuf;
26use std::process::{Command, Stdio};
27
28use crate::ui::dashboard::Dashboard;
29use crate::ui::history::SessionHistoryBrowser;
30use crate::ui::timer::InteractiveTimer;
31use crossterm::{
32 execute,
33 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
34};
35use ratatui::{backend::CrosstermBackend, Terminal};
36use std::io;
37use tokio::runtime::Handle;
38
39pub async fn handle_command(cli: Cli) -> Result<()> {
40 match cli.command {
41 Commands::Start => start_daemon().await,
42
43 Commands::Stop => stop_daemon().await,
44
45 Commands::Restart => restart_daemon().await,
46
47 Commands::Status => status_daemon().await,
48
49 Commands::Init {
50 name,
51 path,
52 description,
53 } => init_project(name, path, description).await,
54
55 Commands::List { all, tag } => list_projects(all, tag).await,
56
57 Commands::Report {
58 project,
59 from,
60 to,
61 format,
62 group,
63 } => generate_report(project, from, to, format, group).await,
64
65 Commands::Project { action } => handle_project_action(action).await,
66
67 Commands::Session { action } => handle_session_action(action).await,
68
69 Commands::Tag { action } => handle_tag_action(action).await,
70
71 Commands::Config { action } => handle_config_action(action).await,
72
73 Commands::Dashboard => launch_dashboard().await,
74
75 Commands::Tui => launch_dashboard().await,
76
77 Commands::Timer => launch_timer().await,
78
79 Commands::History => launch_history().await,
80
81 Commands::Goal { action } => handle_goal_action(action).await,
82
83 Commands::Insights { period, project } => show_insights(period, project).await,
84
85 Commands::Summary { period, from } => show_summary(period, from).await,
86
87 Commands::Compare { projects, from, to } => compare_projects(projects, from, to).await,
88
89 Commands::PoolStats => show_pool_stats().await,
90
91 Commands::Estimate { action } => handle_estimate_action(action).await,
92
93 Commands::Branch { action } => handle_branch_action(action).await,
94
95 Commands::Template { action } => handle_template_action(action).await,
96
97 Commands::Workspace { action } => handle_workspace_action(action).await,
98
99 Commands::Calendar { action } => handle_calendar_action(action).await,
100
101 Commands::Issue { action } => handle_issue_action(action).await,
102
103 Commands::Client { action } => handle_client_action(action).await,
104
105 Commands::Update {
106 check,
107 force,
108 verbose,
109 } => handle_update(check, force, verbose).await,
110
111 Commands::Completions { shell } => {
112 Cli::generate_completions(shell);
113 Ok(())
114 }
115 }
116}
117
118async fn handle_project_action(action: ProjectAction) -> Result<()> {
119 match action {
120 ProjectAction::Archive { project } => archive_project(project).await,
121
122 ProjectAction::Unarchive { project } => unarchive_project(project).await,
123
124 ProjectAction::UpdatePath { project, path } => update_project_path(project, path).await,
125
126 ProjectAction::AddTag { project, tag } => add_tag_to_project(project, tag).await,
127
128 ProjectAction::RemoveTag { project, tag } => remove_tag_from_project(project, tag).await,
129 }
130}
131
132async fn handle_session_action(action: SessionAction) -> Result<()> {
133 match action {
134 SessionAction::Start { project, context } => start_session(project, context).await,
135
136 SessionAction::Stop => stop_session().await,
137
138 SessionAction::Pause => pause_session().await,
139
140 SessionAction::Resume => resume_session().await,
141
142 SessionAction::Current => current_session().await,
143
144 SessionAction::List { limit, project } => list_sessions(limit, project).await,
145
146 SessionAction::Edit {
147 id,
148 start,
149 end,
150 project,
151 reason,
152 } => edit_session(id, start, end, project, reason).await,
153
154 SessionAction::Delete { id, force } => delete_session(id, force).await,
155
156 SessionAction::Merge {
157 session_ids,
158 project,
159 notes,
160 } => merge_sessions(session_ids, project, notes).await,
161
162 SessionAction::Split {
163 session_id,
164 split_times,
165 notes,
166 } => split_session(session_id, split_times, notes).await,
167 }
168}
169
170async fn handle_tag_action(action: TagAction) -> Result<()> {
171 match action {
172 TagAction::Create {
173 name,
174 color,
175 description,
176 } => create_tag(name, color, description).await,
177
178 TagAction::List => list_tags().await,
179
180 TagAction::Delete { name } => delete_tag(name).await,
181 }
182}
183
184async fn handle_config_action(action: ConfigAction) -> Result<()> {
185 match action {
186 ConfigAction::Show => show_config().await,
187
188 ConfigAction::Set { key, value } => set_config(key, value).await,
189
190 ConfigAction::Get { key } => get_config(key).await,
191
192 ConfigAction::Reset => reset_config().await,
193 }
194}
195
196async fn start_daemon() -> Result<()> {
198 if is_daemon_running() {
199 println!("Daemon is already running");
200 return Ok(());
201 }
202
203 println!("Starting tempo daemon...");
204
205 let current_exe = std::env::current_exe()?;
206 let daemon_exe = current_exe.with_file_name("tempo-daemon");
207
208 if !daemon_exe.exists() {
209 return Err(anyhow::anyhow!(
210 "tempo-daemon executable not found at {:?}",
211 daemon_exe
212 ));
213 }
214
215 let mut cmd = Command::new(&daemon_exe);
216 cmd.stdout(Stdio::null())
217 .stderr(Stdio::null())
218 .stdin(Stdio::null());
219
220 #[cfg(unix)]
221 {
222 use std::os::unix::process::CommandExt;
223 cmd.process_group(0);
224 }
225
226 let child = cmd.spawn()?;
227
228 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
230
231 if is_daemon_running() {
232 println!("Daemon started successfully (PID: {})", child.id());
233 Ok(())
234 } else {
235 Err(anyhow::anyhow!("Failed to start daemon"))
236 }
237}
238
239async fn stop_daemon() -> Result<()> {
240 if !is_daemon_running() {
241 println!("Daemon is not running");
242 return Ok(());
243 }
244
245 println!("Stopping tempo daemon...");
246
247 if let Ok(socket_path) = get_socket_path() {
249 if let Ok(mut client) = IpcClient::connect(&socket_path).await {
250 match client.send_message(&IpcMessage::Shutdown).await {
251 Ok(_) => {
252 println!("Daemon stopped successfully");
253 return Ok(());
254 }
255 Err(e) => {
256 eprintln!("Failed to send shutdown message: {}", e);
257 }
258 }
259 }
260 }
261
262 if let Ok(Some(pid)) = crate::utils::ipc::read_pid_file() {
264 #[cfg(unix)]
265 {
266 let result = Command::new("kill").arg(pid.to_string()).output();
267
268 match result {
269 Ok(_) => println!("Daemon stopped via kill signal"),
270 Err(e) => eprintln!("Failed to kill daemon: {}", e),
271 }
272 }
273
274 #[cfg(windows)]
275 {
276 let result = Command::new("taskkill")
277 .args(&["/PID", &pid.to_string(), "/F"])
278 .output();
279
280 match result {
281 Ok(_) => println!("Daemon stopped via taskkill"),
282 Err(e) => eprintln!("Failed to kill daemon: {}", e),
283 }
284 }
285 }
286
287 Ok(())
288}
289
290async fn restart_daemon() -> Result<()> {
291 println!("Restarting tempo daemon...");
292 stop_daemon().await?;
293 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
294 start_daemon().await
295}
296
297async fn status_daemon() -> Result<()> {
298 if !is_daemon_running() {
299 print_daemon_not_running();
300 return Ok(());
301 }
302
303 if let Ok(socket_path) = get_socket_path() {
304 match IpcClient::connect(&socket_path).await {
305 Ok(mut client) => {
306 match client.send_message(&IpcMessage::GetStatus).await {
307 Ok(IpcResponse::Status {
308 daemon_running: _,
309 active_session,
310 uptime,
311 }) => {
312 print_daemon_status(uptime, active_session.as_ref());
313 }
314 Ok(IpcResponse::Pong) => {
315 print_daemon_status(0, None); }
317 Ok(other) => {
318 println!("Daemon is running (unexpected response: {:?})", other);
319 }
320 Err(e) => {
321 println!("Daemon is running but not responding: {}", e);
322 }
323 }
324 }
325 Err(e) => {
326 println!("Daemon appears to be running but cannot connect: {}", e);
327 }
328 }
329 } else {
330 println!("Cannot determine socket path");
331 }
332
333 Ok(())
334}
335
336async fn start_session(project: Option<String>, context: Option<String>) -> Result<()> {
338 if !is_daemon_running() {
339 println!("Daemon is not running. Start it with 'tempo start'");
340 return Ok(());
341 }
342
343 let project_path = if let Some(proj) = project {
344 PathBuf::from(proj)
345 } else {
346 env::current_dir()?
347 };
348
349 let context = context.unwrap_or_else(|| "manual".to_string());
350
351 let socket_path = get_socket_path()?;
352 let mut client = IpcClient::connect(&socket_path).await?;
353
354 let message = IpcMessage::StartSession {
355 project_path: Some(project_path.clone()),
356 context,
357 };
358
359 match client.send_message(&message).await {
360 Ok(IpcResponse::Ok) => {
361 println!("Session started for project at {:?}", project_path);
362 }
363 Ok(IpcResponse::Error(message)) => {
364 println!("Failed to start session: {}", message);
365 }
366 Ok(other) => {
367 println!("Unexpected response: {:?}", other);
368 }
369 Err(e) => {
370 println!("Failed to communicate with daemon: {}", e);
371 }
372 }
373
374 Ok(())
375}
376
377async fn stop_session() -> Result<()> {
378 if !is_daemon_running() {
379 println!("Daemon is not running");
380 return Ok(());
381 }
382
383 let socket_path = get_socket_path()?;
384 let mut client = IpcClient::connect(&socket_path).await?;
385
386 match client.send_message(&IpcMessage::StopSession).await {
387 Ok(IpcResponse::Ok) => {
388 println!("Session stopped");
389 }
390 Ok(IpcResponse::Error(message)) => {
391 println!("Failed to stop session: {}", message);
392 }
393 Ok(other) => {
394 println!("Unexpected response: {:?}", other);
395 }
396 Err(e) => {
397 println!("Failed to communicate with daemon: {}", e);
398 }
399 }
400
401 Ok(())
402}
403
404async fn pause_session() -> Result<()> {
405 if !is_daemon_running() {
406 println!("Daemon is not running");
407 return Ok(());
408 }
409
410 let socket_path = get_socket_path()?;
411 let mut client = IpcClient::connect(&socket_path).await?;
412
413 match client.send_message(&IpcMessage::PauseSession).await {
414 Ok(IpcResponse::Ok) => {
415 println!("Session paused");
416 }
417 Ok(IpcResponse::Error(message)) => {
418 println!("Failed to pause session: {}", message);
419 }
420 Ok(other) => {
421 println!("Unexpected response: {:?}", other);
422 }
423 Err(e) => {
424 println!("Failed to communicate with daemon: {}", e);
425 }
426 }
427
428 Ok(())
429}
430
431async fn resume_session() -> Result<()> {
432 if !is_daemon_running() {
433 println!("Daemon is not running");
434 return Ok(());
435 }
436
437 let socket_path = get_socket_path()?;
438 let mut client = IpcClient::connect(&socket_path).await?;
439
440 match client.send_message(&IpcMessage::ResumeSession).await {
441 Ok(IpcResponse::Ok) => {
442 println!("Session resumed");
443 }
444 Ok(IpcResponse::Error(message)) => {
445 println!("Failed to resume session: {}", message);
446 }
447 Ok(other) => {
448 println!("Unexpected response: {:?}", other);
449 }
450 Err(e) => {
451 println!("Failed to communicate with daemon: {}", e);
452 }
453 }
454
455 Ok(())
456}
457
458async fn current_session() -> Result<()> {
459 if !is_daemon_running() {
460 print_daemon_not_running();
461 return Ok(());
462 }
463
464 let socket_path = get_socket_path()?;
465 let mut client = IpcClient::connect(&socket_path).await?;
466
467 match client.send_message(&IpcMessage::GetActiveSession).await {
468 Ok(IpcResponse::SessionInfo(session)) => {
469 print_formatted_session(&session)?;
470 }
471 Ok(IpcResponse::Error(message)) => {
472 print_no_active_session(&message);
473 }
474 Ok(other) => {
475 println!("Unexpected response: {:?}", other);
476 }
477 Err(e) => {
478 println!("Failed to communicate with daemon: {}", e);
479 }
480 }
481
482 Ok(())
483}
484
485async fn generate_report(
487 project: Option<String>,
488 from: Option<String>,
489 to: Option<String>,
490 format: Option<String>,
491 group: Option<String>,
492) -> Result<()> {
493 println!("Generating time report...");
494
495 let generator = ReportGenerator::new()?;
496 let report = generator.generate_report(project, from, to, group)?;
497
498 match format.as_deref() {
499 Some("csv") => {
500 let output_path = PathBuf::from("tempo-report.csv");
501 generator.export_csv(&report, &output_path)?;
502 println!("Report exported to: {:?}", output_path);
503 }
504 Some("json") => {
505 let output_path = PathBuf::from("tempo-report.json");
506 generator.export_json(&report, &output_path)?;
507 println!("Report exported to: {:?}", output_path);
508 }
509 _ => {
510 print_formatted_report(&report)?;
512 }
513 }
514
515 Ok(())
516}
517
518fn print_formatted_session(session: &crate::utils::ipc::SessionInfo) -> Result<()> {
520 CliFormatter::print_section_header("Current Session");
521 CliFormatter::print_status("Active", true);
522 CliFormatter::print_field_bold("Project", &session.project_name, Some("yellow"));
523 CliFormatter::print_field_bold(
524 "Duration",
525 &format_duration_clean(session.duration),
526 Some("green"),
527 );
528 CliFormatter::print_field(
529 "Started",
530 &session.start_time.format("%H:%M:%S").to_string(),
531 None,
532 );
533 CliFormatter::print_field(
534 "Context",
535 &session.context,
536 Some(get_context_color(&session.context)),
537 );
538 CliFormatter::print_field(
539 "Path",
540 &session.project_path.to_string_lossy(),
541 Some("gray"),
542 );
543 Ok(())
544}
545
546fn get_context_color(context: &str) -> &str {
547 match context {
548 "terminal" => "cyan",
549 "ide" => "magenta",
550 "linked" => "yellow",
551 "manual" => "blue",
552 _ => "white",
553 }
554}
555
556fn print_formatted_report(report: &crate::cli::reports::TimeReport) -> Result<()> {
557 CliFormatter::print_section_header("Time Report");
558
559 for (project_name, project_summary) in &report.projects {
560 CliFormatter::print_project_entry(
561 project_name,
562 &format_duration_clean(project_summary.total_duration),
563 );
564
565 for (context, duration) in &project_summary.contexts {
566 CliFormatter::print_context_entry(context, &format_duration_clean(*duration));
567 }
568 println!(); }
570
571 CliFormatter::print_summary("Total Time", &format_duration_clean(report.total_duration));
572 Ok(())
573}
574
575fn print_daemon_not_running() {
579 CliFormatter::print_section_header("Daemon Status");
580 CliFormatter::print_status("Offline", false);
581 CliFormatter::print_warning("Daemon is not running.");
582 CliFormatter::print_info("Start it with: tempo start");
583}
584
585fn print_no_active_session(message: &str) {
586 CliFormatter::print_section_header("Current Session");
587 CliFormatter::print_status("Idle", false);
588 CliFormatter::print_empty_state(message);
589 CliFormatter::print_info("Start tracking: tempo session start");
590}
591
592fn print_daemon_status(uptime: u64, active_session: Option<&crate::utils::ipc::SessionInfo>) {
593 CliFormatter::print_section_header("Daemon Status");
594 CliFormatter::print_status("Online", true);
595 CliFormatter::print_field("Uptime", &format_duration_clean(uptime as i64), None);
596
597 if let Some(session) = active_session {
598 println!();
599 CliFormatter::print_field_bold("Active Session", "", None);
600 CliFormatter::print_field(" Project", &session.project_name, Some("yellow"));
601 CliFormatter::print_field(
602 " Duration",
603 &format_duration_clean(session.duration),
604 Some("green"),
605 );
606 CliFormatter::print_field(
607 " Context",
608 &session.context,
609 Some(get_context_color(&session.context)),
610 );
611 } else {
612 CliFormatter::print_field("Session", "No active session", Some("yellow"));
613 }
614}
615
616async fn init_project(
618 name: Option<String>,
619 path: Option<PathBuf>,
620 description: Option<String>,
621) -> Result<()> {
622 let validated_name = if let Some(n) = name.as_ref() {
624 Some(validate_project_name(n).with_context(|| format!("Invalid project name '{}'", n))?)
625 } else {
626 None
627 };
628
629 let validated_description = if let Some(d) = description.as_ref() {
630 Some(validate_project_description(d).with_context(|| "Invalid project description")?)
631 } else {
632 None
633 };
634
635 let project_path =
636 path.unwrap_or_else(|| env::current_dir().expect("Failed to get current directory"));
637
638 let canonical_path = validate_project_path(&project_path)
640 .with_context(|| format!("Invalid project path: {}", project_path.display()))?;
641
642 let project_name = validated_name.clone().unwrap_or_else(|| {
643 let detected = detect_project_name(&canonical_path);
644 validate_project_name(&detected).unwrap_or_else(|_| "project".to_string())
645 });
646
647 let conn = match get_connection().await {
649 Ok(conn) => conn,
650 Err(_) => {
651 let db_path = get_database_path()?;
653 let db = Database::new(&db_path)?;
654 return init_project_with_db(
655 validated_name,
656 Some(canonical_path),
657 validated_description,
658 &db.connection,
659 )
660 .await;
661 }
662 };
663
664 if let Some(existing) = ProjectQueries::find_by_path(conn.connection(), &canonical_path)? {
666 eprintln!(
667 "\x1b[33m! Warning:\x1b[0m A project named '{}' already exists at this path.",
668 existing.name
669 );
670 eprintln!("Use 'tempo list' to see all projects or choose a different location.");
671 return Ok(());
672 }
673
674 init_project_with_db(
676 Some(project_name.clone()),
677 Some(canonical_path.clone()),
678 validated_description,
679 conn.connection(),
680 )
681 .await?;
682
683 println!(
684 "\x1b[32m+ Success:\x1b[0m Project '{}' initialized at {}",
685 project_name,
686 canonical_path.display()
687 );
688 println!("Start tracking time with: \x1b[36mtempo start\x1b[0m");
689
690 Ok(())
691}
692
693async fn list_projects(include_archived: bool, tag_filter: Option<String>) -> Result<()> {
694 let db_path = get_database_path()?;
696 let db = Database::new(&db_path)?;
697
698 let projects = ProjectQueries::list_all(&db.connection, include_archived)?;
700
701 if projects.is_empty() {
702 CliFormatter::print_section_header("No Projects");
703 CliFormatter::print_empty_state("No projects found.");
704 println!();
705 CliFormatter::print_info("Create a project: tempo init [project-name]");
706 return Ok(());
707 }
708
709 let filtered_projects = if let Some(_tag) = tag_filter {
711 projects
713 } else {
714 projects
715 };
716
717 CliFormatter::print_section_header("Projects");
718
719 for project in &filtered_projects {
720 let status_icon = if project.is_archived { "[A]" } else { "[P]" };
721 let git_indicator = if project.git_hash.is_some() {
722 " (git)"
723 } else {
724 ""
725 };
726
727 let project_name = format!("{} {}{}", status_icon, project.name, git_indicator);
728 println!(
729 " {}",
730 if project.is_archived {
731 project_name.dimmed()
732 } else {
733 project_name
734 }
735 );
736
737 if let Some(description) = &project.description {
738 println!(" {}", truncate_string(description, 60).dimmed());
739 }
740
741 let path_display = project.path.to_string_lossy();
742 let home_dir = dirs::home_dir();
743 let display_path = if let Some(home) = home_dir {
744 if let Ok(stripped) = project.path.strip_prefix(&home) {
745 format!("~/{}", stripped.display())
746 } else {
747 path_display.to_string()
748 }
749 } else {
750 path_display.to_string()
751 };
752 println!(" {}", truncate_string(&display_path, 60).dimmed());
753 println!();
754 }
755
756 println!(
757 " {}: {}",
758 "Total".dimmed(),
759 format!("{} projects", filtered_projects.len())
760 );
761 if include_archived {
762 let active_count = filtered_projects.iter().filter(|p| !p.is_archived).count();
763 let archived_count = filtered_projects.iter().filter(|p| p.is_archived).count();
764 println!(
765 " {}: {} {}: {}",
766 "Active".dimmed(),
767 active_count,
768 "Archived".dimmed(),
769 archived_count
770 );
771 }
772
773 Ok(())
774}
775
776async fn create_tag(
778 name: String,
779 color: Option<String>,
780 description: Option<String>,
781) -> Result<()> {
782 let db_path = get_database_path()?;
784 let db = Database::new(&db_path)?;
785
786 let mut tag = Tag::new(name);
788 if let Some(c) = color {
789 tag = tag.with_color(c);
790 }
791 if let Some(d) = description {
792 tag = tag.with_description(d);
793 }
794
795 tag.validate()?;
797
798 let existing_tags = TagQueries::list_all(&db.connection)?;
800 if existing_tags.iter().any(|t| t.name == tag.name) {
801 println!("\x1b[33m⚠ Tag already exists:\x1b[0m {}", tag.name);
802 return Ok(());
803 }
804
805 let tag_id = TagQueries::create(&db.connection, &tag)?;
807
808 CliFormatter::print_section_header("Tag Created");
809 CliFormatter::print_field_bold("Name", &tag.name, Some("yellow"));
810 if let Some(color_val) = &tag.color {
811 CliFormatter::print_field("Color", color_val, None);
812 }
813 if let Some(desc) = &tag.description {
814 CliFormatter::print_field("Description", desc, Some("gray"));
815 }
816 CliFormatter::print_field("ID", &tag_id.to_string(), Some("gray"));
817 CliFormatter::print_success("Tag created successfully");
818
819 Ok(())
820}
821
822async fn list_tags() -> Result<()> {
823 let db_path = get_database_path()?;
825 let db = Database::new(&db_path)?;
826
827 let tags = TagQueries::list_all(&db.connection)?;
829
830 if tags.is_empty() {
831 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
832 println!("\x1b[36m│\x1b[0m \x1b[1;37mNo Tags\x1b[0m \x1b[36m│\x1b[0m");
833 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
834 println!("\x1b[36m│\x1b[0m No tags found. \x1b[36m│\x1b[0m");
835 println!("\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m");
836 println!("\x1b[36m│\x1b[0m \x1b[37mCreate a tag:\x1b[0m \x1b[36m│\x1b[0m");
837 println!("\x1b[36m│\x1b[0m \x1b[96mtempo tag create <name>\x1b[0m \x1b[36m│\x1b[0m");
838 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
839 return Ok(());
840 }
841
842 CliFormatter::print_section_header("Tags");
843
844 for tag in &tags {
845 let color_indicator = if let Some(color) = &tag.color {
846 format!(" ({})", color)
847 } else {
848 String::new()
849 };
850
851 let tag_name = format!("🏷️ {}{}", tag.name, color_indicator);
852 println!(" {}", ansi_color("yellow", &tag_name, true));
853
854 if let Some(description) = &tag.description {
855 println!(" {}", description.dimmed());
856 }
857 println!();
858 }
859
860 println!(" {}: {}", "Total".dimmed(), format!("{} tags", tags.len()));
861
862 Ok(())
863}
864
865async fn delete_tag(name: String) -> Result<()> {
866 let db_path = get_database_path()?;
868 let db = Database::new(&db_path)?;
869
870 if TagQueries::find_by_name(&db.connection, &name)?.is_none() {
872 println!("\x1b[31m✗ Tag '{}' not found\x1b[0m", name);
873 return Ok(());
874 }
875
876 let deleted = TagQueries::delete_by_name(&db.connection, &name)?;
878
879 if deleted {
880 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
881 println!("\x1b[36m│\x1b[0m \x1b[1;37mTag Deleted\x1b[0m \x1b[36m│\x1b[0m");
882 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
883 println!(
884 "\x1b[36m│\x1b[0m Name: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
885 truncate_string(&name, 27)
886 );
887 println!(
888 "\x1b[36m│\x1b[0m Status: \x1b[32mDeleted\x1b[0m \x1b[36m│\x1b[0m"
889 );
890 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
891 println!("\x1b[36m│\x1b[0m \x1b[32m✓ Tag deleted successfully\x1b[0m \x1b[36m│\x1b[0m");
892 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
893 } else {
894 println!("\x1b[31m✗ Failed to delete tag '{}'\x1b[0m", name);
895 }
896
897 Ok(())
898}
899
900async fn show_config() -> Result<()> {
902 let config = load_config()?;
903
904 CliFormatter::print_section_header("Configuration");
905 CliFormatter::print_field("idle_timeout_minutes", &config.idle_timeout_minutes.to_string(), Some("yellow"));
906 CliFormatter::print_field("auto_pause_enabled", &config.auto_pause_enabled.to_string(), Some("yellow"));
907 CliFormatter::print_field("default_context", &config.default_context, Some("yellow"));
908 CliFormatter::print_field("max_session_hours", &config.max_session_hours.to_string(), Some("yellow"));
909 CliFormatter::print_field("backup_enabled", &config.backup_enabled.to_string(), Some("yellow"));
910 CliFormatter::print_field("log_level", &config.log_level, Some("yellow"));
911
912 if !config.custom_settings.is_empty() {
913 println!();
914 CliFormatter::print_field_bold("Custom Settings", "", None);
915 for (key, value) in &config.custom_settings {
916 CliFormatter::print_field(key, value, Some("yellow"));
917 }
918 }
919
920 Ok(())
921}
922
923async fn get_config(key: String) -> Result<()> {
924 let config = load_config()?;
925
926 let value = match key.as_str() {
927 "idle_timeout_minutes" => Some(config.idle_timeout_minutes.to_string()),
928 "auto_pause_enabled" => Some(config.auto_pause_enabled.to_string()),
929 "default_context" => Some(config.default_context),
930 "max_session_hours" => Some(config.max_session_hours.to_string()),
931 "backup_enabled" => Some(config.backup_enabled.to_string()),
932 "log_level" => Some(config.log_level),
933 _ => config.custom_settings.get(&key).cloned(),
934 };
935
936 match value {
937 Some(val) => {
938 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
939 println!("\x1b[36m│\x1b[0m \x1b[1;37mConfiguration Value\x1b[0m \x1b[36m│\x1b[0m");
940 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
941 println!(
942 "\x1b[36m│\x1b[0m {:<20} \x1b[33m{:<16}\x1b[0m \x1b[36m│\x1b[0m",
943 truncate_string(&key, 20),
944 truncate_string(&val, 16)
945 );
946 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
947 }
948 None => {
949 println!("\x1b[31m✗ Configuration key not found:\x1b[0m {}", key);
950 }
951 }
952
953 Ok(())
954}
955
956async fn set_config(key: String, value: String) -> Result<()> {
957 let mut config = load_config()?;
958
959 let display_value = value.clone(); match key.as_str() {
962 "idle_timeout_minutes" => {
963 config.idle_timeout_minutes = value.parse()?;
964 }
965 "auto_pause_enabled" => {
966 config.auto_pause_enabled = value.parse()?;
967 }
968 "default_context" => {
969 config.default_context = value;
970 }
971 "max_session_hours" => {
972 config.max_session_hours = value.parse()?;
973 }
974 "backup_enabled" => {
975 config.backup_enabled = value.parse()?;
976 }
977 "log_level" => {
978 config.log_level = value;
979 }
980 _ => {
981 config.set_custom(key.clone(), value);
982 }
983 }
984
985 config.validate()?;
986 save_config(&config)?;
987
988 CliFormatter::print_section_header("Configuration Updated");
989 CliFormatter::print_field(&key, &display_value, Some("green"));
990 CliFormatter::print_success("Configuration saved successfully");
991
992 Ok(())
993}
994
995async fn reset_config() -> Result<()> {
996 let default_config = crate::models::Config::default();
997 save_config(&default_config)?;
998
999 CliFormatter::print_section_header("Configuration Reset");
1000 CliFormatter::print_success("Configuration reset to defaults");
1001 CliFormatter::print_info("View current config: tempo config show");
1002
1003 Ok(())
1004}
1005
1006async fn list_sessions(limit: Option<usize>, project_filter: Option<String>) -> Result<()> {
1008 let db_path = get_database_path()?;
1010 let db = Database::new(&db_path)?;
1011
1012 let session_limit = limit.unwrap_or(10);
1013
1014 let project_id = if let Some(project_name) = &project_filter {
1016 match ProjectQueries::find_by_name(&db.connection, project_name)? {
1017 Some(project) => Some(project.id.unwrap()),
1018 None => {
1019 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1020 return Ok(());
1021 }
1022 }
1023 } else {
1024 None
1025 };
1026
1027 let sessions = SessionQueries::list_with_filter(
1028 &db.connection,
1029 project_id,
1030 None,
1031 None,
1032 Some(session_limit),
1033 )?;
1034
1035 if sessions.is_empty() {
1036 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1037 println!("\x1b[36m│\x1b[0m \x1b[1;37mNo Sessions\x1b[0m \x1b[36m│\x1b[0m");
1038 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1039 println!("\x1b[36m│\x1b[0m No sessions found. \x1b[36m│\x1b[0m");
1040 println!("\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m");
1041 println!("\x1b[36m│\x1b[0m \x1b[37mStart a session:\x1b[0m \x1b[36m│\x1b[0m");
1042 println!("\x1b[36m│\x1b[0m \x1b[96mtempo session start\x1b[0m \x1b[36m│\x1b[0m");
1043 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1044 return Ok(());
1045 }
1046
1047 let filtered_sessions = if let Some(_project) = project_filter {
1049 sessions
1051 } else {
1052 sessions
1053 };
1054
1055 CliFormatter::print_section_header("Recent Sessions");
1056
1057 for session in &filtered_sessions {
1058 let status_icon = if session.end_time.is_some() { "✅" } else { "🔄" };
1059 let duration = if let Some(end) = session.end_time {
1060 (end - session.start_time).num_seconds() - session.paused_duration.num_seconds()
1061 } else {
1062 (Utc::now() - session.start_time).num_seconds() - session.paused_duration.num_seconds()
1063 };
1064
1065 let context_color = match session.context {
1066 crate::models::SessionContext::Terminal => "cyan",
1067 crate::models::SessionContext::IDE => "magenta",
1068 crate::models::SessionContext::Linked => "yellow",
1069 crate::models::SessionContext::Manual => "blue",
1070 };
1071
1072 println!(" {} {}", status_icon, ansi_color("white", &format!("Session {}", session.id.unwrap_or(0)), true));
1073 CliFormatter::print_field(" Duration", &format_duration_clean(duration), Some("green"));
1074 CliFormatter::print_field(" Context", &session.context.to_string(), Some(context_color));
1075 CliFormatter::print_field(" Started", &session.start_time.format("%Y-%m-%d %H:%M:%S").to_string(), None);
1076 println!();
1077 }
1078
1079 println!(" {}: {}", "Showing".dimmed(), format!("{} recent sessions", filtered_sessions.len()));
1080
1081 Ok(())
1082}
1083
1084async fn edit_session(
1085 id: i64,
1086 start: Option<String>,
1087 end: Option<String>,
1088 project: Option<String>,
1089 reason: Option<String>,
1090) -> Result<()> {
1091 let db_path = get_database_path()?;
1093 let db = Database::new(&db_path)?;
1094
1095 let session = SessionQueries::find_by_id(&db.connection, id)?;
1097 let session = match session {
1098 Some(s) => s,
1099 None => {
1100 println!("\x1b[31m✗ Session {} not found\x1b[0m", id);
1101 return Ok(());
1102 }
1103 };
1104
1105 let original_start = session.start_time;
1106 let original_end = session.end_time;
1107
1108 let mut new_start = original_start;
1110 let mut new_end = original_end;
1111 let mut new_project_id = session.project_id;
1112
1113 if let Some(start_str) = &start {
1115 new_start = match chrono::DateTime::parse_from_rfc3339(start_str) {
1116 Ok(dt) => dt.with_timezone(&chrono::Utc),
1117 Err(_) => match chrono::NaiveDateTime::parse_from_str(start_str, "%Y-%m-%d %H:%M:%S") {
1118 Ok(dt) => chrono::Utc.from_utc_datetime(&dt),
1119 Err(_) => {
1120 return Err(anyhow::anyhow!(
1121 "Invalid start time format. Use RFC3339 or 'YYYY-MM-DD HH:MM:SS'"
1122 ))
1123 }
1124 },
1125 };
1126 }
1127
1128 if let Some(end_str) = &end {
1130 if end_str.to_lowercase() == "null" || end_str.to_lowercase() == "none" {
1131 new_end = None;
1132 } else {
1133 new_end = Some(match chrono::DateTime::parse_from_rfc3339(end_str) {
1134 Ok(dt) => dt.with_timezone(&chrono::Utc),
1135 Err(_) => {
1136 match chrono::NaiveDateTime::parse_from_str(end_str, "%Y-%m-%d %H:%M:%S") {
1137 Ok(dt) => chrono::Utc.from_utc_datetime(&dt),
1138 Err(_) => {
1139 return Err(anyhow::anyhow!(
1140 "Invalid end time format. Use RFC3339 or 'YYYY-MM-DD HH:MM:SS'"
1141 ))
1142 }
1143 }
1144 }
1145 });
1146 }
1147 }
1148
1149 if let Some(project_name) = &project {
1151 if let Some(proj) = ProjectQueries::find_by_name(&db.connection, project_name)? {
1152 new_project_id = proj.id.unwrap();
1153 } else {
1154 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1155 return Ok(());
1156 }
1157 }
1158
1159 if new_start >= new_end.unwrap_or(chrono::Utc::now()) {
1161 println!("\x1b[31m✗ Start time must be before end time\x1b[0m");
1162 return Ok(());
1163 }
1164
1165 SessionEditQueries::create_edit_record(
1167 &db.connection,
1168 id,
1169 original_start,
1170 original_end,
1171 new_start,
1172 new_end,
1173 reason.clone(),
1174 )?;
1175
1176 SessionQueries::update_session(
1178 &db.connection,
1179 id,
1180 if start.is_some() {
1181 Some(new_start)
1182 } else {
1183 None
1184 },
1185 if end.is_some() { Some(new_end) } else { None },
1186 if project.is_some() {
1187 Some(new_project_id)
1188 } else {
1189 None
1190 },
1191 None,
1192 )?;
1193
1194 CliFormatter::print_section_header("Session Updated");
1195 CliFormatter::print_field("Session", &id.to_string(), Some("white"));
1196
1197 if start.is_some() {
1198 CliFormatter::print_field("Start", &new_start.format("%Y-%m-%d %H:%M:%S").to_string(), Some("green"));
1199 }
1200
1201 if end.is_some() {
1202 let end_str = if let Some(e) = new_end {
1203 e.format("%Y-%m-%d %H:%M:%S").to_string()
1204 } else {
1205 "Ongoing".to_string()
1206 };
1207 CliFormatter::print_field("End", &end_str, Some("green"));
1208 }
1209
1210 if let Some(r) = &reason {
1211 CliFormatter::print_field("Reason", r, Some("gray"));
1212 }
1213
1214 CliFormatter::print_success("Session updated with audit trail");
1215
1216 Ok(())
1217}
1218
1219async fn delete_session(id: i64, force: bool) -> Result<()> {
1220 let db_path = get_database_path()?;
1222 let db = Database::new(&db_path)?;
1223
1224 let session = SessionQueries::find_by_id(&db.connection, id)?;
1226 let session = match session {
1227 Some(s) => s,
1228 None => {
1229 println!("\x1b[31m✗ Session {} not found\x1b[0m", id);
1230 return Ok(());
1231 }
1232 };
1233
1234 if session.end_time.is_none() && !force {
1236 println!("\x1b[33m⚠ Cannot delete active session without --force flag\x1b[0m");
1237 println!(" Use: tempo session delete {} --force", id);
1238 return Ok(());
1239 }
1240
1241 SessionQueries::delete_session(&db.connection, id)?;
1243
1244 CliFormatter::print_section_header("Session Deleted");
1245 CliFormatter::print_field("Session", &id.to_string(), Some("white"));
1246 CliFormatter::print_field("Status", "Deleted", Some("green"));
1247
1248 if session.end_time.is_none() {
1249 CliFormatter::print_field("Type", "Active session (forced)", Some("yellow"));
1250 } else {
1251 CliFormatter::print_field("Type", "Completed session", None);
1252 }
1253
1254 CliFormatter::print_success("Session and audit trail removed");
1255
1256 Ok(())
1257}
1258
1259async fn archive_project(project_name: String) -> Result<()> {
1261 let db_path = get_database_path()?;
1262 let db = Database::new(&db_path)?;
1263
1264 let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
1265 Some(p) => p,
1266 None => {
1267 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1268 return Ok(());
1269 }
1270 };
1271
1272 if project.is_archived {
1273 println!(
1274 "\x1b[33m⚠ Project '{}' is already archived\x1b[0m",
1275 project_name
1276 );
1277 return Ok(());
1278 }
1279
1280 let success = ProjectQueries::archive_project(&db.connection, project.id.unwrap())?;
1281
1282 if success {
1283 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1284 println!("\x1b[36m│\x1b[0m \x1b[1;37mProject Archived\x1b[0m \x1b[36m│\x1b[0m");
1285 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1286 println!(
1287 "\x1b[36m│\x1b[0m Name: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1288 truncate_string(&project_name, 27)
1289 );
1290 println!(
1291 "\x1b[36m│\x1b[0m Status: \x1b[90mArchived\x1b[0m \x1b[36m│\x1b[0m"
1292 );
1293 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1294 println!("\x1b[36m│\x1b[0m \x1b[32m✓ Project archived successfully\x1b[0m \x1b[36m│\x1b[0m");
1295 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1296 } else {
1297 println!(
1298 "\x1b[31m✗ Failed to archive project '{}'\x1b[0m",
1299 project_name
1300 );
1301 }
1302
1303 Ok(())
1304}
1305
1306async fn unarchive_project(project_name: String) -> Result<()> {
1307 let db_path = get_database_path()?;
1308 let db = Database::new(&db_path)?;
1309
1310 let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
1311 Some(p) => p,
1312 None => {
1313 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1314 return Ok(());
1315 }
1316 };
1317
1318 if !project.is_archived {
1319 println!(
1320 "\x1b[33m⚠ Project '{}' is not archived\x1b[0m",
1321 project_name
1322 );
1323 return Ok(());
1324 }
1325
1326 let success = ProjectQueries::unarchive_project(&db.connection, project.id.unwrap())?;
1327
1328 if success {
1329 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1330 println!("\x1b[36m│\x1b[0m \x1b[1;37mProject Unarchived\x1b[0m \x1b[36m│\x1b[0m");
1331 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1332 println!(
1333 "\x1b[36m│\x1b[0m Name: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1334 truncate_string(&project_name, 27)
1335 );
1336 println!(
1337 "\x1b[36m│\x1b[0m Status: \x1b[32mActive\x1b[0m \x1b[36m│\x1b[0m"
1338 );
1339 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1340 println!("\x1b[36m│\x1b[0m \x1b[32m✓ Project unarchived successfully\x1b[0m \x1b[36m│\x1b[0m");
1341 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1342 } else {
1343 println!(
1344 "\x1b[31m✗ Failed to unarchive project '{}'\x1b[0m",
1345 project_name
1346 );
1347 }
1348
1349 Ok(())
1350}
1351
1352async fn update_project_path(project_name: String, new_path: PathBuf) -> Result<()> {
1353 let db_path = get_database_path()?;
1354 let db = Database::new(&db_path)?;
1355
1356 let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
1357 Some(p) => p,
1358 None => {
1359 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1360 return Ok(());
1361 }
1362 };
1363
1364 let canonical_path = canonicalize_path(&new_path)?;
1365 let success =
1366 ProjectQueries::update_project_path(&db.connection, project.id.unwrap(), &canonical_path)?;
1367
1368 if success {
1369 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1370 println!("\x1b[36m│\x1b[0m \x1b[1;37mProject Path Updated\x1b[0m \x1b[36m│\x1b[0m");
1371 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1372 println!(
1373 "\x1b[36m│\x1b[0m Name: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1374 truncate_string(&project_name, 27)
1375 );
1376 println!(
1377 "\x1b[36m│\x1b[0m Old Path: \x1b[2;37m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1378 truncate_string(&project.path.to_string_lossy(), 27)
1379 );
1380 println!(
1381 "\x1b[36m│\x1b[0m New Path: \x1b[32m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1382 truncate_string(&canonical_path.to_string_lossy(), 27)
1383 );
1384 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1385 println!("\x1b[36m│\x1b[0m \x1b[32m✓ Path updated successfully\x1b[0m \x1b[36m│\x1b[0m");
1386 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1387 } else {
1388 println!(
1389 "\x1b[31m✗ Failed to update path for project '{}'\x1b[0m",
1390 project_name
1391 );
1392 }
1393
1394 Ok(())
1395}
1396
1397async fn add_tag_to_project(project_name: String, tag_name: String) -> Result<()> {
1398 println!("\x1b[33m⚠ Project-tag associations not yet implemented\x1b[0m");
1399 println!("Would add tag '{}' to project '{}'", tag_name, project_name);
1400 println!("This requires implementing project_tags table operations.");
1401 Ok(())
1402}
1403
1404async fn remove_tag_from_project(project_name: String, tag_name: String) -> Result<()> {
1405 println!("\x1b[33m⚠ Project-tag associations not yet implemented\x1b[0m");
1406 println!(
1407 "Would remove tag '{}' from project '{}'",
1408 tag_name, project_name
1409 );
1410 println!("This requires implementing project_tags table operations.");
1411 Ok(())
1412}
1413
1414#[allow(dead_code)]
1416async fn bulk_update_sessions_project(
1417 session_ids: Vec<i64>,
1418 new_project_name: String,
1419) -> Result<()> {
1420 let db_path = get_database_path()?;
1421 let db = Database::new(&db_path)?;
1422
1423 let project = match ProjectQueries::find_by_name(&db.connection, &new_project_name)? {
1425 Some(p) => p,
1426 None => {
1427 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", new_project_name);
1428 return Ok(());
1429 }
1430 };
1431
1432 let updated =
1433 SessionQueries::bulk_update_project(&db.connection, &session_ids, project.id.unwrap())?;
1434
1435 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1436 println!(
1437 "\x1b[36m│\x1b[0m \x1b[1;37mBulk Session Update\x1b[0m \x1b[36m│\x1b[0m"
1438 );
1439 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1440 println!(
1441 "\x1b[36m│\x1b[0m Sessions: \x1b[1;37m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1442 updated
1443 );
1444 println!(
1445 "\x1b[36m│\x1b[0m Project: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1446 truncate_string(&new_project_name, 27)
1447 );
1448 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1449 println!(
1450 "\x1b[36m│\x1b[0m \x1b[32m✓ {} sessions updated\x1b[0m {:<12} \x1b[36m│\x1b[0m",
1451 updated, ""
1452 );
1453 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1454
1455 Ok(())
1456}
1457
1458#[allow(dead_code)]
1459async fn bulk_delete_sessions(session_ids: Vec<i64>) -> Result<()> {
1460 let db_path = get_database_path()?;
1461 let db = Database::new(&db_path)?;
1462
1463 let deleted = SessionQueries::bulk_delete(&db.connection, &session_ids)?;
1464
1465 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1466 println!(
1467 "\x1b[36m│\x1b[0m \x1b[1;37mBulk Session Delete\x1b[0m \x1b[36m│\x1b[0m"
1468 );
1469 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1470 println!(
1471 "\x1b[36m│\x1b[0m Requested: \x1b[1;37m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
1472 session_ids.len()
1473 );
1474 println!(
1475 "\x1b[36m│\x1b[0m Deleted: \x1b[32m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
1476 deleted
1477 );
1478 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1479 println!(
1480 "\x1b[36m│\x1b[0m \x1b[32m✓ {} sessions deleted\x1b[0m {:<10} \x1b[36m│\x1b[0m",
1481 deleted, ""
1482 );
1483 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1484
1485 Ok(())
1486}
1487
1488async fn launch_dashboard() -> Result<()> {
1489 if !is_tty() {
1491 return show_dashboard_fallback().await;
1492 }
1493
1494 enable_raw_mode()
1496 .context("Failed to enable raw mode - terminal may not support interactive features")?;
1497 let mut stdout = io::stdout();
1498
1499 execute!(stdout, EnterAlternateScreen)
1500 .context("Failed to enter alternate screen - terminal may not support full-screen mode")?;
1501
1502 let backend = CrosstermBackend::new(stdout);
1503 let mut terminal = Terminal::new(backend).context("Failed to initialize terminal backend")?;
1504
1505 terminal.clear().context("Failed to clear terminal")?;
1507
1508 let result = async {
1510 let mut dashboard = Dashboard::new().await?;
1511 dashboard.run(&mut terminal).await
1512 };
1513
1514 let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
1515
1516 let cleanup_result = cleanup_terminal(&mut terminal);
1518
1519 if let Err(e) = cleanup_result {
1521 eprintln!("Warning: Failed to restore terminal: {}", e);
1522 }
1523
1524 result
1525}
1526
1527fn is_tty() -> bool {
1528 use std::os::unix::io::AsRawFd;
1529 unsafe { libc::isatty(std::io::stdin().as_raw_fd()) == 1 }
1530}
1531
1532async fn show_dashboard_fallback() -> Result<()> {
1533 println!("📊 Tempo Dashboard (Text Mode)");
1534 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1535 println!();
1536
1537 if is_daemon_running() {
1539 println!("🟢 Daemon Status: Running");
1540 } else {
1541 println!("🔴 Daemon Status: Offline");
1542 println!(" Start with: tempo start");
1543 println!();
1544 return Ok(());
1545 }
1546
1547 let socket_path = get_socket_path()?;
1549 if let Ok(mut client) = IpcClient::connect(&socket_path).await {
1550 match client.send_message(&IpcMessage::GetActiveSession).await {
1551 Ok(IpcResponse::ActiveSession(Some(session))) => {
1552 println!("⏱️ Active Session:");
1553 println!(" Started: {}", session.start_time.format("%H:%M:%S"));
1554 println!(
1555 " Duration: {}",
1556 format_duration_simple(
1557 (chrono::Utc::now().timestamp() - session.start_time.timestamp())
1558 - session.paused_duration.num_seconds()
1559 )
1560 );
1561 println!(" Context: {}", session.context);
1562 println!();
1563
1564 match client
1566 .send_message(&IpcMessage::GetProject(session.project_id))
1567 .await
1568 {
1569 Ok(IpcResponse::Project(Some(project))) => {
1570 println!("📁 Current Project: {}", project.name);
1571 println!(" Path: {}", project.path.display());
1572 println!();
1573 }
1574 _ => {
1575 println!("📁 Project: Unknown");
1576 println!();
1577 }
1578 }
1579 }
1580 _ => {
1581 println!("⏸️ No active session");
1582 println!(" Start tracking with: tempo session start");
1583 println!();
1584 }
1585 }
1586
1587 let today = chrono::Local::now().date_naive();
1589 match client.send_message(&IpcMessage::GetDailyStats(today)).await {
1590 Ok(IpcResponse::DailyStats {
1591 sessions_count,
1592 total_seconds,
1593 avg_seconds,
1594 }) => {
1595 println!("📈 Today's Summary:");
1596 println!(" Sessions: {}", sessions_count);
1597 println!(" Total time: {}", format_duration_simple(total_seconds));
1598 if sessions_count > 0 {
1599 println!(
1600 " Average session: {}",
1601 format_duration_simple(avg_seconds)
1602 );
1603 }
1604 let progress = (total_seconds as f64 / (8.0 * 3600.0)) * 100.0;
1605 println!(" Daily goal (8h): {:.1}%", progress);
1606 println!();
1607 }
1608 _ => {
1609 println!("📈 Today's Summary: No data available");
1610 println!();
1611 }
1612 }
1613 } else {
1614 println!("❌ Unable to connect to daemon");
1615 println!(" Try: tempo restart");
1616 println!();
1617 }
1618
1619 println!("💡 For interactive dashboard, run in a terminal:");
1620 println!(" • Terminal.app, iTerm2, or other terminal emulators");
1621 println!(" • SSH sessions with TTY allocation (ssh -t)");
1622 println!(" • Interactive shell environments");
1623
1624 Ok(())
1625}
1626
1627fn format_duration_simple(seconds: i64) -> String {
1628 let hours = seconds / 3600;
1629 let minutes = (seconds % 3600) / 60;
1630 let secs = seconds % 60;
1631
1632 if hours > 0 {
1633 format!("{}h {}m {}s", hours, minutes, secs)
1634 } else if minutes > 0 {
1635 format!("{}m {}s", minutes, secs)
1636 } else {
1637 format!("{}s", secs)
1638 }
1639}
1640
1641fn cleanup_terminal<B>(terminal: &mut Terminal<B>) -> Result<()>
1642where
1643 B: ratatui::backend::Backend + std::io::Write,
1644{
1645 disable_raw_mode().context("Failed to disable raw mode")?;
1647 execute!(terminal.backend_mut(), LeaveAlternateScreen)
1648 .context("Failed to leave alternate screen")?;
1649 terminal.show_cursor().context("Failed to show cursor")?;
1650 Ok(())
1651}
1652
1653async fn launch_timer() -> Result<()> {
1654 if !is_tty() {
1656 return Err(anyhow::anyhow!(
1657 "Interactive timer requires an interactive terminal (TTY).\n\
1658 \n\
1659 This command needs to run in a proper terminal environment.\n\
1660 Try running this command directly in your terminal application."
1661 ));
1662 }
1663
1664 enable_raw_mode().context("Failed to enable raw mode")?;
1666 let mut stdout = io::stdout();
1667 execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
1668 let backend = CrosstermBackend::new(stdout);
1669 let mut terminal = Terminal::new(backend).context("Failed to initialize terminal")?;
1670 terminal.clear().context("Failed to clear terminal")?;
1671
1672 let result = async {
1674 let mut timer = InteractiveTimer::new().await?;
1675 timer.run(&mut terminal).await
1676 };
1677
1678 let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
1679
1680 let cleanup_result = cleanup_terminal(&mut terminal);
1682 if let Err(e) = cleanup_result {
1683 eprintln!("Warning: Failed to restore terminal: {}", e);
1684 }
1685
1686 result
1687}
1688
1689async fn merge_sessions(
1690 session_ids_str: String,
1691 project_name: Option<String>,
1692 notes: Option<String>,
1693) -> Result<()> {
1694 let session_ids: Result<Vec<i64>, _> = session_ids_str
1696 .split(',')
1697 .map(|s| s.trim().parse::<i64>())
1698 .collect();
1699
1700 let session_ids = session_ids.map_err(|_| {
1701 anyhow::anyhow!("Invalid session IDs format. Use comma-separated numbers like '1,2,3'")
1702 })?;
1703
1704 if session_ids.len() < 2 {
1705 return Err(anyhow::anyhow!(
1706 "At least 2 sessions are required for merging"
1707 ));
1708 }
1709
1710 let mut target_project_id = None;
1712 if let Some(project) = project_name {
1713 let db_path = get_database_path()?;
1714 let db = Database::new(&db_path)?;
1715
1716 if let Ok(project_id) = project.parse::<i64>() {
1718 if ProjectQueries::find_by_id(&db.connection, project_id)?.is_some() {
1719 target_project_id = Some(project_id);
1720 }
1721 } else if let Some(proj) = ProjectQueries::find_by_name(&db.connection, &project)? {
1722 target_project_id = proj.id;
1723 }
1724
1725 if target_project_id.is_none() {
1726 return Err(anyhow::anyhow!("Project '{}' not found", project));
1727 }
1728 }
1729
1730 let db_path = get_database_path()?;
1732 let db = Database::new(&db_path)?;
1733
1734 let merged_id =
1735 SessionQueries::merge_sessions(&db.connection, &session_ids, target_project_id, notes)?;
1736
1737 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1738 println!("\x1b[36m│\x1b[0m \x1b[1;37mSession Merge Complete\x1b[0m \x1b[36m│\x1b[0m");
1739 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1740 println!(
1741 "\x1b[36m│\x1b[0m Merged sessions: \x1b[33m{:<22}\x1b[0m \x1b[36m│\x1b[0m",
1742 session_ids
1743 .iter()
1744 .map(|id| id.to_string())
1745 .collect::<Vec<_>>()
1746 .join(", ")
1747 );
1748 println!(
1749 "\x1b[36m│\x1b[0m New session ID: \x1b[32m{:<22}\x1b[0m \x1b[36m│\x1b[0m",
1750 merged_id
1751 );
1752 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1753 println!(
1754 "\x1b[36m│\x1b[0m \x1b[32m✓ Sessions successfully merged\x1b[0m \x1b[36m│\x1b[0m"
1755 );
1756 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1757
1758 Ok(())
1759}
1760
1761async fn split_session(
1762 session_id: i64,
1763 split_times_str: String,
1764 notes: Option<String>,
1765) -> Result<()> {
1766 let split_time_strings: Vec<&str> = split_times_str.split(',').map(|s| s.trim()).collect();
1768 let mut split_times = Vec::new();
1769
1770 for time_str in split_time_strings {
1771 let datetime = if time_str.contains(':') {
1773 let today = chrono::Local::now().date_naive();
1775 let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M")
1776 .or_else(|_| chrono::NaiveTime::parse_from_str(time_str, "%H:%M:%S"))
1777 .map_err(|_| {
1778 anyhow::anyhow!("Invalid time format '{}'. Use HH:MM or HH:MM:SS", time_str)
1779 })?;
1780 today.and_time(time).and_utc()
1781 } else {
1782 chrono::DateTime::parse_from_rfc3339(time_str)
1784 .map_err(|_| {
1785 anyhow::anyhow!(
1786 "Invalid datetime format '{}'. Use HH:MM or RFC3339 format",
1787 time_str
1788 )
1789 })?
1790 .to_utc()
1791 };
1792
1793 split_times.push(datetime);
1794 }
1795
1796 if split_times.is_empty() {
1797 return Err(anyhow::anyhow!("No valid split times provided"));
1798 }
1799
1800 let notes_list = notes.map(|n| {
1802 n.split(',')
1803 .map(|s| s.trim().to_string())
1804 .collect::<Vec<String>>()
1805 });
1806
1807 let db_path = get_database_path()?;
1809 let db = Database::new(&db_path)?;
1810
1811 let new_session_ids =
1812 SessionQueries::split_session(&db.connection, session_id, &split_times, notes_list)?;
1813
1814 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1815 println!("\x1b[36m│\x1b[0m \x1b[1;37mSession Split Complete\x1b[0m \x1b[36m│\x1b[0m");
1816 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1817 println!(
1818 "\x1b[36m│\x1b[0m Original session: \x1b[33m{:<20}\x1b[0m \x1b[36m│\x1b[0m",
1819 session_id
1820 );
1821 println!(
1822 "\x1b[36m│\x1b[0m Split points: \x1b[90m{:<20}\x1b[0m \x1b[36m│\x1b[0m",
1823 split_times.len()
1824 );
1825 println!(
1826 "\x1b[36m│\x1b[0m New sessions: \x1b[32m{:<20}\x1b[0m \x1b[36m│\x1b[0m",
1827 new_session_ids
1828 .iter()
1829 .map(|id| id.to_string())
1830 .collect::<Vec<_>>()
1831 .join(", ")
1832 );
1833 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1834 println!(
1835 "\x1b[36m│\x1b[0m \x1b[32m✓ Session successfully split\x1b[0m \x1b[36m│\x1b[0m"
1836 );
1837 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1838
1839 Ok(())
1840}
1841
1842async fn launch_history() -> Result<()> {
1843 if !is_tty() {
1845 return Err(anyhow::anyhow!(
1846 "Session history browser requires an interactive terminal (TTY).\n\
1847 \n\
1848 This command needs to run in a proper terminal environment.\n\
1849 Try running this command directly in your terminal application."
1850 ));
1851 }
1852
1853 enable_raw_mode().context("Failed to enable raw mode")?;
1855 let mut stdout = io::stdout();
1856 execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
1857 let backend = CrosstermBackend::new(stdout);
1858 let mut terminal = Terminal::new(backend).context("Failed to initialize terminal")?;
1859 terminal.clear().context("Failed to clear terminal")?;
1860
1861 let result = async {
1862 let mut browser = SessionHistoryBrowser::new().await?;
1863 browser.run(&mut terminal).await
1864 };
1865
1866 let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
1867
1868 let cleanup_result = cleanup_terminal(&mut terminal);
1870 if let Err(e) = cleanup_result {
1871 eprintln!("Warning: Failed to restore terminal: {}", e);
1872 }
1873
1874 result
1875}
1876
1877async fn handle_goal_action(action: GoalAction) -> Result<()> {
1878 match action {
1879 GoalAction::Create {
1880 name,
1881 target_hours,
1882 project,
1883 description,
1884 start_date,
1885 end_date,
1886 } => {
1887 create_goal(
1888 name,
1889 target_hours,
1890 project,
1891 description,
1892 start_date,
1893 end_date,
1894 )
1895 .await
1896 }
1897 GoalAction::List { project } => list_goals(project).await,
1898 GoalAction::Update { id, hours } => update_goal_progress(id, hours).await,
1899 }
1900}
1901
1902async fn create_goal(
1903 name: String,
1904 target_hours: f64,
1905 project: Option<String>,
1906 description: Option<String>,
1907 start_date: Option<String>,
1908 end_date: Option<String>,
1909) -> Result<()> {
1910 let db_path = get_database_path()?;
1911 let db = Database::new(&db_path)?;
1912
1913 let project_id = if let Some(proj_name) = project {
1914 match ProjectQueries::find_by_name(&db.connection, &proj_name)? {
1915 Some(p) => p.id,
1916 None => {
1917 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", proj_name);
1918 return Ok(());
1919 }
1920 }
1921 } else {
1922 None
1923 };
1924
1925 let start = start_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
1926 let end = end_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
1927
1928 let mut goal = Goal::new(name.clone(), target_hours);
1929 if let Some(pid) = project_id {
1930 goal = goal.with_project(pid);
1931 }
1932 if let Some(desc) = description {
1933 goal = goal.with_description(desc);
1934 }
1935 goal = goal.with_dates(start, end);
1936
1937 goal.validate()?;
1938 let goal_id = GoalQueries::create(&db.connection, &goal)?;
1939
1940 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1941 println!("\x1b[36m│\x1b[0m \x1b[1;37mGoal Created\x1b[0m \x1b[36m│\x1b[0m");
1942 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1943 println!(
1944 "\x1b[36m│\x1b[0m Name: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1945 truncate_string(&name, 27)
1946 );
1947 println!(
1948 "\x1b[36m│\x1b[0m Target: \x1b[32m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1949 format!("{} hours", target_hours)
1950 );
1951 println!(
1952 "\x1b[36m│\x1b[0m ID: \x1b[90m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
1953 goal_id
1954 );
1955 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1956 println!(
1957 "\x1b[36m│\x1b[0m \x1b[32m✓ Goal created successfully\x1b[0m \x1b[36m│\x1b[0m"
1958 );
1959 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1960
1961 Ok(())
1962}
1963
1964async fn list_goals(project: Option<String>) -> Result<()> {
1965 let db_path = get_database_path()?;
1966 let db = Database::new(&db_path)?;
1967
1968 let project_id = if let Some(proj_name) = &project {
1969 match ProjectQueries::find_by_name(&db.connection, proj_name)? {
1970 Some(p) => p.id,
1971 None => {
1972 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", proj_name);
1973 return Ok(());
1974 }
1975 }
1976 } else {
1977 None
1978 };
1979
1980 let goals = GoalQueries::list_by_project(&db.connection, project_id)?;
1981
1982 if goals.is_empty() {
1983 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1984 println!("\x1b[36m│\x1b[0m \x1b[1;37mNo Goals\x1b[0m \x1b[36m│\x1b[0m");
1985 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
1986 return Ok(());
1987 }
1988
1989 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
1990 println!("\x1b[36m│\x1b[0m \x1b[1;37mGoals\x1b[0m \x1b[36m│\x1b[0m");
1991 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
1992
1993 for goal in &goals {
1994 let progress_pct = goal.progress_percentage();
1995 println!(
1996 "\x1b[36m│\x1b[0m 🎯 \x1b[1;33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
1997 truncate_string(&goal.name, 25)
1998 );
1999 println!("\x1b[36m│\x1b[0m Progress: \x1b[32m{:.1}%\x1b[0m ({:.1}h / {:.1}h) \x1b[36m│\x1b[0m",
2000 progress_pct, goal.current_progress, goal.target_hours);
2001 println!("\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m");
2002 }
2003
2004 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2005 Ok(())
2006}
2007
2008async fn update_goal_progress(id: i64, hours: f64) -> Result<()> {
2009 let db_path = get_database_path()?;
2010 let db = Database::new(&db_path)?;
2011
2012 GoalQueries::update_progress(&db.connection, id, hours)?;
2013 CliFormatter::print_success(&format!("Updated goal {} progress by {} hours", id, hours));
2014 Ok(())
2015}
2016
2017async fn show_insights(period: Option<String>, project: Option<String>) -> Result<()> {
2018 CliFormatter::print_section_header("Productivity Insights");
2019 CliFormatter::print_field("Period", period.as_deref().unwrap_or("all"), Some("yellow"));
2020 if let Some(proj) = project {
2021 CliFormatter::print_field("Project", &truncate_string(&proj, 40), Some("yellow"));
2022 }
2023 println!();
2024 CliFormatter::print_warning("Insights calculation in progress...");
2025 Ok(())
2026}
2027
2028async fn show_summary(period: String, from: Option<String>) -> Result<()> {
2029 let db_path = get_database_path()?;
2030 let db = Database::new(&db_path)?;
2031
2032 let start_date = if let Some(from_str) = from {
2033 chrono::NaiveDate::parse_from_str(&from_str, "%Y-%m-%d")?
2034 } else {
2035 match period.as_str() {
2036 "week" => chrono::Local::now().date_naive() - chrono::Duration::days(7),
2037 "month" => chrono::Local::now().date_naive() - chrono::Duration::days(30),
2038 _ => chrono::Local::now().date_naive(),
2039 }
2040 };
2041
2042 let insight_data = match period.as_str() {
2043 "week" => InsightQueries::calculate_weekly_summary(&db.connection, start_date)?,
2044 "month" => InsightQueries::calculate_monthly_summary(&db.connection, start_date)?,
2045 _ => return Err(anyhow::anyhow!("Invalid period. Use 'week' or 'month'")),
2046 };
2047
2048 CliFormatter::print_section_header(&format!("{} Summary", period));
2049 CliFormatter::print_field(
2050 "Total Hours",
2051 &format!("{:.1}h", insight_data.total_hours),
2052 Some("green"),
2053 );
2054 CliFormatter::print_field(
2055 "Sessions",
2056 &insight_data.sessions_count.to_string(),
2057 Some("yellow"),
2058 );
2059 CliFormatter::print_field(
2060 "Avg Session",
2061 &format!("{:.1}h", insight_data.avg_session_duration),
2062 Some("yellow"),
2063 );
2064 Ok(())
2065}
2066
2067async fn compare_projects(
2068 projects: String,
2069 _from: Option<String>,
2070 _to: Option<String>,
2071) -> Result<()> {
2072 let _project_names: Vec<&str> = projects.split(',').map(|s| s.trim()).collect();
2073
2074 CliFormatter::print_section_header("Project Comparison");
2075 CliFormatter::print_field("Projects", &truncate_string(&projects, 60), Some("yellow"));
2076 println!();
2077 CliFormatter::print_warning("Comparison feature in development");
2078 Ok(())
2079}
2080
2081async fn handle_estimate_action(action: EstimateAction) -> Result<()> {
2082 match action {
2083 EstimateAction::Create {
2084 project,
2085 task,
2086 hours,
2087 due_date,
2088 } => create_estimate(project, task, hours, due_date).await,
2089 EstimateAction::Record { id, hours } => record_actual_time(id, hours).await,
2090 EstimateAction::List { project } => list_estimates(project).await,
2091 }
2092}
2093
2094async fn create_estimate(
2095 project: String,
2096 task: String,
2097 hours: f64,
2098 due_date: Option<String>,
2099) -> Result<()> {
2100 let db_path = get_database_path()?;
2101 let db = Database::new(&db_path)?;
2102
2103 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2104 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2105
2106 let due = due_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
2107
2108 let mut estimate = TimeEstimate::new(project_obj.id.unwrap(), task.clone(), hours);
2109 estimate.due_date = due;
2110
2111 let estimate_id = TimeEstimateQueries::create(&db.connection, &estimate)?;
2112
2113 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2114 println!("\x1b[36m│\x1b[0m \x1b[1;37mTime Estimate Created\x1b[0m \x1b[36m│\x1b[0m");
2115 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2116 println!(
2117 "\x1b[36m│\x1b[0m Task: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2118 truncate_string(&task, 27)
2119 );
2120 println!(
2121 "\x1b[36m│\x1b[0m Estimate: \x1b[32m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2122 format!("{} hours", hours)
2123 );
2124 println!(
2125 "\x1b[36m│\x1b[0m ID: \x1b[90m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2126 estimate_id
2127 );
2128 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2129 Ok(())
2130}
2131
2132async fn record_actual_time(id: i64, hours: f64) -> Result<()> {
2133 let db_path = get_database_path()?;
2134 let db = Database::new(&db_path)?;
2135
2136 TimeEstimateQueries::record_actual(&db.connection, id, hours)?;
2137 println!(
2138 "\x1b[32m✓ Recorded {} hours for estimate {}\x1b[0m",
2139 hours, id
2140 );
2141 Ok(())
2142}
2143
2144async fn list_estimates(project: String) -> Result<()> {
2145 let db_path = get_database_path()?;
2146 let db = Database::new(&db_path)?;
2147
2148 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2149 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2150
2151 let estimates = TimeEstimateQueries::list_by_project(&db.connection, project_obj.id.unwrap())?;
2152
2153 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2154 println!("\x1b[36m│\x1b[0m \x1b[1;37mTime Estimates\x1b[0m \x1b[36m│\x1b[0m");
2155 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2156
2157 for est in &estimates {
2158 let variance = est.variance();
2159 let variance_str = if let Some(v) = variance {
2160 if v > 0.0 {
2161 format!("\x1b[31m+{:.1}h over\x1b[0m", v)
2162 } else {
2163 format!("\x1b[32m{:.1}h under\x1b[0m", v.abs())
2164 }
2165 } else {
2166 "N/A".to_string()
2167 };
2168
2169 println!(
2170 "\x1b[36m│\x1b[0m 📋 \x1b[1;33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2171 truncate_string(&est.task_name, 25)
2172 );
2173 let actual_str = est
2174 .actual_hours
2175 .map(|h| format!("{:.1}h", h))
2176 .unwrap_or_else(|| "N/A".to_string());
2177 println!(
2178 "\x1b[36m│\x1b[0m Est: {}h | Actual: {} | {} \x1b[36m│\x1b[0m",
2179 est.estimated_hours, actual_str, variance_str
2180 );
2181 println!("\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m");
2182 }
2183
2184 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2185 Ok(())
2186}
2187
2188async fn handle_branch_action(action: BranchAction) -> Result<()> {
2189 match action {
2190 BranchAction::List { project } => list_branches(project).await,
2191 BranchAction::Stats { project, branch } => show_branch_stats(project, branch).await,
2192 }
2193}
2194
2195async fn list_branches(project: String) -> Result<()> {
2196 let db_path = get_database_path()?;
2197 let db = Database::new(&db_path)?;
2198
2199 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2200 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2201
2202 let branches = GitBranchQueries::list_by_project(&db.connection, project_obj.id.unwrap())?;
2203
2204 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2205 println!("\x1b[36m│\x1b[0m \x1b[1;37mGit Branches\x1b[0m \x1b[36m│\x1b[0m");
2206 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2207
2208 for branch in &branches {
2209 println!(
2210 "\x1b[36m│\x1b[0m 🌿 \x1b[1;33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2211 truncate_string(&branch.branch_name, 25)
2212 );
2213 println!(
2214 "\x1b[36m│\x1b[0m Time: \x1b[32m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2215 format!("{:.1}h", branch.total_hours())
2216 );
2217 println!("\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m");
2218 }
2219
2220 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2221 Ok(())
2222}
2223
2224async fn show_branch_stats(project: String, branch: Option<String>) -> Result<()> {
2225 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2226 println!("\x1b[36m│\x1b[0m \x1b[1;37mBranch Statistics\x1b[0m \x1b[36m│\x1b[0m");
2227 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2228 println!(
2229 "\x1b[36m│\x1b[0m Project: \x1b[33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2230 truncate_string(&project, 27)
2231 );
2232 if let Some(b) = branch {
2233 println!(
2234 "\x1b[36m│\x1b[0m Branch: \x1b[33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2235 truncate_string(&b, 27)
2236 );
2237 }
2238 println!(
2239 "\x1b[36m│\x1b[0m \x1b[33m⚠ Branch stats in development\x1b[0m \x1b[36m│\x1b[0m"
2240 );
2241 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2242 Ok(())
2243}
2244
2245async fn handle_template_action(action: TemplateAction) -> Result<()> {
2247 match action {
2248 TemplateAction::Create {
2249 name,
2250 description,
2251 tags,
2252 workspace_path,
2253 } => create_template(name, description, tags, workspace_path).await,
2254 TemplateAction::List => list_templates().await,
2255 TemplateAction::Delete { template } => delete_template(template).await,
2256 TemplateAction::Use {
2257 template,
2258 project_name,
2259 path,
2260 } => use_template(template, project_name, path).await,
2261 }
2262}
2263
2264async fn create_template(
2265 name: String,
2266 description: Option<String>,
2267 tags: Option<String>,
2268 workspace_path: Option<PathBuf>,
2269) -> Result<()> {
2270 let db_path = get_database_path()?;
2271 let db = Database::new(&db_path)?;
2272
2273 let default_tags = tags
2274 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
2275 .unwrap_or_default();
2276
2277 let mut template = ProjectTemplate::new(name.clone()).with_tags(default_tags);
2278
2279 let desc_clone = description.clone();
2280 if let Some(desc) = description {
2281 template = template.with_description(desc);
2282 }
2283 if let Some(path) = workspace_path {
2284 template = template.with_workspace_path(path);
2285 }
2286
2287 let _template_id = TemplateQueries::create(&db.connection, &template)?;
2288
2289 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2290 println!("\x1b[36m│\x1b[0m \x1b[1;37mTemplate Created\x1b[0m \x1b[36m│\x1b[0m");
2291 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2292 println!(
2293 "\x1b[36m│\x1b[0m Name: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2294 truncate_string(&name, 27)
2295 );
2296 if let Some(desc) = &desc_clone {
2297 println!(
2298 "\x1b[36m│\x1b[0m Desc: \x1b[2;37m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2299 truncate_string(desc, 27)
2300 );
2301 }
2302 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2303 Ok(())
2304}
2305
2306async fn list_templates() -> Result<()> {
2307 let db_path = get_database_path()?;
2308 let db = Database::new(&db_path)?;
2309
2310 let templates = TemplateQueries::list_all(&db.connection)?;
2311
2312 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2313 println!("\x1b[36m│\x1b[0m \x1b[1;37mTemplates\x1b[0m \x1b[36m│\x1b[0m");
2314 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2315
2316 if templates.is_empty() {
2317 println!("\x1b[36m│\x1b[0m No templates found. \x1b[36m│\x1b[0m");
2318 } else {
2319 for template in &templates {
2320 println!(
2321 "\x1b[36m│\x1b[0m 📋 \x1b[1;33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2322 truncate_string(&template.name, 25)
2323 );
2324 if let Some(desc) = &template.description {
2325 println!(
2326 "\x1b[36m│\x1b[0m \x1b[2;37m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2327 truncate_string(desc, 27)
2328 );
2329 }
2330 println!("\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m");
2331 }
2332 }
2333
2334 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2335 Ok(())
2336}
2337
2338async fn delete_template(_template: String) -> Result<()> {
2339 println!("\x1b[33m⚠ Template deletion not yet implemented\x1b[0m");
2340 Ok(())
2341}
2342
2343async fn use_template(template: String, project_name: String, path: Option<PathBuf>) -> Result<()> {
2344 let db_path = get_database_path()?;
2345 let db = Database::new(&db_path)?;
2346
2347 let templates = TemplateQueries::list_all(&db.connection)?;
2348 let selected_template = templates
2349 .iter()
2350 .find(|t| t.name == template || t.id.map(|id| id.to_string()) == Some(template.clone()))
2351 .ok_or_else(|| anyhow::anyhow!("Template '{}' not found", template))?;
2352
2353 let project_path = path.unwrap_or_else(|| env::current_dir().unwrap());
2355 let canonical_path = canonicalize_path(&project_path)?;
2356
2357 if ProjectQueries::find_by_path(&db.connection, &canonical_path)?.is_some() {
2359 return Err(anyhow::anyhow!("Project already exists at this path"));
2360 }
2361
2362 let git_hash = if is_git_repository(&canonical_path) {
2363 get_git_hash(&canonical_path)
2364 } else {
2365 None
2366 };
2367
2368 let template_desc = selected_template.description.clone();
2369 let mut project = Project::new(project_name.clone(), canonical_path.clone())
2370 .with_git_hash(git_hash)
2371 .with_description(template_desc);
2372
2373 let project_id = ProjectQueries::create(&db.connection, &project)?;
2374 project.id = Some(project_id);
2375
2376 for goal_def in &selected_template.default_goals {
2381 let mut goal =
2382 Goal::new(goal_def.name.clone(), goal_def.target_hours).with_project(project_id);
2383 if let Some(desc) = &goal_def.description {
2384 goal = goal.with_description(desc.clone());
2385 }
2386 GoalQueries::create(&db.connection, &goal)?;
2387 }
2388
2389 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2390 println!("\x1b[36m│\x1b[0m \x1b[1;37mProject Created from Template\x1b[0m \x1b[36m│\x1b[0m");
2391 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2392 println!(
2393 "\x1b[36m│\x1b[0m Template: \x1b[33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2394 truncate_string(&selected_template.name, 27)
2395 );
2396 println!(
2397 "\x1b[36m│\x1b[0m Project: \x1b[33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2398 truncate_string(&project_name, 27)
2399 );
2400 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2401 Ok(())
2402}
2403
2404async fn handle_workspace_action(action: WorkspaceAction) -> Result<()> {
2406 match action {
2407 WorkspaceAction::Create {
2408 name,
2409 description,
2410 path,
2411 } => create_workspace(name, description, path).await,
2412 WorkspaceAction::List => list_workspaces().await,
2413 WorkspaceAction::AddProject { workspace, project } => {
2414 add_project_to_workspace(workspace, project).await
2415 }
2416 WorkspaceAction::RemoveProject { workspace, project } => {
2417 remove_project_from_workspace(workspace, project).await
2418 }
2419 WorkspaceAction::Projects { workspace } => list_workspace_projects(workspace).await,
2420 WorkspaceAction::Delete { workspace } => delete_workspace(workspace).await,
2421 }
2422}
2423
2424async fn create_workspace(
2425 name: String,
2426 description: Option<String>,
2427 path: Option<PathBuf>,
2428) -> Result<()> {
2429 let db_path = get_database_path()?;
2430 let db = Database::new(&db_path)?;
2431
2432 let mut workspace = Workspace::new(name.clone());
2433 let desc_clone = description.clone();
2434 if let Some(desc) = description {
2435 workspace = workspace.with_description(desc);
2436 }
2437 if let Some(p) = path {
2438 workspace = workspace.with_path(p);
2439 }
2440
2441 let _workspace_id = WorkspaceQueries::create(&db.connection, &workspace)?;
2442
2443 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2444 println!("\x1b[36m│\x1b[0m \x1b[1;37mWorkspace Created\x1b[0m \x1b[36m│\x1b[0m");
2445 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2446 println!(
2447 "\x1b[36m│\x1b[0m Name: \x1b[1;33m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2448 truncate_string(&name, 27)
2449 );
2450 if let Some(desc) = &desc_clone {
2451 println!(
2452 "\x1b[36m│\x1b[0m Desc: \x1b[2;37m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2453 truncate_string(desc, 27)
2454 );
2455 }
2456 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2457 Ok(())
2458}
2459
2460async fn list_workspaces() -> Result<()> {
2461 let db_path = get_database_path()?;
2462 let db = Database::new(&db_path)?;
2463
2464 let workspaces = WorkspaceQueries::list_all(&db.connection)?;
2465
2466 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2467 println!("\x1b[36m│\x1b[0m \x1b[1;37mWorkspaces\x1b[0m \x1b[36m│\x1b[0m");
2468 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2469
2470 if workspaces.is_empty() {
2471 println!("\x1b[36m│\x1b[0m No workspaces found. \x1b[36m│\x1b[0m");
2472 } else {
2473 for workspace in &workspaces {
2474 println!(
2475 "\x1b[36m│\x1b[0m 📁 \x1b[1;33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2476 truncate_string(&workspace.name, 25)
2477 );
2478 if let Some(desc) = &workspace.description {
2479 println!(
2480 "\x1b[36m│\x1b[0m \x1b[2;37m{:<27}\x1b[0m \x1b[36m│\x1b[0m",
2481 truncate_string(desc, 27)
2482 );
2483 }
2484 println!("\x1b[36m│\x1b[0m \x1b[36m│\x1b[0m");
2485 }
2486 }
2487
2488 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2489 Ok(())
2490}
2491
2492async fn add_project_to_workspace(workspace: String, project: String) -> Result<()> {
2493 let db_path = get_database_path()?;
2494 let db = Database::new(&db_path)?;
2495
2496 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2498 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2499
2500 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2502 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2503
2504 let workspace_id = workspace_obj
2505 .id
2506 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2507 let project_id = project_obj
2508 .id
2509 .ok_or_else(|| anyhow::anyhow!("Project ID is missing"))?;
2510
2511 if WorkspaceQueries::add_project(&db.connection, workspace_id, project_id)? {
2512 println!(
2513 "\x1b[32m✓\x1b[0m Added project '\x1b[33m{}\x1b[0m' to workspace '\x1b[33m{}\x1b[0m'",
2514 project, workspace
2515 );
2516 } else {
2517 println!("\x1b[33m⚠\x1b[0m Project '\x1b[33m{}\x1b[0m' is already in workspace '\x1b[33m{}\x1b[0m'", project, workspace);
2518 }
2519
2520 Ok(())
2521}
2522
2523async fn remove_project_from_workspace(workspace: String, project: String) -> Result<()> {
2524 let db_path = get_database_path()?;
2525 let db = Database::new(&db_path)?;
2526
2527 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2529 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2530
2531 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2533 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2534
2535 let workspace_id = workspace_obj
2536 .id
2537 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2538 let project_id = project_obj
2539 .id
2540 .ok_or_else(|| anyhow::anyhow!("Project ID is missing"))?;
2541
2542 if WorkspaceQueries::remove_project(&db.connection, workspace_id, project_id)? {
2543 println!("\x1b[32m✓\x1b[0m Removed project '\x1b[33m{}\x1b[0m' from workspace '\x1b[33m{}\x1b[0m'", project, workspace);
2544 } else {
2545 println!(
2546 "\x1b[33m⚠\x1b[0m Project '\x1b[33m{}\x1b[0m' was not in workspace '\x1b[33m{}\x1b[0m'",
2547 project, workspace
2548 );
2549 }
2550
2551 Ok(())
2552}
2553
2554async fn list_workspace_projects(workspace: String) -> Result<()> {
2555 let db_path = get_database_path()?;
2556 let db = Database::new(&db_path)?;
2557
2558 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2560 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2561
2562 let workspace_id = workspace_obj
2563 .id
2564 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2565 let projects = WorkspaceQueries::list_projects(&db.connection, workspace_id)?;
2566
2567 if projects.is_empty() {
2568 println!(
2569 "\x1b[33m⚠\x1b[0m No projects found in workspace '\x1b[33m{}\x1b[0m'",
2570 workspace
2571 );
2572 return Ok(());
2573 }
2574
2575 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2576 println!("\x1b[36m│\x1b[0m \x1b[1;37mWorkspace Projects\x1b[0m \x1b[36m│\x1b[0m");
2577 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2578 println!(
2579 "\x1b[36m│\x1b[0m Workspace: \x1b[33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2580 truncate_string(&workspace, 25)
2581 );
2582 println!(
2583 "\x1b[36m│\x1b[0m Projects: \x1b[32m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2584 format!("{} projects", projects.len())
2585 );
2586 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2587
2588 for project in &projects {
2589 let status_indicator = if !project.is_archived {
2590 "\x1b[32m●\x1b[0m"
2591 } else {
2592 "\x1b[31m○\x1b[0m"
2593 };
2594 println!(
2595 "\x1b[36m│\x1b[0m {} \x1b[37m{:<33}\x1b[0m \x1b[36m│\x1b[0m",
2596 status_indicator,
2597 truncate_string(&project.name, 33)
2598 );
2599 }
2600
2601 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2602 Ok(())
2603}
2604
2605async fn delete_workspace(workspace: String) -> Result<()> {
2606 let db_path = get_database_path()?;
2607 let db = Database::new(&db_path)?;
2608
2609 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2611 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2612
2613 let workspace_id = workspace_obj
2614 .id
2615 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2616
2617 let projects = WorkspaceQueries::list_projects(&db.connection, workspace_id)?;
2619 if !projects.is_empty() {
2620 println!("\x1b[33m⚠\x1b[0m Cannot delete workspace '\x1b[33m{}\x1b[0m' - it contains {} project(s). Remove projects first.",
2621 workspace, projects.len());
2622 return Ok(());
2623 }
2624
2625 if WorkspaceQueries::delete(&db.connection, workspace_id)? {
2626 println!(
2627 "\x1b[32m✓\x1b[0m Deleted workspace '\x1b[33m{}\x1b[0m'",
2628 workspace
2629 );
2630 } else {
2631 println!(
2632 "\x1b[31m✗\x1b[0m Failed to delete workspace '\x1b[33m{}\x1b[0m'",
2633 workspace
2634 );
2635 }
2636
2637 Ok(())
2638}
2639
2640async fn handle_calendar_action(action: CalendarAction) -> Result<()> {
2642 match action {
2643 CalendarAction::Add {
2644 name,
2645 start,
2646 end,
2647 event_type,
2648 project,
2649 description,
2650 } => add_calendar_event(name, start, end, event_type, project, description).await,
2651 CalendarAction::List { from, to, project } => list_calendar_events(from, to, project).await,
2652 CalendarAction::Delete { id } => delete_calendar_event(id).await,
2653 }
2654}
2655
2656async fn add_calendar_event(
2657 _name: String,
2658 _start: String,
2659 _end: Option<String>,
2660 _event_type: Option<String>,
2661 _project: Option<String>,
2662 _description: Option<String>,
2663) -> Result<()> {
2664 println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
2665 Ok(())
2666}
2667
2668async fn list_calendar_events(
2669 _from: Option<String>,
2670 _to: Option<String>,
2671 _project: Option<String>,
2672) -> Result<()> {
2673 println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
2674 Ok(())
2675}
2676
2677async fn delete_calendar_event(_id: i64) -> Result<()> {
2678 println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
2679 Ok(())
2680}
2681
2682async fn handle_issue_action(action: IssueAction) -> Result<()> {
2684 match action {
2685 IssueAction::Sync {
2686 project,
2687 tracker_type,
2688 } => sync_issues(project, tracker_type).await,
2689 IssueAction::List { project, status } => list_issues(project, status).await,
2690 IssueAction::Link {
2691 session_id,
2692 issue_id,
2693 } => link_session_to_issue(session_id, issue_id).await,
2694 }
2695}
2696
2697async fn sync_issues(_project: String, _tracker_type: Option<String>) -> Result<()> {
2698 println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
2699 Ok(())
2700}
2701
2702async fn list_issues(_project: String, _status: Option<String>) -> Result<()> {
2703 println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
2704 Ok(())
2705}
2706
2707async fn link_session_to_issue(_session_id: i64, _issue_id: String) -> Result<()> {
2708 println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
2709 Ok(())
2710}
2711
2712async fn handle_client_action(action: ClientAction) -> Result<()> {
2714 match action {
2715 ClientAction::Generate {
2716 client,
2717 from,
2718 to,
2719 projects,
2720 format,
2721 } => generate_client_report(client, from, to, projects, format).await,
2722 ClientAction::List { client } => list_client_reports(client).await,
2723 ClientAction::View { id } => view_client_report(id).await,
2724 }
2725}
2726
2727async fn generate_client_report(
2728 _client: String,
2729 _from: String,
2730 _to: String,
2731 _projects: Option<String>,
2732 _format: Option<String>,
2733) -> Result<()> {
2734 println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
2735 Ok(())
2736}
2737
2738async fn list_client_reports(_client: Option<String>) -> Result<()> {
2739 println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
2740 Ok(())
2741}
2742
2743async fn view_client_report(_id: i64) -> Result<()> {
2744 println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
2745 Ok(())
2746}
2747
2748#[allow(dead_code)]
2749fn should_quit(event: crossterm::event::Event) -> bool {
2750 match event {
2751 crossterm::event::Event::Key(key) if key.kind == crossterm::event::KeyEventKind::Press => {
2752 matches!(
2753 key.code,
2754 crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc
2755 )
2756 }
2757 _ => false,
2758 }
2759}
2760
2761async fn init_project_with_db(
2763 name: Option<String>,
2764 canonical_path: Option<PathBuf>,
2765 description: Option<String>,
2766 conn: &rusqlite::Connection,
2767) -> Result<()> {
2768 let canonical_path =
2769 canonical_path.ok_or_else(|| anyhow::anyhow!("Canonical path required"))?;
2770 let project_name = name.unwrap_or_else(|| detect_project_name(&canonical_path));
2771
2772 if let Some(existing) = ProjectQueries::find_by_path(conn, &canonical_path)? {
2774 println!(
2775 "\x1b[33m⚠ Project already exists:\x1b[0m {}",
2776 existing.name
2777 );
2778 return Ok(());
2779 }
2780
2781 let git_hash = if is_git_repository(&canonical_path) {
2783 get_git_hash(&canonical_path)
2784 } else {
2785 None
2786 };
2787
2788 let mut project = Project::new(project_name.clone(), canonical_path.clone())
2790 .with_git_hash(git_hash.clone())
2791 .with_description(description.clone());
2792
2793 let project_id = ProjectQueries::create(conn, &project)?;
2795 project.id = Some(project_id);
2796
2797 let marker_path = canonical_path.join(".tempo");
2799 if !marker_path.exists() {
2800 std::fs::write(
2801 &marker_path,
2802 format!("# Tempo time tracking project\nname: {}\n", project_name),
2803 )?;
2804 }
2805
2806 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2807 println!("\x1b[36m│\x1b[0m \x1b[1;37mProject Initialized\x1b[0m \x1b[36m│\x1b[0m");
2808 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2809 println!(
2810 "\x1b[36m│\x1b[0m Name: \x1b[33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2811 truncate_string(&project_name, 25)
2812 );
2813 println!(
2814 "\x1b[36m│\x1b[0m Path: \x1b[37m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2815 truncate_string(&canonical_path.display().to_string(), 25)
2816 );
2817
2818 if let Some(desc) = &description {
2819 println!(
2820 "\x1b[36m│\x1b[0m Description: \x1b[37m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2821 truncate_string(desc, 25)
2822 );
2823 }
2824
2825 if is_git_repository(&canonical_path) {
2826 println!(
2827 "\x1b[36m│\x1b[0m Git: \x1b[32m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2828 "Repository detected"
2829 );
2830 if let Some(hash) = &git_hash {
2831 println!(
2832 "\x1b[36m│\x1b[0m Git Hash: \x1b[37m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2833 truncate_string(hash, 25)
2834 );
2835 }
2836 }
2837
2838 println!(
2839 "\x1b[36m│\x1b[0m ID: \x1b[37m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2840 project_id
2841 );
2842 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2843
2844 Ok(())
2845}
2846
2847async fn show_pool_stats() -> Result<()> {
2849 match get_pool_stats() {
2850 Ok(stats) => {
2851 CliFormatter::print_section_header("Database Pool Statistics");
2852 CliFormatter::print_field(
2853 "Total Created",
2854 &stats.total_connections_created.to_string(),
2855 Some("green"),
2856 );
2857 CliFormatter::print_field(
2858 "Active",
2859 &stats.active_connections.to_string(),
2860 Some("yellow"),
2861 );
2862 CliFormatter::print_field(
2863 "Available",
2864 &stats.connections_in_pool.to_string(),
2865 Some("white"),
2866 );
2867 CliFormatter::print_field(
2868 "Total Requests",
2869 &stats.connection_requests.to_string(),
2870 Some("white"),
2871 );
2872 CliFormatter::print_field(
2873 "Timeouts",
2874 &stats.connection_timeouts.to_string(),
2875 Some("red"),
2876 );
2877 }
2878 Err(_) => {
2879 CliFormatter::print_warning("Database pool not initialized or not available");
2880 CliFormatter::print_info("Using direct database connections as fallback");
2881 }
2882 }
2883 Ok(())
2884}
2885
2886#[derive(Deserialize)]
2887#[allow(dead_code)]
2888struct GitHubRelease {
2889 tag_name: String,
2890 name: String,
2891 body: String,
2892 published_at: String,
2893 prerelease: bool,
2894}
2895
2896async fn handle_update(check: bool, force: bool, verbose: bool) -> Result<()> {
2897 let current_version = env!("CARGO_PKG_VERSION");
2898
2899 if verbose {
2900 println!("🔍 Current version: v{}", current_version);
2901 println!("📡 Checking for updates...");
2902 } else {
2903 println!("🔍 Checking for updates...");
2904 }
2905
2906 let client = reqwest::Client::new();
2908 let response = client
2909 .get("https://api.github.com/repos/own-path/vibe/releases/latest")
2910 .header("User-Agent", format!("tempo-cli/{}", current_version))
2911 .send()
2912 .await
2913 .context("Failed to fetch release information")?;
2914
2915 if !response.status().is_success() {
2916 return Err(anyhow::anyhow!(
2917 "Failed to fetch release information: HTTP {}",
2918 response.status()
2919 ));
2920 }
2921
2922 let release: GitHubRelease = response
2923 .json()
2924 .await
2925 .context("Failed to parse release information")?;
2926
2927 let latest_version = release.tag_name.trim_start_matches('v');
2928
2929 if verbose {
2930 println!("📦 Latest version: v{}", latest_version);
2931 println!("📅 Released: {}", release.published_at);
2932 }
2933
2934 let current_semver =
2936 semver::Version::parse(current_version).context("Failed to parse current version")?;
2937 let latest_semver =
2938 semver::Version::parse(latest_version).context("Failed to parse latest version")?;
2939
2940 if current_semver >= latest_semver && !force {
2941 println!(
2942 "✅ You're already running the latest version (v{})",
2943 current_version
2944 );
2945 if check {
2946 return Ok(());
2947 }
2948
2949 if !force {
2950 println!("💡 Use --force to reinstall the current version");
2951 return Ok(());
2952 }
2953 }
2954
2955 if check {
2956 if current_semver < latest_semver {
2957 println!(
2958 "📦 Update available: v{} → v{}",
2959 current_version, latest_version
2960 );
2961 println!("🔗 Run `tempo update` to install the latest version");
2962
2963 if verbose && !release.body.is_empty() {
2964 println!("\n📝 Release Notes:");
2965 println!("{}", release.body);
2966 }
2967 }
2968 return Ok(());
2969 }
2970
2971 if current_semver < latest_semver || force {
2972 println!(
2973 "⬇️ Updating tempo from v{} to v{}",
2974 current_version, latest_version
2975 );
2976
2977 if verbose {
2978 println!("🔧 Installing via cargo...");
2979 }
2980
2981 let mut cmd = Command::new("cargo");
2983 cmd.args(["install", "tempo-cli", "--force"]);
2984
2985 if verbose {
2986 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
2987 } else {
2988 cmd.stdout(Stdio::null()).stderr(Stdio::null());
2989 }
2990
2991 let status = cmd
2992 .status()
2993 .context("Failed to run cargo install command")?;
2994
2995 if status.success() {
2996 println!("✅ Successfully updated tempo to v{}", latest_version);
2997 println!("🎉 You can now use the latest features!");
2998
2999 if !release.body.is_empty() && verbose {
3000 println!("\n📝 What's new in v{}:", latest_version);
3001 println!("{}", release.body);
3002 }
3003 } else {
3004 return Err(anyhow::anyhow!(
3005 "Failed to install update. Try running manually: cargo install tempo-cli --force"
3006 ));
3007 }
3008 }
3009
3010 Ok(())
3011}