1use std::time::Duration;
2
3use anyhow::Result;
4use clap::{Parser, Subcommand};
5use time::OffsetDateTime;
6
7use crate::storage::Storage;
8use crate::timer::TimerManager;
9
10mod export;
11use export::ExportCommands;
12
13#[derive(Parser)]
15#[command(name = "work-tuimer")]
16#[command(about = "Automatic time tracking with CLI commands and TUI", long_about = None)]
17#[command(version)]
18pub struct Cli {
19 #[command(subcommand)]
20 pub command: Commands,
21}
22
23#[derive(Subcommand)]
25pub enum Commands {
26 Session {
28 #[command(subcommand)]
29 command: SessionCommands,
30 },
31
32 Doctor,
34
35 Export {
37 #[command(subcommand)]
38 command: ExportCommands,
39 },
40}
41
42#[derive(Subcommand)]
44pub enum SessionCommands {
45 Start {
47 task: String,
49
50 #[arg(short, long)]
52 description: Option<String>,
53
54 #[arg(long)]
56 project: Option<String>,
57
58 #[arg(long)]
60 customer: Option<String>,
61 },
62
63 Stop,
65
66 Pause,
68
69 Resume,
71
72 Status,
74}
75
76pub fn handle_command(cmd: Commands, storage: Storage) -> Result<()> {
78 match cmd {
79 Commands::Session { command } => match command {
80 SessionCommands::Start {
81 task,
82 description,
83 project,
84 customer,
85 } => handle_start(task, description, project, customer, storage),
86 SessionCommands::Stop => handle_stop(storage),
87 SessionCommands::Pause => handle_pause(storage),
88 SessionCommands::Resume => handle_resume(storage),
89 SessionCommands::Status => handle_status(storage),
90 },
91 Commands::Doctor => handle_doctor(storage),
92 Commands::Export { command } => export::handle_export(command, storage),
93 }
94}
95
96fn handle_doctor(storage: Storage) -> Result<()> {
97 let diagnostics = storage.diagnostics()?;
98
99 println!("WorkTimer Doctor");
100 println!(" Database: {}", diagnostics.database_path.display());
101 println!(
102 " Migration marker: {}",
103 diagnostics
104 .migration_marker
105 .as_deref()
106 .unwrap_or("<not-set>")
107 );
108 println!(" Days stored: {}", diagnostics.days_count);
109 println!(" Work records: {}", diagnostics.work_records_count);
110 println!(
111 " Active timer: {}",
112 if diagnostics.active_timer_present {
113 "present"
114 } else {
115 "none"
116 }
117 );
118 println!(
119 " Legacy JSON backups: {} day files, {} timer files",
120 diagnostics.legacy_day_json_files, diagnostics.legacy_timer_json_files
121 );
122
123 if diagnostics.migration_marker.is_some() {
124 println!(" Status: OK (SQLite migration completed)");
125 } else {
126 println!(
127 " Status: WARN (unexpected missing migration marker; possible failed migration or DB issue)"
128 );
129 }
130
131 Ok(())
132}
133
134fn handle_start(
136 task: String,
137 description: Option<String>,
138 project: Option<String>,
139 customer: Option<String>,
140 storage: Storage,
141) -> Result<()> {
142 let timer_manager = TimerManager::new(storage);
143
144 let task = task.trim().to_string();
146 if task.is_empty() {
147 return Err(anyhow::anyhow!("Task name cannot be empty"));
148 }
149
150 let project = project
151 .map(|value| value.trim().to_string())
152 .filter(|value| !value.is_empty());
153 let customer = customer
154 .map(|value| value.trim().to_string())
155 .filter(|value| !value.is_empty());
156
157 let timer = timer_manager.start(task, description, project, customer, None, None)?;
158
159 let start_time = format_time(timer.start_time);
160 println!("✓ Session started");
161 println!(" Task: {}", timer.task_name);
162 if let Some(project) = &timer.project {
163 println!(" Project: {}", project);
164 }
165 if let Some(customer) = &timer.customer {
166 println!(" Customer: {}", customer);
167 }
168 if let Some(desc) = &timer.description {
169 println!(" Description: {}", desc);
170 }
171 println!(" Started at: {}", start_time);
172
173 Ok(())
174}
175
176fn handle_stop(storage: Storage) -> Result<()> {
178 let timer_manager = TimerManager::new(storage);
179
180 let timer = timer_manager
182 .status()?
183 .ok_or_else(|| anyhow::anyhow!("No session is running"))?;
184
185 let elapsed = timer_manager.get_elapsed_duration(&timer);
186 let formatted_duration = format_duration(elapsed);
187
188 let start_time = format_time(timer.start_time);
189
190 let record = timer_manager.stop()?;
192
193 let end_time = format!("{:02}:{:02}:{:02}", record.end.hour, record.end.minute, 0);
195
196 println!("✓ Session stopped");
197 println!(" Task: {}", timer.task_name);
198 if let Some(project) = &timer.project {
199 println!(" Project: {}", project);
200 }
201 if let Some(customer) = &timer.customer {
202 println!(" Customer: {}", customer);
203 }
204 println!(" Duration: {}", formatted_duration);
205 println!(" Started at: {}", start_time);
206 println!(" Ended at: {}", end_time);
207
208 Ok(())
209}
210
211fn handle_pause(storage: Storage) -> Result<()> {
213 let timer_manager = TimerManager::new(storage);
214
215 let timer = timer_manager
216 .status()?
217 .ok_or_else(|| anyhow::anyhow!("No session is running"))?;
218
219 let _paused_timer = timer_manager.pause()?;
220 let elapsed = timer_manager.get_elapsed_duration(&timer);
221 let formatted_duration = format_duration(elapsed);
222
223 println!("⏸ Session paused");
224 println!(" Task: {}", timer.task_name);
225 println!(" Elapsed: {}", formatted_duration);
226
227 Ok(())
228}
229
230fn handle_resume(storage: Storage) -> Result<()> {
232 let timer_manager = TimerManager::new(storage);
233
234 let timer = timer_manager
235 .status()?
236 .ok_or_else(|| anyhow::anyhow!("No session is running"))?;
237
238 let _resumed_timer = timer_manager.resume()?;
239 let elapsed = timer_manager.get_elapsed_duration(&timer);
240 let formatted_duration = format_duration(elapsed);
241
242 println!("▶ Session resumed");
243 println!(" Task: {}", timer.task_name);
244 println!(" Total elapsed (before pause): {}", formatted_duration);
245
246 Ok(())
247}
248
249fn handle_status(storage: Storage) -> Result<()> {
251 let timer_manager = TimerManager::new(storage);
252
253 match timer_manager.status()? {
254 Some(timer) => {
255 let elapsed = timer_manager.get_elapsed_duration(&timer);
256 let formatted_duration = format_duration(elapsed);
257 let start_time = format_time(timer.start_time);
258
259 println!("⏱ Session Status");
260 println!(" Task: {}", timer.task_name);
261 println!(
262 " Status: {}",
263 match timer.status {
264 crate::timer::TimerStatus::Running => "Running",
265 crate::timer::TimerStatus::Paused => "Paused",
266 crate::timer::TimerStatus::Stopped => "Stopped",
267 }
268 );
269 println!(" Elapsed: {}", formatted_duration);
270 println!(" Started at: {}", start_time);
271 if let Some(project) = &timer.project {
272 println!(" Project: {}", project);
273 }
274 if let Some(customer) = &timer.customer {
275 println!(" Customer: {}", customer);
276 }
277 if let Some(desc) = &timer.description {
278 println!(" Description: {}", desc);
279 }
280 }
281 None => {
282 println!("No session is currently running");
283 }
284 }
285
286 Ok(())
287}
288
289fn format_time(dt: OffsetDateTime) -> String {
291 format!("{:02}:{:02}:{:02}", dt.hour(), dt.minute(), dt.second())
292}
293
294fn format_duration(duration: Duration) -> String {
296 let total_secs = duration.as_secs();
297 let hours = total_secs / 3600;
298 let minutes = (total_secs % 3600) / 60;
299 let seconds = total_secs % 60;
300
301 if hours > 0 {
302 format!("{}h {:02}m {:02}s", hours, minutes, seconds)
303 } else {
304 format!("{}m {:02}s", minutes, seconds)
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn test_format_duration_hours_minutes_seconds() {
314 let duration = Duration::from_secs(3661); assert_eq!(format_duration(duration), "1h 01m 01s");
316 }
317
318 #[test]
319 fn test_format_duration_minutes_seconds() {
320 let duration = Duration::from_secs(125); assert_eq!(format_duration(duration), "2m 05s");
322 }
323
324 #[test]
325 fn test_format_duration_seconds_only() {
326 let duration = Duration::from_secs(45);
327 assert_eq!(format_duration(duration), "0m 45s");
328 }
329
330 #[test]
331 fn test_format_duration_zero() {
332 let duration = Duration::from_secs(0);
333 assert_eq!(format_duration(duration), "0m 00s");
334 }
335
336 #[test]
337 fn test_format_time() {
338 use time::macros::datetime;
339 let dt = datetime!(2025-01-15 14:30:45 UTC);
340 assert_eq!(format_time(dt), "14:30:45");
341 }
342
343 #[test]
344 fn test_cli_has_version() {
345 use clap::CommandFactory;
346 let cmd = Cli::command();
347 let version = cmd.get_version();
348 assert!(version.is_some(), "CLI should have version configured");
349 assert_eq!(version.unwrap(), env!("CARGO_PKG_VERSION"));
351 }
352
353 #[test]
354 fn test_cli_doctor_command_parse() {
355 let cli = Cli::try_parse_from(["work-tuimer", "doctor"]).unwrap();
356 assert!(matches!(cli.command, Commands::Doctor));
357 }
358
359 #[test]
360 fn test_cli_session_start_parses_project_and_customer() {
361 let cli = Cli::try_parse_from([
362 "work-tuimer",
363 "session",
364 "start",
365 "My Task",
366 "--project",
367 "Internal Platform",
368 "--customer",
369 "ACME",
370 ])
371 .unwrap();
372
373 let Commands::Session { command } = cli.command else {
374 panic!("Expected session command");
375 };
376
377 match command {
378 SessionCommands::Start {
379 task,
380 project,
381 customer,
382 ..
383 } => {
384 assert_eq!(task, "My Task");
385 assert_eq!(project.as_deref(), Some("Internal Platform"));
386 assert_eq!(customer.as_deref(), Some("ACME"));
387 }
388 _ => panic!("Expected session start command"),
389 }
390 }
391}