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