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