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