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 CliFormatter::print_section_header("No Tags");
832 CliFormatter::print_empty_state("No tags found.");
833 println!();
834 CliFormatter::print_info("Create a tag: tempo tag create <name>");
835 return Ok(());
836 }
837
838 CliFormatter::print_section_header("Tags");
839
840 for tag in &tags {
841 let color_indicator = if let Some(color) = &tag.color {
842 format!(" ({})", color)
843 } else {
844 String::new()
845 };
846
847 let tag_name = format!("🏷️ {}{}", tag.name, color_indicator);
848 println!(" {}", ansi_color("yellow", &tag_name, true));
849
850 if let Some(description) = &tag.description {
851 println!(" {}", description.dimmed());
852 }
853 println!();
854 }
855
856 println!(" {}: {}", "Total".dimmed(), format!("{} tags", tags.len()));
857
858 Ok(())
859}
860
861async fn delete_tag(name: String) -> Result<()> {
862 let db_path = get_database_path()?;
864 let db = Database::new(&db_path)?;
865
866 if TagQueries::find_by_name(&db.connection, &name)?.is_none() {
868 println!("\x1b[31m✗ Tag '{}' not found\x1b[0m", name);
869 return Ok(());
870 }
871
872 let deleted = TagQueries::delete_by_name(&db.connection, &name)?;
874
875 if deleted {
876 CliFormatter::print_section_header("Tag Deleted");
877 CliFormatter::print_field_bold("Name", &name, Some("yellow"));
878 CliFormatter::print_status("Deleted", true);
879 CliFormatter::print_success("Tag deleted successfully");
880 } else {
881 CliFormatter::print_error(&format!("Failed to delete tag '{}'", name));
882 }
883
884 Ok(())
885}
886
887async fn show_config() -> Result<()> {
889 let config = load_config()?;
890
891 CliFormatter::print_section_header("Configuration");
892 CliFormatter::print_field("idle_timeout_minutes", &config.idle_timeout_minutes.to_string(), Some("yellow"));
893 CliFormatter::print_field("auto_pause_enabled", &config.auto_pause_enabled.to_string(), Some("yellow"));
894 CliFormatter::print_field("default_context", &config.default_context, Some("yellow"));
895 CliFormatter::print_field("max_session_hours", &config.max_session_hours.to_string(), Some("yellow"));
896 CliFormatter::print_field("backup_enabled", &config.backup_enabled.to_string(), Some("yellow"));
897 CliFormatter::print_field("log_level", &config.log_level, Some("yellow"));
898
899 if !config.custom_settings.is_empty() {
900 println!();
901 CliFormatter::print_field_bold("Custom Settings", "", None);
902 for (key, value) in &config.custom_settings {
903 CliFormatter::print_field(key, value, Some("yellow"));
904 }
905 }
906
907 Ok(())
908}
909
910async fn get_config(key: String) -> Result<()> {
911 let config = load_config()?;
912
913 let value = match key.as_str() {
914 "idle_timeout_minutes" => Some(config.idle_timeout_minutes.to_string()),
915 "auto_pause_enabled" => Some(config.auto_pause_enabled.to_string()),
916 "default_context" => Some(config.default_context),
917 "max_session_hours" => Some(config.max_session_hours.to_string()),
918 "backup_enabled" => Some(config.backup_enabled.to_string()),
919 "log_level" => Some(config.log_level),
920 _ => config.custom_settings.get(&key).cloned(),
921 };
922
923 match value {
924 Some(val) => {
925 CliFormatter::print_section_header("Configuration Value");
926 CliFormatter::print_field(&key, &val, Some("yellow"));
927 }
928 None => {
929 CliFormatter::print_error(&format!("Configuration key not found: {}", key));
930 }
931 }
932
933 Ok(())
934}
935
936async fn set_config(key: String, value: String) -> Result<()> {
937 let mut config = load_config()?;
938
939 let display_value = value.clone(); match key.as_str() {
942 "idle_timeout_minutes" => {
943 config.idle_timeout_minutes = value.parse()?;
944 }
945 "auto_pause_enabled" => {
946 config.auto_pause_enabled = value.parse()?;
947 }
948 "default_context" => {
949 config.default_context = value;
950 }
951 "max_session_hours" => {
952 config.max_session_hours = value.parse()?;
953 }
954 "backup_enabled" => {
955 config.backup_enabled = value.parse()?;
956 }
957 "log_level" => {
958 config.log_level = value;
959 }
960 _ => {
961 config.set_custom(key.clone(), value);
962 }
963 }
964
965 config.validate()?;
966 save_config(&config)?;
967
968 CliFormatter::print_section_header("Configuration Updated");
969 CliFormatter::print_field(&key, &display_value, Some("green"));
970 CliFormatter::print_success("Configuration saved successfully");
971
972 Ok(())
973}
974
975async fn reset_config() -> Result<()> {
976 let default_config = crate::models::Config::default();
977 save_config(&default_config)?;
978
979 CliFormatter::print_section_header("Configuration Reset");
980 CliFormatter::print_success("Configuration reset to defaults");
981 CliFormatter::print_info("View current config: tempo config show");
982
983 Ok(())
984}
985
986async fn list_sessions(limit: Option<usize>, project_filter: Option<String>) -> Result<()> {
988 let db_path = get_database_path()?;
990 let db = Database::new(&db_path)?;
991
992 let session_limit = limit.unwrap_or(10);
993
994 let project_id = if let Some(project_name) = &project_filter {
996 match ProjectQueries::find_by_name(&db.connection, project_name)? {
997 Some(project) => Some(project.id.unwrap()),
998 None => {
999 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1000 return Ok(());
1001 }
1002 }
1003 } else {
1004 None
1005 };
1006
1007 let sessions = SessionQueries::list_with_filter(
1008 &db.connection,
1009 project_id,
1010 None,
1011 None,
1012 Some(session_limit),
1013 )?;
1014
1015 if sessions.is_empty() {
1016 CliFormatter::print_section_header("No Sessions");
1017 CliFormatter::print_empty_state("No sessions found.");
1018 println!();
1019 CliFormatter::print_info("Start a session: tempo session start");
1020 return Ok(());
1021 }
1022
1023 let filtered_sessions = if let Some(_project) = project_filter {
1025 sessions
1027 } else {
1028 sessions
1029 };
1030
1031 CliFormatter::print_section_header("Recent Sessions");
1032
1033 for session in &filtered_sessions {
1034 let status_icon = if session.end_time.is_some() { "✅" } else { "🔄" };
1035 let duration = if let Some(end) = session.end_time {
1036 (end - session.start_time).num_seconds() - session.paused_duration.num_seconds()
1037 } else {
1038 (Utc::now() - session.start_time).num_seconds() - session.paused_duration.num_seconds()
1039 };
1040
1041 let context_color = match session.context {
1042 crate::models::SessionContext::Terminal => "cyan",
1043 crate::models::SessionContext::IDE => "magenta",
1044 crate::models::SessionContext::Linked => "yellow",
1045 crate::models::SessionContext::Manual => "blue",
1046 };
1047
1048 println!(" {} {}", status_icon, ansi_color("white", &format!("Session {}", session.id.unwrap_or(0)), true));
1049 CliFormatter::print_field(" Duration", &format_duration_clean(duration), Some("green"));
1050 CliFormatter::print_field(" Context", &session.context.to_string(), Some(context_color));
1051 CliFormatter::print_field(" Started", &session.start_time.format("%Y-%m-%d %H:%M:%S").to_string(), None);
1052 println!();
1053 }
1054
1055 println!(" {}: {}", "Showing".dimmed(), format!("{} recent sessions", filtered_sessions.len()));
1056
1057 Ok(())
1058}
1059
1060async fn edit_session(
1061 id: i64,
1062 start: Option<String>,
1063 end: Option<String>,
1064 project: Option<String>,
1065 reason: Option<String>,
1066) -> Result<()> {
1067 let db_path = get_database_path()?;
1069 let db = Database::new(&db_path)?;
1070
1071 let session = SessionQueries::find_by_id(&db.connection, id)?;
1073 let session = match session {
1074 Some(s) => s,
1075 None => {
1076 println!("\x1b[31m✗ Session {} not found\x1b[0m", id);
1077 return Ok(());
1078 }
1079 };
1080
1081 let original_start = session.start_time;
1082 let original_end = session.end_time;
1083
1084 let mut new_start = original_start;
1086 let mut new_end = original_end;
1087 let mut new_project_id = session.project_id;
1088
1089 if let Some(start_str) = &start {
1091 new_start = match chrono::DateTime::parse_from_rfc3339(start_str) {
1092 Ok(dt) => dt.with_timezone(&chrono::Utc),
1093 Err(_) => match chrono::NaiveDateTime::parse_from_str(start_str, "%Y-%m-%d %H:%M:%S") {
1094 Ok(dt) => chrono::Utc.from_utc_datetime(&dt),
1095 Err(_) => {
1096 return Err(anyhow::anyhow!(
1097 "Invalid start time format. Use RFC3339 or 'YYYY-MM-DD HH:MM:SS'"
1098 ))
1099 }
1100 },
1101 };
1102 }
1103
1104 if let Some(end_str) = &end {
1106 if end_str.to_lowercase() == "null" || end_str.to_lowercase() == "none" {
1107 new_end = None;
1108 } else {
1109 new_end = Some(match chrono::DateTime::parse_from_rfc3339(end_str) {
1110 Ok(dt) => dt.with_timezone(&chrono::Utc),
1111 Err(_) => {
1112 match chrono::NaiveDateTime::parse_from_str(end_str, "%Y-%m-%d %H:%M:%S") {
1113 Ok(dt) => chrono::Utc.from_utc_datetime(&dt),
1114 Err(_) => {
1115 return Err(anyhow::anyhow!(
1116 "Invalid end time format. Use RFC3339 or 'YYYY-MM-DD HH:MM:SS'"
1117 ))
1118 }
1119 }
1120 }
1121 });
1122 }
1123 }
1124
1125 if let Some(project_name) = &project {
1127 if let Some(proj) = ProjectQueries::find_by_name(&db.connection, project_name)? {
1128 new_project_id = proj.id.unwrap();
1129 } else {
1130 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1131 return Ok(());
1132 }
1133 }
1134
1135 if new_start >= new_end.unwrap_or(chrono::Utc::now()) {
1137 println!("\x1b[31m✗ Start time must be before end time\x1b[0m");
1138 return Ok(());
1139 }
1140
1141 SessionEditQueries::create_edit_record(
1143 &db.connection,
1144 id,
1145 original_start,
1146 original_end,
1147 new_start,
1148 new_end,
1149 reason.clone(),
1150 )?;
1151
1152 SessionQueries::update_session(
1154 &db.connection,
1155 id,
1156 if start.is_some() {
1157 Some(new_start)
1158 } else {
1159 None
1160 },
1161 if end.is_some() { Some(new_end) } else { None },
1162 if project.is_some() {
1163 Some(new_project_id)
1164 } else {
1165 None
1166 },
1167 None,
1168 )?;
1169
1170 CliFormatter::print_section_header("Session Updated");
1171 CliFormatter::print_field("Session", &id.to_string(), Some("white"));
1172
1173 if start.is_some() {
1174 CliFormatter::print_field("Start", &new_start.format("%Y-%m-%d %H:%M:%S").to_string(), Some("green"));
1175 }
1176
1177 if end.is_some() {
1178 let end_str = if let Some(e) = new_end {
1179 e.format("%Y-%m-%d %H:%M:%S").to_string()
1180 } else {
1181 "Ongoing".to_string()
1182 };
1183 CliFormatter::print_field("End", &end_str, Some("green"));
1184 }
1185
1186 if let Some(r) = &reason {
1187 CliFormatter::print_field("Reason", r, Some("gray"));
1188 }
1189
1190 CliFormatter::print_success("Session updated with audit trail");
1191
1192 Ok(())
1193}
1194
1195async fn delete_session(id: i64, force: bool) -> Result<()> {
1196 let db_path = get_database_path()?;
1198 let db = Database::new(&db_path)?;
1199
1200 let session = SessionQueries::find_by_id(&db.connection, id)?;
1202 let session = match session {
1203 Some(s) => s,
1204 None => {
1205 println!("\x1b[31m✗ Session {} not found\x1b[0m", id);
1206 return Ok(());
1207 }
1208 };
1209
1210 if session.end_time.is_none() && !force {
1212 println!("\x1b[33m⚠ Cannot delete active session without --force flag\x1b[0m");
1213 println!(" Use: tempo session delete {} --force", id);
1214 return Ok(());
1215 }
1216
1217 SessionQueries::delete_session(&db.connection, id)?;
1219
1220 CliFormatter::print_section_header("Session Deleted");
1221 CliFormatter::print_field("Session", &id.to_string(), Some("white"));
1222 CliFormatter::print_field("Status", "Deleted", Some("green"));
1223
1224 if session.end_time.is_none() {
1225 CliFormatter::print_field("Type", "Active session (forced)", Some("yellow"));
1226 } else {
1227 CliFormatter::print_field("Type", "Completed session", None);
1228 }
1229
1230 CliFormatter::print_success("Session and audit trail removed");
1231
1232 Ok(())
1233}
1234
1235async fn archive_project(project_name: String) -> Result<()> {
1237 let db_path = get_database_path()?;
1238 let db = Database::new(&db_path)?;
1239
1240 let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
1241 Some(p) => p,
1242 None => {
1243 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1244 return Ok(());
1245 }
1246 };
1247
1248 if project.is_archived {
1249 println!(
1250 "\x1b[33m⚠ Project '{}' is already archived\x1b[0m",
1251 project_name
1252 );
1253 return Ok(());
1254 }
1255
1256 let success = ProjectQueries::archive_project(&db.connection, project.id.unwrap())?;
1257
1258 if success {
1259 CliFormatter::print_section_header("Project Archived");
1260 CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
1261 CliFormatter::print_field("Status", "Archived", Some("gray"));
1262 CliFormatter::print_success("Project archived successfully");
1263 } else {
1264 CliFormatter::print_error(&format!("Failed to archive project '{}'", project_name));
1265 }
1266
1267 Ok(())
1268}
1269
1270async fn unarchive_project(project_name: String) -> Result<()> {
1271 let db_path = get_database_path()?;
1272 let db = Database::new(&db_path)?;
1273
1274 let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
1275 Some(p) => p,
1276 None => {
1277 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1278 return Ok(());
1279 }
1280 };
1281
1282 if !project.is_archived {
1283 println!(
1284 "\x1b[33m⚠ Project '{}' is not archived\x1b[0m",
1285 project_name
1286 );
1287 return Ok(());
1288 }
1289
1290 let success = ProjectQueries::unarchive_project(&db.connection, project.id.unwrap())?;
1291
1292 if success {
1293 CliFormatter::print_section_header("Project Unarchived");
1294 CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
1295 CliFormatter::print_field("Status", "Active", Some("green"));
1296 CliFormatter::print_success("Project unarchived successfully");
1297 } else {
1298 CliFormatter::print_error(&format!("Failed to unarchive project '{}'", project_name));
1299 }
1300
1301 Ok(())
1302}
1303
1304async fn update_project_path(project_name: String, new_path: PathBuf) -> Result<()> {
1305 let db_path = get_database_path()?;
1306 let db = Database::new(&db_path)?;
1307
1308 let project = match ProjectQueries::find_by_name(&db.connection, &project_name)? {
1309 Some(p) => p,
1310 None => {
1311 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", project_name);
1312 return Ok(());
1313 }
1314 };
1315
1316 let canonical_path = canonicalize_path(&new_path)?;
1317 let success =
1318 ProjectQueries::update_project_path(&db.connection, project.id.unwrap(), &canonical_path)?;
1319
1320 if success {
1321 CliFormatter::print_section_header("Project Path Updated");
1322 CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
1323 CliFormatter::print_field("Old Path", &project.path.to_string_lossy(), Some("gray"));
1324 CliFormatter::print_field("New Path", &canonical_path.to_string_lossy(), Some("green"));
1325 CliFormatter::print_success("Path updated successfully");
1326 } else {
1327 CliFormatter::print_error(&format!("Failed to update path for project '{}'", project_name));
1328 }
1329
1330 Ok(())
1331}
1332
1333async fn add_tag_to_project(project_name: String, tag_name: String) -> Result<()> {
1334 println!("\x1b[33m⚠ Project-tag associations not yet implemented\x1b[0m");
1335 println!("Would add tag '{}' to project '{}'", tag_name, project_name);
1336 println!("This requires implementing project_tags table operations.");
1337 Ok(())
1338}
1339
1340async fn remove_tag_from_project(project_name: String, tag_name: String) -> Result<()> {
1341 println!("\x1b[33m⚠ Project-tag associations not yet implemented\x1b[0m");
1342 println!(
1343 "Would remove tag '{}' from project '{}'",
1344 tag_name, project_name
1345 );
1346 println!("This requires implementing project_tags table operations.");
1347 Ok(())
1348}
1349
1350#[allow(dead_code)]
1352async fn bulk_update_sessions_project(
1353 session_ids: Vec<i64>,
1354 new_project_name: String,
1355) -> Result<()> {
1356 let db_path = get_database_path()?;
1357 let db = Database::new(&db_path)?;
1358
1359 let project = match ProjectQueries::find_by_name(&db.connection, &new_project_name)? {
1361 Some(p) => p,
1362 None => {
1363 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", new_project_name);
1364 return Ok(());
1365 }
1366 };
1367
1368 let updated =
1369 SessionQueries::bulk_update_project(&db.connection, &session_ids, project.id.unwrap())?;
1370
1371 CliFormatter::print_section_header("Bulk Session Update");
1372 CliFormatter::print_field("Sessions", &updated.to_string(), Some("white"));
1373 CliFormatter::print_field("Project", &new_project_name, Some("yellow"));
1374 CliFormatter::print_success(&format!("{} sessions updated", updated));
1375
1376 Ok(())
1377}
1378
1379#[allow(dead_code)]
1380async fn bulk_delete_sessions(session_ids: Vec<i64>) -> Result<()> {
1381 let db_path = get_database_path()?;
1382 let db = Database::new(&db_path)?;
1383
1384 let deleted = SessionQueries::bulk_delete(&db.connection, &session_ids)?;
1385
1386 CliFormatter::print_section_header("Bulk Session Delete");
1387 CliFormatter::print_field("Requested", &session_ids.len().to_string(), Some("white"));
1388 CliFormatter::print_field("Deleted", &deleted.to_string(), Some("green"));
1389 CliFormatter::print_success(&format!("{} sessions deleted", deleted));
1390
1391 Ok(())
1392}
1393
1394async fn launch_dashboard() -> Result<()> {
1395 if !is_tty() {
1397 return show_dashboard_fallback().await;
1398 }
1399
1400 enable_raw_mode()
1402 .context("Failed to enable raw mode - terminal may not support interactive features")?;
1403 let mut stdout = io::stdout();
1404
1405 execute!(stdout, EnterAlternateScreen)
1406 .context("Failed to enter alternate screen - terminal may not support full-screen mode")?;
1407
1408 let backend = CrosstermBackend::new(stdout);
1409 let mut terminal = Terminal::new(backend).context("Failed to initialize terminal backend")?;
1410
1411 terminal.clear().context("Failed to clear terminal")?;
1413
1414 let result = async {
1416 let mut dashboard = Dashboard::new().await?;
1417 dashboard.run(&mut terminal).await
1418 };
1419
1420 let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
1421
1422 let cleanup_result = cleanup_terminal(&mut terminal);
1424
1425 if let Err(e) = cleanup_result {
1427 eprintln!("Warning: Failed to restore terminal: {}", e);
1428 }
1429
1430 result
1431}
1432
1433fn is_tty() -> bool {
1434 use std::os::unix::io::AsRawFd;
1435 unsafe { libc::isatty(std::io::stdin().as_raw_fd()) == 1 }
1436}
1437
1438async fn show_dashboard_fallback() -> Result<()> {
1439 println!("📊 Tempo Dashboard (Text Mode)");
1440 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
1441 println!();
1442
1443 if is_daemon_running() {
1445 println!("🟢 Daemon Status: Running");
1446 } else {
1447 println!("🔴 Daemon Status: Offline");
1448 println!(" Start with: tempo start");
1449 println!();
1450 return Ok(());
1451 }
1452
1453 let socket_path = get_socket_path()?;
1455 if let Ok(mut client) = IpcClient::connect(&socket_path).await {
1456 match client.send_message(&IpcMessage::GetActiveSession).await {
1457 Ok(IpcResponse::ActiveSession(Some(session))) => {
1458 println!("⏱️ Active Session:");
1459 println!(" Started: {}", session.start_time.format("%H:%M:%S"));
1460 println!(
1461 " Duration: {}",
1462 format_duration_simple(
1463 (chrono::Utc::now().timestamp() - session.start_time.timestamp())
1464 - session.paused_duration.num_seconds()
1465 )
1466 );
1467 println!(" Context: {}", session.context);
1468 println!();
1469
1470 match client
1472 .send_message(&IpcMessage::GetProject(session.project_id))
1473 .await
1474 {
1475 Ok(IpcResponse::Project(Some(project))) => {
1476 println!("📁 Current Project: {}", project.name);
1477 println!(" Path: {}", project.path.display());
1478 println!();
1479 }
1480 _ => {
1481 println!("📁 Project: Unknown");
1482 println!();
1483 }
1484 }
1485 }
1486 _ => {
1487 println!("⏸️ No active session");
1488 println!(" Start tracking with: tempo session start");
1489 println!();
1490 }
1491 }
1492
1493 let today = chrono::Local::now().date_naive();
1495 match client.send_message(&IpcMessage::GetDailyStats(today)).await {
1496 Ok(IpcResponse::DailyStats {
1497 sessions_count,
1498 total_seconds,
1499 avg_seconds,
1500 }) => {
1501 println!("📈 Today's Summary:");
1502 println!(" Sessions: {}", sessions_count);
1503 println!(" Total time: {}", format_duration_simple(total_seconds));
1504 if sessions_count > 0 {
1505 println!(
1506 " Average session: {}",
1507 format_duration_simple(avg_seconds)
1508 );
1509 }
1510 let progress = (total_seconds as f64 / (8.0 * 3600.0)) * 100.0;
1511 println!(" Daily goal (8h): {:.1}%", progress);
1512 println!();
1513 }
1514 _ => {
1515 println!("📈 Today's Summary: No data available");
1516 println!();
1517 }
1518 }
1519 } else {
1520 println!("❌ Unable to connect to daemon");
1521 println!(" Try: tempo restart");
1522 println!();
1523 }
1524
1525 println!("💡 For interactive dashboard, run in a terminal:");
1526 println!(" • Terminal.app, iTerm2, or other terminal emulators");
1527 println!(" • SSH sessions with TTY allocation (ssh -t)");
1528 println!(" • Interactive shell environments");
1529
1530 Ok(())
1531}
1532
1533fn format_duration_simple(seconds: i64) -> String {
1534 let hours = seconds / 3600;
1535 let minutes = (seconds % 3600) / 60;
1536 let secs = seconds % 60;
1537
1538 if hours > 0 {
1539 format!("{}h {}m {}s", hours, minutes, secs)
1540 } else if minutes > 0 {
1541 format!("{}m {}s", minutes, secs)
1542 } else {
1543 format!("{}s", secs)
1544 }
1545}
1546
1547fn cleanup_terminal<B>(terminal: &mut Terminal<B>) -> Result<()>
1548where
1549 B: ratatui::backend::Backend + std::io::Write,
1550{
1551 disable_raw_mode().context("Failed to disable raw mode")?;
1553 execute!(terminal.backend_mut(), LeaveAlternateScreen)
1554 .context("Failed to leave alternate screen")?;
1555 terminal.show_cursor().context("Failed to show cursor")?;
1556 Ok(())
1557}
1558
1559async fn launch_timer() -> Result<()> {
1560 if !is_tty() {
1562 return Err(anyhow::anyhow!(
1563 "Interactive timer requires an interactive terminal (TTY).\n\
1564 \n\
1565 This command needs to run in a proper terminal environment.\n\
1566 Try running this command directly in your terminal application."
1567 ));
1568 }
1569
1570 enable_raw_mode().context("Failed to enable raw mode")?;
1572 let mut stdout = io::stdout();
1573 execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
1574 let backend = CrosstermBackend::new(stdout);
1575 let mut terminal = Terminal::new(backend).context("Failed to initialize terminal")?;
1576 terminal.clear().context("Failed to clear terminal")?;
1577
1578 let result = async {
1580 let mut timer = InteractiveTimer::new().await?;
1581 timer.run(&mut terminal).await
1582 };
1583
1584 let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
1585
1586 let cleanup_result = cleanup_terminal(&mut terminal);
1588 if let Err(e) = cleanup_result {
1589 eprintln!("Warning: Failed to restore terminal: {}", e);
1590 }
1591
1592 result
1593}
1594
1595async fn merge_sessions(
1596 session_ids_str: String,
1597 project_name: Option<String>,
1598 notes: Option<String>,
1599) -> Result<()> {
1600 let session_ids: Result<Vec<i64>, _> = session_ids_str
1602 .split(',')
1603 .map(|s| s.trim().parse::<i64>())
1604 .collect();
1605
1606 let session_ids = session_ids.map_err(|_| {
1607 anyhow::anyhow!("Invalid session IDs format. Use comma-separated numbers like '1,2,3'")
1608 })?;
1609
1610 if session_ids.len() < 2 {
1611 return Err(anyhow::anyhow!(
1612 "At least 2 sessions are required for merging"
1613 ));
1614 }
1615
1616 let mut target_project_id = None;
1618 if let Some(project) = project_name {
1619 let db_path = get_database_path()?;
1620 let db = Database::new(&db_path)?;
1621
1622 if let Ok(project_id) = project.parse::<i64>() {
1624 if ProjectQueries::find_by_id(&db.connection, project_id)?.is_some() {
1625 target_project_id = Some(project_id);
1626 }
1627 } else if let Some(proj) = ProjectQueries::find_by_name(&db.connection, &project)? {
1628 target_project_id = proj.id;
1629 }
1630
1631 if target_project_id.is_none() {
1632 return Err(anyhow::anyhow!("Project '{}' not found", project));
1633 }
1634 }
1635
1636 let db_path = get_database_path()?;
1638 let db = Database::new(&db_path)?;
1639
1640 let merged_id =
1641 SessionQueries::merge_sessions(&db.connection, &session_ids, target_project_id, notes)?;
1642
1643 CliFormatter::print_section_header("Session Merge Complete");
1644 CliFormatter::print_field("Merged sessions", &session_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(", "), Some("yellow"));
1645 CliFormatter::print_field("New session ID", &merged_id.to_string(), Some("green"));
1646 CliFormatter::print_success("Sessions successfully merged");
1647
1648 Ok(())
1649}
1650
1651async fn split_session(
1652 session_id: i64,
1653 split_times_str: String,
1654 notes: Option<String>,
1655) -> Result<()> {
1656 let split_time_strings: Vec<&str> = split_times_str.split(',').map(|s| s.trim()).collect();
1658 let mut split_times = Vec::new();
1659
1660 for time_str in split_time_strings {
1661 let datetime = if time_str.contains(':') {
1663 let today = chrono::Local::now().date_naive();
1665 let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M")
1666 .or_else(|_| chrono::NaiveTime::parse_from_str(time_str, "%H:%M:%S"))
1667 .map_err(|_| {
1668 anyhow::anyhow!("Invalid time format '{}'. Use HH:MM or HH:MM:SS", time_str)
1669 })?;
1670 today.and_time(time).and_utc()
1671 } else {
1672 chrono::DateTime::parse_from_rfc3339(time_str)
1674 .map_err(|_| {
1675 anyhow::anyhow!(
1676 "Invalid datetime format '{}'. Use HH:MM or RFC3339 format",
1677 time_str
1678 )
1679 })?
1680 .to_utc()
1681 };
1682
1683 split_times.push(datetime);
1684 }
1685
1686 if split_times.is_empty() {
1687 return Err(anyhow::anyhow!("No valid split times provided"));
1688 }
1689
1690 let notes_list = notes.map(|n| {
1692 n.split(',')
1693 .map(|s| s.trim().to_string())
1694 .collect::<Vec<String>>()
1695 });
1696
1697 let db_path = get_database_path()?;
1699 let db = Database::new(&db_path)?;
1700
1701 let new_session_ids =
1702 SessionQueries::split_session(&db.connection, session_id, &split_times, notes_list)?;
1703
1704 CliFormatter::print_section_header("Session Split Complete");
1705 CliFormatter::print_field("Original session", &session_id.to_string(), Some("yellow"));
1706 CliFormatter::print_field("Split points", &split_times.len().to_string(), Some("gray"));
1707 CliFormatter::print_field("New sessions", &new_session_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(", "), Some("green"));
1708 CliFormatter::print_success("Session successfully split");
1709
1710 Ok(())
1711}
1712
1713async fn launch_history() -> Result<()> {
1714 if !is_tty() {
1716 return Err(anyhow::anyhow!(
1717 "Session history browser requires an interactive terminal (TTY).\n\
1718 \n\
1719 This command needs to run in a proper terminal environment.\n\
1720 Try running this command directly in your terminal application."
1721 ));
1722 }
1723
1724 enable_raw_mode().context("Failed to enable raw mode")?;
1726 let mut stdout = io::stdout();
1727 execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?;
1728 let backend = CrosstermBackend::new(stdout);
1729 let mut terminal = Terminal::new(backend).context("Failed to initialize terminal")?;
1730 terminal.clear().context("Failed to clear terminal")?;
1731
1732 let result = async {
1733 let mut browser = SessionHistoryBrowser::new().await?;
1734 browser.run(&mut terminal).await
1735 };
1736
1737 let result = tokio::task::block_in_place(|| Handle::current().block_on(result));
1738
1739 let cleanup_result = cleanup_terminal(&mut terminal);
1741 if let Err(e) = cleanup_result {
1742 eprintln!("Warning: Failed to restore terminal: {}", e);
1743 }
1744
1745 result
1746}
1747
1748async fn handle_goal_action(action: GoalAction) -> Result<()> {
1749 match action {
1750 GoalAction::Create {
1751 name,
1752 target_hours,
1753 project,
1754 description,
1755 start_date,
1756 end_date,
1757 } => {
1758 create_goal(
1759 name,
1760 target_hours,
1761 project,
1762 description,
1763 start_date,
1764 end_date,
1765 )
1766 .await
1767 }
1768 GoalAction::List { project } => list_goals(project).await,
1769 GoalAction::Update { id, hours } => update_goal_progress(id, hours).await,
1770 }
1771}
1772
1773async fn create_goal(
1774 name: String,
1775 target_hours: f64,
1776 project: Option<String>,
1777 description: Option<String>,
1778 start_date: Option<String>,
1779 end_date: Option<String>,
1780) -> Result<()> {
1781 let db_path = get_database_path()?;
1782 let db = Database::new(&db_path)?;
1783
1784 let project_id = if let Some(proj_name) = project {
1785 match ProjectQueries::find_by_name(&db.connection, &proj_name)? {
1786 Some(p) => p.id,
1787 None => {
1788 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", proj_name);
1789 return Ok(());
1790 }
1791 }
1792 } else {
1793 None
1794 };
1795
1796 let start = start_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
1797 let end = end_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
1798
1799 let mut goal = Goal::new(name.clone(), target_hours);
1800 if let Some(pid) = project_id {
1801 goal = goal.with_project(pid);
1802 }
1803 if let Some(desc) = description {
1804 goal = goal.with_description(desc);
1805 }
1806 goal = goal.with_dates(start, end);
1807
1808 goal.validate()?;
1809 let goal_id = GoalQueries::create(&db.connection, &goal)?;
1810
1811 CliFormatter::print_section_header("Goal Created");
1812 CliFormatter::print_field_bold("Name", &name, Some("yellow"));
1813 CliFormatter::print_field("Target", &format!("{} hours", target_hours), Some("green"));
1814 CliFormatter::print_field("ID", &goal_id.to_string(), Some("gray"));
1815 CliFormatter::print_success("Goal created successfully");
1816
1817 Ok(())
1818}
1819
1820async fn list_goals(project: Option<String>) -> Result<()> {
1821 let db_path = get_database_path()?;
1822 let db = Database::new(&db_path)?;
1823
1824 let project_id = if let Some(proj_name) = &project {
1825 match ProjectQueries::find_by_name(&db.connection, proj_name)? {
1826 Some(p) => p.id,
1827 None => {
1828 println!("\x1b[31m✗ Project '{}' not found\x1b[0m", proj_name);
1829 return Ok(());
1830 }
1831 }
1832 } else {
1833 None
1834 };
1835
1836 let goals = GoalQueries::list_by_project(&db.connection, project_id)?;
1837
1838 if goals.is_empty() {
1839 CliFormatter::print_section_header("No Goals");
1840 CliFormatter::print_empty_state("No goals found.");
1841 return Ok(());
1842 }
1843
1844 CliFormatter::print_section_header("Goals");
1845
1846 for goal in &goals {
1847 let progress_pct = goal.progress_percentage();
1848 println!(" 🎯 {}", ansi_color("yellow", &goal.name, true));
1849 println!(" Progress: {}% ({:.1}h / {:.1}h)",
1850 ansi_color("green", &format!("{:.1}", progress_pct), false),
1851 goal.current_progress, goal.target_hours);
1852 println!();
1853 }
1854 Ok(())
1855}
1856
1857async fn update_goal_progress(id: i64, hours: f64) -> Result<()> {
1858 let db_path = get_database_path()?;
1859 let db = Database::new(&db_path)?;
1860
1861 GoalQueries::update_progress(&db.connection, id, hours)?;
1862 CliFormatter::print_success(&format!("Updated goal {} progress by {} hours", id, hours));
1863 Ok(())
1864}
1865
1866async fn show_insights(period: Option<String>, project: Option<String>) -> Result<()> {
1867 CliFormatter::print_section_header("Productivity Insights");
1868 CliFormatter::print_field("Period", period.as_deref().unwrap_or("all"), Some("yellow"));
1869 if let Some(proj) = project {
1870 CliFormatter::print_field("Project", &truncate_string(&proj, 40), Some("yellow"));
1871 }
1872 println!();
1873 CliFormatter::print_warning("Insights calculation in progress...");
1874 Ok(())
1875}
1876
1877async fn show_summary(period: String, from: Option<String>) -> Result<()> {
1878 let db_path = get_database_path()?;
1879 let db = Database::new(&db_path)?;
1880
1881 let start_date = if let Some(from_str) = from {
1882 chrono::NaiveDate::parse_from_str(&from_str, "%Y-%m-%d")?
1883 } else {
1884 match period.as_str() {
1885 "week" => chrono::Local::now().date_naive() - chrono::Duration::days(7),
1886 "month" => chrono::Local::now().date_naive() - chrono::Duration::days(30),
1887 _ => chrono::Local::now().date_naive(),
1888 }
1889 };
1890
1891 let insight_data = match period.as_str() {
1892 "week" => InsightQueries::calculate_weekly_summary(&db.connection, start_date)?,
1893 "month" => InsightQueries::calculate_monthly_summary(&db.connection, start_date)?,
1894 _ => return Err(anyhow::anyhow!("Invalid period. Use 'week' or 'month'")),
1895 };
1896
1897 CliFormatter::print_section_header(&format!("{} Summary", period));
1898 CliFormatter::print_field(
1899 "Total Hours",
1900 &format!("{:.1}h", insight_data.total_hours),
1901 Some("green"),
1902 );
1903 CliFormatter::print_field(
1904 "Sessions",
1905 &insight_data.sessions_count.to_string(),
1906 Some("yellow"),
1907 );
1908 CliFormatter::print_field(
1909 "Avg Session",
1910 &format!("{:.1}h", insight_data.avg_session_duration),
1911 Some("yellow"),
1912 );
1913 Ok(())
1914}
1915
1916async fn compare_projects(
1917 projects: String,
1918 _from: Option<String>,
1919 _to: Option<String>,
1920) -> Result<()> {
1921 let _project_names: Vec<&str> = projects.split(',').map(|s| s.trim()).collect();
1922
1923 CliFormatter::print_section_header("Project Comparison");
1924 CliFormatter::print_field("Projects", &truncate_string(&projects, 60), Some("yellow"));
1925 println!();
1926 CliFormatter::print_warning("Comparison feature in development");
1927 Ok(())
1928}
1929
1930async fn handle_estimate_action(action: EstimateAction) -> Result<()> {
1931 match action {
1932 EstimateAction::Create {
1933 project,
1934 task,
1935 hours,
1936 due_date,
1937 } => create_estimate(project, task, hours, due_date).await,
1938 EstimateAction::Record { id, hours } => record_actual_time(id, hours).await,
1939 EstimateAction::List { project } => list_estimates(project).await,
1940 }
1941}
1942
1943async fn create_estimate(
1944 project: String,
1945 task: String,
1946 hours: f64,
1947 due_date: Option<String>,
1948) -> Result<()> {
1949 let db_path = get_database_path()?;
1950 let db = Database::new(&db_path)?;
1951
1952 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
1953 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
1954
1955 let due = due_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
1956
1957 let mut estimate = TimeEstimate::new(project_obj.id.unwrap(), task.clone(), hours);
1958 estimate.due_date = due;
1959
1960 let estimate_id = TimeEstimateQueries::create(&db.connection, &estimate)?;
1961
1962 CliFormatter::print_section_header("Time Estimate Created");
1963 CliFormatter::print_field_bold("Task", &task, Some("yellow"));
1964 CliFormatter::print_field("Estimate", &format!("{} hours", hours), Some("green"));
1965 CliFormatter::print_field("ID", &estimate_id.to_string(), Some("gray"));
1966 Ok(())
1967}
1968
1969async fn record_actual_time(id: i64, hours: f64) -> Result<()> {
1970 let db_path = get_database_path()?;
1971 let db = Database::new(&db_path)?;
1972
1973 TimeEstimateQueries::record_actual(&db.connection, id, hours)?;
1974 println!(
1975 "\x1b[32m✓ Recorded {} hours for estimate {}\x1b[0m",
1976 hours, id
1977 );
1978 Ok(())
1979}
1980
1981async fn list_estimates(project: String) -> Result<()> {
1982 let db_path = get_database_path()?;
1983 let db = Database::new(&db_path)?;
1984
1985 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
1986 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
1987
1988 let estimates = TimeEstimateQueries::list_by_project(&db.connection, project_obj.id.unwrap())?;
1989
1990 CliFormatter::print_section_header("Time Estimates");
1991
1992 for est in &estimates {
1993 let variance = est.variance();
1994 let variance_str = if let Some(v) = variance {
1995 if v > 0.0 {
1996 ansi_color("red", &format!("+{:.1}h over", v), false)
1997 } else {
1998 ansi_color("green", &format!("{:.1}h under", v.abs()), false)
1999 }
2000 } else {
2001 "N/A".to_string()
2002 };
2003
2004 println!(" 📋 {}", ansi_color("yellow", &est.task_name, true));
2005 let actual_str = est
2006 .actual_hours
2007 .map(|h| format!("{:.1}h", h))
2008 .unwrap_or_else(|| "N/A".to_string());
2009 println!(" Est: {}h | Actual: {} | {}",
2010 est.estimated_hours, actual_str, variance_str);
2011 println!();
2012 }
2013 Ok(())
2014}
2015
2016async fn handle_branch_action(action: BranchAction) -> Result<()> {
2017 match action {
2018 BranchAction::List { project } => list_branches(project).await,
2019 BranchAction::Stats { project, branch } => show_branch_stats(project, branch).await,
2020 }
2021}
2022
2023async fn list_branches(project: String) -> Result<()> {
2024 let db_path = get_database_path()?;
2025 let db = Database::new(&db_path)?;
2026
2027 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2028 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2029
2030 let branches = GitBranchQueries::list_by_project(&db.connection, project_obj.id.unwrap())?;
2031
2032 CliFormatter::print_section_header("Git Branches");
2033
2034 for branch in &branches {
2035 println!(" 🌿 {}", ansi_color("yellow", &branch.branch_name, true));
2036 println!(" Time: {}", ansi_color("green", &format!("{:.1}h", branch.total_hours()), false));
2037 println!();
2038 }
2039 Ok(())
2040}
2041
2042async fn show_branch_stats(project: String, branch: Option<String>) -> Result<()> {
2043 CliFormatter::print_section_header("Branch Statistics");
2044 CliFormatter::print_field("Project", &project, Some("yellow"));
2045 if let Some(b) = branch {
2046 CliFormatter::print_field("Branch", &b, Some("yellow"));
2047 }
2048 CliFormatter::print_warning("Branch stats in development");
2049 Ok(())
2050}
2051
2052async fn handle_template_action(action: TemplateAction) -> Result<()> {
2054 match action {
2055 TemplateAction::Create {
2056 name,
2057 description,
2058 tags,
2059 workspace_path,
2060 } => create_template(name, description, tags, workspace_path).await,
2061 TemplateAction::List => list_templates().await,
2062 TemplateAction::Delete { template } => delete_template(template).await,
2063 TemplateAction::Use {
2064 template,
2065 project_name,
2066 path,
2067 } => use_template(template, project_name, path).await,
2068 }
2069}
2070
2071async fn create_template(
2072 name: String,
2073 description: Option<String>,
2074 tags: Option<String>,
2075 workspace_path: Option<PathBuf>,
2076) -> Result<()> {
2077 let db_path = get_database_path()?;
2078 let db = Database::new(&db_path)?;
2079
2080 let default_tags = tags
2081 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect())
2082 .unwrap_or_default();
2083
2084 let mut template = ProjectTemplate::new(name.clone()).with_tags(default_tags);
2085
2086 let desc_clone = description.clone();
2087 if let Some(desc) = description {
2088 template = template.with_description(desc);
2089 }
2090 if let Some(path) = workspace_path {
2091 template = template.with_workspace_path(path);
2092 }
2093
2094 let _template_id = TemplateQueries::create(&db.connection, &template)?;
2095
2096 CliFormatter::print_section_header("Template Created");
2097 CliFormatter::print_field_bold("Name", &name, Some("yellow"));
2098 if let Some(desc) = &desc_clone {
2099 CliFormatter::print_field("Description", desc, Some("gray"));
2100 }
2101 Ok(())
2102}
2103
2104async fn list_templates() -> Result<()> {
2105 let db_path = get_database_path()?;
2106 let db = Database::new(&db_path)?;
2107
2108 let templates = TemplateQueries::list_all(&db.connection)?;
2109
2110 CliFormatter::print_section_header("Templates");
2111
2112 if templates.is_empty() {
2113 CliFormatter::print_empty_state("No templates found.");
2114 } else {
2115 for template in &templates {
2116 println!(
2117 " 📋 {}",
2118 ansi_color("yellow", &truncate_string(&template.name, 25), true)
2119 );
2120 if let Some(desc) = &template.description {
2121 println!(" {}", truncate_string(desc, 27).dimmed());
2122 }
2123 println!();
2124 }
2125 }
2126 Ok(())
2127}
2128
2129async fn delete_template(_template: String) -> Result<()> {
2130 println!("\x1b[33m⚠ Template deletion not yet implemented\x1b[0m");
2131 Ok(())
2132}
2133
2134async fn use_template(template: String, project_name: String, path: Option<PathBuf>) -> Result<()> {
2135 let db_path = get_database_path()?;
2136 let db = Database::new(&db_path)?;
2137
2138 let templates = TemplateQueries::list_all(&db.connection)?;
2139 let selected_template = templates
2140 .iter()
2141 .find(|t| t.name == template || t.id.map(|id| id.to_string()) == Some(template.clone()))
2142 .ok_or_else(|| anyhow::anyhow!("Template '{}' not found", template))?;
2143
2144 let project_path = path.unwrap_or_else(|| env::current_dir().unwrap());
2146 let canonical_path = canonicalize_path(&project_path)?;
2147
2148 if ProjectQueries::find_by_path(&db.connection, &canonical_path)?.is_some() {
2150 return Err(anyhow::anyhow!("Project already exists at this path"));
2151 }
2152
2153 let git_hash = if is_git_repository(&canonical_path) {
2154 get_git_hash(&canonical_path)
2155 } else {
2156 None
2157 };
2158
2159 let template_desc = selected_template.description.clone();
2160 let mut project = Project::new(project_name.clone(), canonical_path.clone())
2161 .with_git_hash(git_hash)
2162 .with_description(template_desc);
2163
2164 let project_id = ProjectQueries::create(&db.connection, &project)?;
2165 project.id = Some(project_id);
2166
2167 for goal_def in &selected_template.default_goals {
2172 let mut goal =
2173 Goal::new(goal_def.name.clone(), goal_def.target_hours).with_project(project_id);
2174 if let Some(desc) = &goal_def.description {
2175 goal = goal.with_description(desc.clone());
2176 }
2177 GoalQueries::create(&db.connection, &goal)?;
2178 }
2179
2180 CliFormatter::print_section_header("Project Created from Template");
2181 CliFormatter::print_field_bold("Template", &selected_template.name, Some("yellow"));
2182 CliFormatter::print_field_bold("Project", &project_name, Some("yellow"));
2183 Ok(())
2184}
2185
2186async fn handle_workspace_action(action: WorkspaceAction) -> Result<()> {
2188 match action {
2189 WorkspaceAction::Create {
2190 name,
2191 description,
2192 path,
2193 } => create_workspace(name, description, path).await,
2194 WorkspaceAction::List => list_workspaces().await,
2195 WorkspaceAction::AddProject { workspace, project } => {
2196 add_project_to_workspace(workspace, project).await
2197 }
2198 WorkspaceAction::RemoveProject { workspace, project } => {
2199 remove_project_from_workspace(workspace, project).await
2200 }
2201 WorkspaceAction::Projects { workspace } => list_workspace_projects(workspace).await,
2202 WorkspaceAction::Delete { workspace } => delete_workspace(workspace).await,
2203 }
2204}
2205
2206async fn create_workspace(
2207 name: String,
2208 description: Option<String>,
2209 path: Option<PathBuf>,
2210) -> Result<()> {
2211 let db_path = get_database_path()?;
2212 let db = Database::new(&db_path)?;
2213
2214 let mut workspace = Workspace::new(name.clone());
2215 let desc_clone = description.clone();
2216 if let Some(desc) = description {
2217 workspace = workspace.with_description(desc);
2218 }
2219 if let Some(p) = path {
2220 workspace = workspace.with_path(p);
2221 }
2222
2223 let _workspace_id = WorkspaceQueries::create(&db.connection, &workspace)?;
2224
2225 CliFormatter::print_section_header("Workspace Created");
2226 CliFormatter::print_field_bold("Name", &name, Some("yellow"));
2227 if let Some(desc) = &desc_clone {
2228 CliFormatter::print_field("Description", desc, Some("gray"));
2229 }
2230 Ok(())
2231}
2232
2233async fn list_workspaces() -> Result<()> {
2234 let db_path = get_database_path()?;
2235 let db = Database::new(&db_path)?;
2236
2237 let workspaces = WorkspaceQueries::list_all(&db.connection)?;
2238
2239 CliFormatter::print_section_header("Workspaces");
2240
2241 if workspaces.is_empty() {
2242 CliFormatter::print_empty_state("No workspaces found.");
2243 } else {
2244 for workspace in &workspaces {
2245 println!(
2246 " 📁 {}",
2247 ansi_color("yellow", &truncate_string(&workspace.name, 25), true)
2248 );
2249 if let Some(desc) = &workspace.description {
2250 println!(" {}", truncate_string(desc, 27).dimmed());
2251 }
2252 println!();
2253 }
2254 }
2255 Ok(())
2256}
2257
2258async fn add_project_to_workspace(workspace: String, project: String) -> Result<()> {
2259 let db_path = get_database_path()?;
2260 let db = Database::new(&db_path)?;
2261
2262 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2264 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2265
2266 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2268 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2269
2270 let workspace_id = workspace_obj
2271 .id
2272 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2273 let project_id = project_obj
2274 .id
2275 .ok_or_else(|| anyhow::anyhow!("Project ID is missing"))?;
2276
2277 if WorkspaceQueries::add_project(&db.connection, workspace_id, project_id)? {
2278 println!(
2279 "\x1b[32m✓\x1b[0m Added project '\x1b[33m{}\x1b[0m' to workspace '\x1b[33m{}\x1b[0m'",
2280 project, workspace
2281 );
2282 } else {
2283 println!("\x1b[33m⚠\x1b[0m Project '\x1b[33m{}\x1b[0m' is already in workspace '\x1b[33m{}\x1b[0m'", project, workspace);
2284 }
2285
2286 Ok(())
2287}
2288
2289async fn remove_project_from_workspace(workspace: String, project: String) -> Result<()> {
2290 let db_path = get_database_path()?;
2291 let db = Database::new(&db_path)?;
2292
2293 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2295 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2296
2297 let project_obj = ProjectQueries::find_by_name(&db.connection, &project)?
2299 .ok_or_else(|| anyhow::anyhow!("Project '{}' not found", project))?;
2300
2301 let workspace_id = workspace_obj
2302 .id
2303 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2304 let project_id = project_obj
2305 .id
2306 .ok_or_else(|| anyhow::anyhow!("Project ID is missing"))?;
2307
2308 if WorkspaceQueries::remove_project(&db.connection, workspace_id, project_id)? {
2309 println!("\x1b[32m✓\x1b[0m Removed project '\x1b[33m{}\x1b[0m' from workspace '\x1b[33m{}\x1b[0m'", project, workspace);
2310 } else {
2311 println!(
2312 "\x1b[33m⚠\x1b[0m Project '\x1b[33m{}\x1b[0m' was not in workspace '\x1b[33m{}\x1b[0m'",
2313 project, workspace
2314 );
2315 }
2316
2317 Ok(())
2318}
2319
2320async fn list_workspace_projects(workspace: String) -> Result<()> {
2321 let db_path = get_database_path()?;
2322 let db = Database::new(&db_path)?;
2323
2324 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2326 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2327
2328 let workspace_id = workspace_obj
2329 .id
2330 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2331 let projects = WorkspaceQueries::list_projects(&db.connection, workspace_id)?;
2332
2333 if projects.is_empty() {
2334 println!(
2335 "\x1b[33m⚠\x1b[0m No projects found in workspace '\x1b[33m{}\x1b[0m'",
2336 workspace
2337 );
2338 return Ok(());
2339 }
2340
2341 println!("\x1b[36m┌─────────────────────────────────────────┐\x1b[0m");
2342 println!("\x1b[36m│\x1b[0m \x1b[1;37mWorkspace Projects\x1b[0m \x1b[36m│\x1b[0m");
2343 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2344 println!(
2345 "\x1b[36m│\x1b[0m Workspace: \x1b[33m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2346 truncate_string(&workspace, 25)
2347 );
2348 println!(
2349 "\x1b[36m│\x1b[0m Projects: \x1b[32m{:<25}\x1b[0m \x1b[36m│\x1b[0m",
2350 format!("{} projects", projects.len())
2351 );
2352 println!("\x1b[36m├─────────────────────────────────────────┤\x1b[0m");
2353
2354 for project in &projects {
2355 let status_indicator = if !project.is_archived {
2356 "\x1b[32m●\x1b[0m"
2357 } else {
2358 "\x1b[31m○\x1b[0m"
2359 };
2360 println!(
2361 "\x1b[36m│\x1b[0m {} \x1b[37m{:<33}\x1b[0m \x1b[36m│\x1b[0m",
2362 status_indicator,
2363 truncate_string(&project.name, 33)
2364 );
2365 }
2366
2367 println!("\x1b[36m└─────────────────────────────────────────┘\x1b[0m");
2368 Ok(())
2369}
2370
2371async fn delete_workspace(workspace: String) -> Result<()> {
2372 let db_path = get_database_path()?;
2373 let db = Database::new(&db_path)?;
2374
2375 let workspace_obj = WorkspaceQueries::find_by_name(&db.connection, &workspace)?
2377 .ok_or_else(|| anyhow::anyhow!("Workspace '{}' not found", workspace))?;
2378
2379 let workspace_id = workspace_obj
2380 .id
2381 .ok_or_else(|| anyhow::anyhow!("Workspace ID is missing"))?;
2382
2383 let projects = WorkspaceQueries::list_projects(&db.connection, workspace_id)?;
2385 if !projects.is_empty() {
2386 CliFormatter::print_warning(&format!("Cannot delete workspace '{}' - it contains {} project(s). Remove projects first.", workspace, projects.len()));
2387 return Ok(());
2388 }
2389
2390 if WorkspaceQueries::delete(&db.connection, workspace_id)? {
2391 CliFormatter::print_success(&format!("Deleted workspace '{}'", workspace));
2392 } else {
2393 CliFormatter::print_error(&format!("Failed to delete workspace '{}'", workspace));
2394 }
2395
2396 Ok(())
2397}
2398
2399async fn handle_calendar_action(action: CalendarAction) -> Result<()> {
2401 match action {
2402 CalendarAction::Add {
2403 name,
2404 start,
2405 end,
2406 event_type,
2407 project,
2408 description,
2409 } => add_calendar_event(name, start, end, event_type, project, description).await,
2410 CalendarAction::List { from, to, project } => list_calendar_events(from, to, project).await,
2411 CalendarAction::Delete { id } => delete_calendar_event(id).await,
2412 }
2413}
2414
2415async fn add_calendar_event(
2416 _name: String,
2417 _start: String,
2418 _end: Option<String>,
2419 _event_type: Option<String>,
2420 _project: Option<String>,
2421 _description: Option<String>,
2422) -> Result<()> {
2423 println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
2424 Ok(())
2425}
2426
2427async fn list_calendar_events(
2428 _from: Option<String>,
2429 _to: Option<String>,
2430 _project: Option<String>,
2431) -> Result<()> {
2432 println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
2433 Ok(())
2434}
2435
2436async fn delete_calendar_event(_id: i64) -> Result<()> {
2437 println!("\x1b[33m⚠ Calendar integration in development\x1b[0m");
2438 Ok(())
2439}
2440
2441async fn handle_issue_action(action: IssueAction) -> Result<()> {
2443 match action {
2444 IssueAction::Sync {
2445 project,
2446 tracker_type,
2447 } => sync_issues(project, tracker_type).await,
2448 IssueAction::List { project, status } => list_issues(project, status).await,
2449 IssueAction::Link {
2450 session_id,
2451 issue_id,
2452 } => link_session_to_issue(session_id, issue_id).await,
2453 }
2454}
2455
2456async fn sync_issues(_project: String, _tracker_type: Option<String>) -> Result<()> {
2457 println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
2458 Ok(())
2459}
2460
2461async fn list_issues(_project: String, _status: Option<String>) -> Result<()> {
2462 println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
2463 Ok(())
2464}
2465
2466async fn link_session_to_issue(_session_id: i64, _issue_id: String) -> Result<()> {
2467 println!("\x1b[33m⚠ Issue tracker integration in development\x1b[0m");
2468 Ok(())
2469}
2470
2471async fn handle_client_action(action: ClientAction) -> Result<()> {
2473 match action {
2474 ClientAction::Generate {
2475 client,
2476 from,
2477 to,
2478 projects,
2479 format,
2480 } => generate_client_report(client, from, to, projects, format).await,
2481 ClientAction::List { client } => list_client_reports(client).await,
2482 ClientAction::View { id } => view_client_report(id).await,
2483 }
2484}
2485
2486async fn generate_client_report(
2487 _client: String,
2488 _from: String,
2489 _to: String,
2490 _projects: Option<String>,
2491 _format: Option<String>,
2492) -> Result<()> {
2493 println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
2494 Ok(())
2495}
2496
2497async fn list_client_reports(_client: Option<String>) -> Result<()> {
2498 println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
2499 Ok(())
2500}
2501
2502async fn view_client_report(_id: i64) -> Result<()> {
2503 println!("\x1b[33m⚠ Client reporting in development\x1b[0m");
2504 Ok(())
2505}
2506
2507#[allow(dead_code)]
2508fn should_quit(event: crossterm::event::Event) -> bool {
2509 match event {
2510 crossterm::event::Event::Key(key) if key.kind == crossterm::event::KeyEventKind::Press => {
2511 matches!(
2512 key.code,
2513 crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc
2514 )
2515 }
2516 _ => false,
2517 }
2518}
2519
2520async fn init_project_with_db(
2522 name: Option<String>,
2523 canonical_path: Option<PathBuf>,
2524 description: Option<String>,
2525 conn: &rusqlite::Connection,
2526) -> Result<()> {
2527 let canonical_path =
2528 canonical_path.ok_or_else(|| anyhow::anyhow!("Canonical path required"))?;
2529 let project_name = name.unwrap_or_else(|| detect_project_name(&canonical_path));
2530
2531 if let Some(existing) = ProjectQueries::find_by_path(conn, &canonical_path)? {
2533 println!(
2534 "\x1b[33m⚠ Project already exists:\x1b[0m {}",
2535 existing.name
2536 );
2537 return Ok(());
2538 }
2539
2540 let git_hash = if is_git_repository(&canonical_path) {
2542 get_git_hash(&canonical_path)
2543 } else {
2544 None
2545 };
2546
2547 let mut project = Project::new(project_name.clone(), canonical_path.clone())
2549 .with_git_hash(git_hash.clone())
2550 .with_description(description.clone());
2551
2552 let project_id = ProjectQueries::create(conn, &project)?;
2554 project.id = Some(project_id);
2555
2556 let marker_path = canonical_path.join(".tempo");
2558 if !marker_path.exists() {
2559 std::fs::write(
2560 &marker_path,
2561 format!("# Tempo time tracking project\nname: {}\n", project_name),
2562 )?;
2563 }
2564
2565 CliFormatter::print_section_header("Project Initialized");
2566 CliFormatter::print_field_bold("Name", &project_name, Some("yellow"));
2567 CliFormatter::print_field("Path", &canonical_path.display().to_string(), Some("gray"));
2568
2569 if let Some(desc) = &description {
2570 CliFormatter::print_field("Description", desc, Some("gray"));
2571 }
2572
2573 if is_git_repository(&canonical_path) {
2574 CliFormatter::print_field("Git", "Repository detected", Some("green"));
2575 if let Some(hash) = &git_hash {
2576 CliFormatter::print_field("Git Hash", &truncate_string(hash, 25), Some("gray"));
2577 }
2578 }
2579
2580 CliFormatter::print_field("ID", &project_id.to_string(), Some("gray"));
2581
2582 Ok(())
2583}
2584
2585async fn show_pool_stats() -> Result<()> {
2587 match get_pool_stats() {
2588 Ok(stats) => {
2589 CliFormatter::print_section_header("Database Pool Statistics");
2590 CliFormatter::print_field(
2591 "Total Created",
2592 &stats.total_connections_created.to_string(),
2593 Some("green"),
2594 );
2595 CliFormatter::print_field(
2596 "Active",
2597 &stats.active_connections.to_string(),
2598 Some("yellow"),
2599 );
2600 CliFormatter::print_field(
2601 "Available",
2602 &stats.connections_in_pool.to_string(),
2603 Some("white"),
2604 );
2605 CliFormatter::print_field(
2606 "Total Requests",
2607 &stats.connection_requests.to_string(),
2608 Some("white"),
2609 );
2610 CliFormatter::print_field(
2611 "Timeouts",
2612 &stats.connection_timeouts.to_string(),
2613 Some("red"),
2614 );
2615 }
2616 Err(_) => {
2617 CliFormatter::print_warning("Database pool not initialized or not available");
2618 CliFormatter::print_info("Using direct database connections as fallback");
2619 }
2620 }
2621 Ok(())
2622}
2623
2624#[derive(Deserialize)]
2625#[allow(dead_code)]
2626struct GitHubRelease {
2627 tag_name: String,
2628 name: String,
2629 body: String,
2630 published_at: String,
2631 prerelease: bool,
2632}
2633
2634async fn handle_update(check: bool, force: bool, verbose: bool) -> Result<()> {
2635 let current_version = env!("CARGO_PKG_VERSION");
2636
2637 if verbose {
2638 println!("🔍 Current version: v{}", current_version);
2639 println!("📡 Checking for updates...");
2640 } else {
2641 println!("🔍 Checking for updates...");
2642 }
2643
2644 let client = reqwest::Client::new();
2646 let response = client
2647 .get("https://api.github.com/repos/own-path/vibe/releases/latest")
2648 .header("User-Agent", format!("tempo-cli/{}", current_version))
2649 .send()
2650 .await
2651 .context("Failed to fetch release information")?;
2652
2653 if !response.status().is_success() {
2654 return Err(anyhow::anyhow!(
2655 "Failed to fetch release information: HTTP {}",
2656 response.status()
2657 ));
2658 }
2659
2660 let release: GitHubRelease = response
2661 .json()
2662 .await
2663 .context("Failed to parse release information")?;
2664
2665 let latest_version = release.tag_name.trim_start_matches('v');
2666
2667 if verbose {
2668 println!("📦 Latest version: v{}", latest_version);
2669 println!("📅 Released: {}", release.published_at);
2670 }
2671
2672 let current_semver =
2674 semver::Version::parse(current_version).context("Failed to parse current version")?;
2675 let latest_semver =
2676 semver::Version::parse(latest_version).context("Failed to parse latest version")?;
2677
2678 if current_semver >= latest_semver && !force {
2679 println!(
2680 "✅ You're already running the latest version (v{})",
2681 current_version
2682 );
2683 if check {
2684 return Ok(());
2685 }
2686
2687 if !force {
2688 println!("💡 Use --force to reinstall the current version");
2689 return Ok(());
2690 }
2691 }
2692
2693 if check {
2694 if current_semver < latest_semver {
2695 println!(
2696 "📦 Update available: v{} → v{}",
2697 current_version, latest_version
2698 );
2699 println!("🔗 Run `tempo update` to install the latest version");
2700
2701 if verbose && !release.body.is_empty() {
2702 println!("\n📝 Release Notes:");
2703 println!("{}", release.body);
2704 }
2705 }
2706 return Ok(());
2707 }
2708
2709 if current_semver < latest_semver || force {
2710 println!(
2711 "⬇️ Updating tempo from v{} to v{}",
2712 current_version, latest_version
2713 );
2714
2715 if verbose {
2716 println!("🔧 Installing via cargo...");
2717 }
2718
2719 let mut cmd = Command::new("cargo");
2721 cmd.args(["install", "tempo-cli", "--force"]);
2722
2723 if verbose {
2724 cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
2725 } else {
2726 cmd.stdout(Stdio::null()).stderr(Stdio::null());
2727 }
2728
2729 let status = cmd
2730 .status()
2731 .context("Failed to run cargo install command")?;
2732
2733 if status.success() {
2734 println!("✅ Successfully updated tempo to v{}", latest_version);
2735 println!("🎉 You can now use the latest features!");
2736
2737 if !release.body.is_empty() && verbose {
2738 println!("\n📝 What's new in v{}:", latest_version);
2739 println!("{}", release.body);
2740 }
2741 } else {
2742 return Err(anyhow::anyhow!(
2743 "Failed to install update. Try running manually: cargo install tempo-cli --force"
2744 ));
2745 }
2746 }
2747
2748 Ok(())
2749}