Skip to main content

work_tuimer/cli/
mod.rs

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/// WorkTimer CLI - Automatic time tracking
14#[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/// Available CLI commands
24#[derive(Subcommand)]
25pub enum Commands {
26    /// Manage timer sessions (start/stop/pause/resume/status)
27    Session {
28        #[command(subcommand)]
29        command: SessionCommands,
30    },
31
32    /// Show storage and migration diagnostics
33    Doctor,
34
35    /// Export work records to CSV
36    Export {
37        #[command(subcommand)]
38        command: ExportCommands,
39    },
40}
41
42/// Session management commands
43#[derive(Subcommand)]
44pub enum SessionCommands {
45    /// Start a new timer session
46    Start {
47        /// Task name
48        task: String,
49
50        /// Optional task description
51        #[arg(short, long)]
52        description: Option<String>,
53
54        /// Optional project name
55        #[arg(long)]
56        project: Option<String>,
57
58        /// Optional customer name
59        #[arg(long)]
60        customer: Option<String>,
61    },
62
63    /// Stop the running timer session
64    Stop,
65
66    /// Pause the running timer session
67    Pause,
68
69    /// Resume the paused timer session
70    Resume,
71
72    /// Show status of running timer session
73    Status,
74}
75
76/// Handle CLI command execution
77pub 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
134/// Start a new session
135fn 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    // Trim task name
145    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
176/// Stop the running session
177fn handle_stop(storage: Storage) -> Result<()> {
178    let timer_manager = TimerManager::new(storage);
179
180    // Load and validate timer exists
181    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    // Stop the timer and get the work record
191    let record = timer_manager.stop()?;
192
193    // Format end time from the work record (HH:MM format)
194    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
211/// Pause the running session
212fn 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
230/// Resume the paused session
231fn 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
249/// Show status of running session
250fn 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
289/// Format OffsetDateTime for display (HH:MM:SS)
290fn format_time(dt: OffsetDateTime) -> String {
291    format!("{:02}:{:02}:{:02}", dt.hour(), dt.minute(), dt.second())
292}
293
294/// Format Duration for display (h:mm:ss or mm:ss)
295fn 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); // 1h 1m 1s
315        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); // 2m 5s
321        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        // Version comes from Cargo.toml
350        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}