Skip to main content

work_tuimer/cli/
mod.rs

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