Skip to main content

sc/cli/commands/
time_entry.rs

1//! Time entry command implementations.
2
3use crate::cli::{TimeCommands, TimeListArgs, TimeLogArgs, TimeUpdateArgs};
4use crate::config::{default_actor, resolve_db_path, resolve_project_path};
5use crate::error::{Error, Result};
6use crate::storage::SqliteStorage;
7use serde::Serialize;
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11/// Output for time entry create.
12#[derive(Serialize)]
13struct TimeLogOutput {
14    id: String,
15    short_id: Option<String>,
16    hours: f64,
17    description: String,
18    work_date: String,
19    period: Option<String>,
20    issue_id: Option<String>,
21    status: String,
22}
23
24/// Output for time entry list.
25#[derive(Serialize)]
26struct TimeListOutput {
27    entries: Vec<crate::storage::TimeEntry>,
28    count: usize,
29    total_hours: f64,
30}
31
32/// Output for time total.
33#[derive(Serialize)]
34struct TimeTotalOutput {
35    total_hours: f64,
36    period: Option<String>,
37    status: Option<String>,
38}
39
40/// Output for invoice operation.
41#[derive(Serialize)]
42struct TimeInvoiceOutput {
43    period: String,
44    count: usize,
45    total_hours: f64,
46    from_status: String,
47    to_status: String,
48}
49
50/// Execute time commands.
51pub fn execute(
52    command: &TimeCommands,
53    db_path: Option<&PathBuf>,
54    actor: Option<&str>,
55    json: bool,
56) -> Result<()> {
57    match command {
58        TimeCommands::Log(args) => log(args, db_path, actor, json),
59        TimeCommands::List(args) => list(args, db_path, json),
60        TimeCommands::Summary { period, group_by, status } => {
61            summary(period.as_deref(), group_by, status.as_deref(), db_path, json)
62        }
63        TimeCommands::Total { period, status } => {
64            total(period.as_deref(), status.as_deref(), db_path, json)
65        }
66        TimeCommands::Update(args) => update(args, db_path, actor, json),
67        TimeCommands::Delete { id } => delete(id, db_path, actor, json),
68        TimeCommands::Invoice { period, from } => invoice(period, from, db_path, actor, json),
69    }
70}
71
72fn log(
73    args: &TimeLogArgs,
74    db_path: Option<&PathBuf>,
75    actor: Option<&str>,
76    json: bool,
77) -> Result<()> {
78    if args.hours <= 0.0 {
79        return Err(Error::InvalidArgument(
80            "Hours must be greater than 0".to_string(),
81        ));
82    }
83
84    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
85    if !db_path.exists() {
86        return Err(Error::NotInitialized);
87    }
88
89    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
90    let mut storage = SqliteStorage::open(&db_path)?;
91    let project_path = resolve_project_path(&storage, None)?;
92
93    // Validate and default date
94    let work_date = match &args.date {
95        Some(d) => {
96            validate_date(d)?;
97            d.clone()
98        }
99        None => chrono::Local::now().format("%Y-%m-%d").to_string(),
100    };
101
102    // Resolve issue short ID to full ID if provided
103    let issue_id = if let Some(ref issue_ref) = args.issue {
104        let issue = storage
105            .get_issue(issue_ref, Some(&project_path))?
106            .ok_or_else(|| Error::IssueNotFound {
107                id: issue_ref.clone(),
108            })?;
109        Some(issue.id)
110    } else {
111        None
112    };
113
114    // Generate IDs
115    let id = format!("time_{}", uuid::Uuid::new_v4());
116    let short_id = format!("TE-{}", generate_short_id());
117
118    storage.create_time_entry(
119        &id,
120        Some(&short_id),
121        &project_path,
122        args.hours,
123        &args.description,
124        &work_date,
125        issue_id.as_deref(),
126        args.period.as_deref(),
127        &actor,
128    )?;
129
130    if crate::is_silent() {
131        println!("{short_id}");
132        return Ok(());
133    }
134
135    if json {
136        let output = TimeLogOutput {
137            id,
138            short_id: Some(short_id),
139            hours: args.hours,
140            description: args.description.clone(),
141            work_date,
142            period: args.period.clone(),
143            issue_id,
144            status: "logged".to_string(),
145        };
146        println!("{}", serde_json::to_string(&output)?);
147    } else {
148        let period_str = args
149            .period
150            .as_deref()
151            .map(|p| format!(" [{p}]"))
152            .unwrap_or_default();
153        let issue_str = issue_id
154            .as_deref()
155            .map(|_| {
156                args.issue
157                    .as_deref()
158                    .map(|i| format!(" (issue: {i})"))
159                    .unwrap_or_default()
160            })
161            .unwrap_or_default();
162        println!(
163            "Logged [{short_id}] {:.1}hrs  {}  {work_date}{period_str}{issue_str}",
164            args.hours, args.description
165        );
166    }
167
168    Ok(())
169}
170
171fn list(args: &TimeListArgs, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
172    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
173    if !db_path.exists() {
174        return Err(Error::NotInitialized);
175    }
176
177    let storage = SqliteStorage::open(&db_path)?;
178    let project_path = resolve_project_path(&storage, None)?;
179
180    let entries = storage.list_time_entries(
181        &project_path,
182        args.period.as_deref(),
183        args.status.as_deref(),
184        args.issue.as_deref(),
185        args.from.as_deref(),
186        args.to.as_deref(),
187        Some(args.limit),
188    )?;
189
190    let total_hours: f64 = entries.iter().map(|e| e.hours).sum();
191    let count = entries.len();
192
193    if json {
194        let output = TimeListOutput {
195            entries,
196            count,
197            total_hours,
198        };
199        println!("{}", serde_json::to_string(&output)?);
200    } else if crate::is_csv() {
201        println!("id,hours,description,work_date,period,status,issue_id");
202        for e in &entries {
203            println!(
204                "{},{},{},{},{},{},{}",
205                e.short_id.as_deref().unwrap_or(&e.id),
206                e.hours,
207                csv_escape(&e.description),
208                e.work_date,
209                e.period.as_deref().unwrap_or(""),
210                e.status,
211                e.issue_id.as_deref().unwrap_or(""),
212            );
213        }
214    } else {
215        if entries.is_empty() {
216            println!("No time entries found.");
217            return Ok(());
218        }
219
220        println!("Time entries ({count} found):");
221        println!();
222        for e in &entries {
223            let short = e.short_id.as_deref().unwrap_or(&e.id[..8]);
224            let status_char = match e.status.as_str() {
225                "reviewed" => '*',
226                "invoiced" => '$',
227                _ => ' ',
228            };
229            let period_str = e
230                .period
231                .as_deref()
232                .map(|p| format!(" [{p}]"))
233                .unwrap_or_default();
234            println!(
235                "{status_char} [{short}] {:.1}hrs  {}  {}{period_str}",
236                e.hours, e.description, e.work_date
237            );
238        }
239        println!();
240        println!("Total: {total_hours:.1}hrs");
241    }
242
243    Ok(())
244}
245
246fn summary(
247    period: Option<&str>,
248    group_by: &str,
249    status: Option<&str>,
250    db_path: Option<&PathBuf>,
251    json: bool,
252) -> Result<()> {
253    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
254    if !db_path.exists() {
255        return Err(Error::NotInitialized);
256    }
257
258    let storage = SqliteStorage::open(&db_path)?;
259    let project_path = resolve_project_path(&storage, None)?;
260
261    let entries = storage.list_time_entries(
262        &project_path,
263        period,
264        status,
265        None,
266        None,
267        None,
268        None,
269    )?;
270
271    if entries.is_empty() {
272        if json {
273            println!("{{\"groups\":[],\"running_total\":0}}");
274        } else {
275            println!("No time entries found.");
276        }
277        return Ok(());
278    }
279
280    // Group entries
281    let mut groups: BTreeMap<String, Vec<&crate::storage::TimeEntry>> = BTreeMap::new();
282    for e in &entries {
283        let key = match group_by {
284            "date" => e.work_date.clone(),
285            "issue" => e.issue_id.clone().unwrap_or_else(|| "(no issue)".to_string()),
286            "status" => e.status.clone(),
287            _ => e.period.clone().unwrap_or_else(|| "(no period)".to_string()),
288        };
289        groups.entry(key).or_default().push(e);
290    }
291
292    let running_total: f64 = entries.iter().map(|e| e.hours).sum();
293
294    if json {
295        let mut json_groups = Vec::new();
296        for (key, items) in &groups {
297            let subtotal: f64 = items.iter().map(|e| e.hours).sum();
298            let entries_json: Vec<serde_json::Value> = items
299                .iter()
300                .map(|e| {
301                    serde_json::json!({
302                        "id": e.short_id.as_deref().unwrap_or(&e.id),
303                        "hours": e.hours,
304                        "description": e.description,
305                        "work_date": e.work_date,
306                        "status": e.status,
307                    })
308                })
309                .collect();
310            json_groups.push(serde_json::json!({
311                "key": key,
312                "entries": entries_json,
313                "subtotal": subtotal,
314            }));
315        }
316        let output = serde_json::json!({
317            "groups": json_groups,
318            "running_total": running_total,
319        });
320        println!("{}", serde_json::to_string(&output)?);
321    } else {
322        for (key, items) in &groups {
323            println!("{key}:");
324            for e in items {
325                let status_suffix = match e.status.as_str() {
326                    "invoiced" => ", INVOICED",
327                    "reviewed" => ", REVIEWED",
328                    _ => "",
329                };
330                println!(
331                    "  - {}: {:.1}hrs{}",
332                    e.description, e.hours, status_suffix
333                );
334            }
335            let subtotal: f64 = items.iter().map(|e| e.hours).sum();
336            println!("  Subtotal: {subtotal:.1}hrs");
337            println!();
338        }
339        println!("Running total: {running_total:.1}hrs");
340    }
341
342    Ok(())
343}
344
345fn total(
346    period: Option<&str>,
347    status: Option<&str>,
348    db_path: Option<&PathBuf>,
349    json: bool,
350) -> Result<()> {
351    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
352    if !db_path.exists() {
353        return Err(Error::NotInitialized);
354    }
355
356    let storage = SqliteStorage::open(&db_path)?;
357    let project_path = resolve_project_path(&storage, None)?;
358
359    let total_hours = storage.get_time_total(&project_path, period, status)?;
360
361    if json {
362        let output = TimeTotalOutput {
363            total_hours,
364            period: period.map(ToString::to_string),
365            status: status.map(ToString::to_string),
366        };
367        println!("{}", serde_json::to_string(&output)?);
368    } else {
369        let qualifier = match (period, status) {
370            (Some(p), Some(s)) => format!(" ({s}, {p})"),
371            (Some(p), None) => format!(" ({p})"),
372            (None, Some(s)) => format!(" ({s})"),
373            (None, None) => String::new(),
374        };
375        println!("Total{qualifier}: {total_hours:.1}hrs");
376    }
377
378    Ok(())
379}
380
381fn update(
382    args: &TimeUpdateArgs,
383    db_path: Option<&PathBuf>,
384    actor: Option<&str>,
385    json: bool,
386) -> Result<()> {
387    if args.hours.is_none()
388        && args.description.is_none()
389        && args.period.is_none()
390        && args.issue.is_none()
391        && args.date.is_none()
392        && args.status.is_none()
393    {
394        return Err(Error::InvalidArgument(
395            "No fields to update. Use --hours, --description, --period, --issue, --date, or --status".to_string(),
396        ));
397    }
398
399    if let Some(h) = args.hours {
400        if h <= 0.0 {
401            return Err(Error::InvalidArgument(
402                "Hours must be greater than 0".to_string(),
403            ));
404        }
405    }
406
407    if let Some(ref d) = args.date {
408        validate_date(d)?;
409    }
410
411    if let Some(ref s) = args.status {
412        validate_status(s)?;
413    }
414
415    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
416    if !db_path.exists() {
417        return Err(Error::NotInitialized);
418    }
419
420    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
421    let mut storage = SqliteStorage::open(&db_path)?;
422    let project_path = resolve_project_path(&storage, None)?;
423
424    // Handle status update separately
425    if let Some(ref status) = args.status {
426        storage.update_time_entry_status(&args.id, &project_path, status, &actor)?;
427    }
428
429    // Handle field updates
430    if args.hours.is_some()
431        || args.description.is_some()
432        || args.period.is_some()
433        || args.issue.is_some()
434        || args.date.is_some()
435    {
436        // Resolve issue short ID if provided
437        let issue_id = if let Some(ref issue_ref) = args.issue {
438            let issue = storage
439                .get_issue(issue_ref, Some(&project_path))?
440                .ok_or_else(|| Error::IssueNotFound {
441                    id: issue_ref.clone(),
442                })?;
443            Some(issue.id)
444        } else {
445            None
446        };
447
448        storage.update_time_entry(
449            &args.id,
450            &project_path,
451            args.hours,
452            args.description.as_deref(),
453            args.period.as_deref(),
454            issue_id.as_deref(),
455            args.date.as_deref(),
456            &actor,
457        )?;
458    }
459
460    if json {
461        let output = serde_json::json!({
462            "id": args.id,
463            "updated": true,
464        });
465        println!("{}", serde_json::to_string(&output)?);
466    } else {
467        println!("Updated time entry: {}", args.id);
468    }
469
470    Ok(())
471}
472
473fn delete(id: &str, db_path: Option<&PathBuf>, actor: Option<&str>, json: bool) -> Result<()> {
474    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
475    if !db_path.exists() {
476        return Err(Error::NotInitialized);
477    }
478
479    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
480    let mut storage = SqliteStorage::open(&db_path)?;
481    let project_path = resolve_project_path(&storage, None)?;
482
483    storage.delete_time_entry(id, &project_path, &actor)?;
484
485    if json {
486        let output = serde_json::json!({
487            "id": id,
488            "deleted": true,
489        });
490        println!("{}", serde_json::to_string(&output)?);
491    } else {
492        println!("Deleted time entry: {id}");
493    }
494
495    Ok(())
496}
497
498fn invoice(
499    period: &str,
500    from: &str,
501    db_path: Option<&PathBuf>,
502    actor: Option<&str>,
503    json: bool,
504) -> Result<()> {
505    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
506    if !db_path.exists() {
507        return Err(Error::NotInitialized);
508    }
509
510    let actor = actor.map(ToString::to_string).unwrap_or_else(default_actor);
511    let mut storage = SqliteStorage::open(&db_path)?;
512    let project_path = resolve_project_path(&storage, None)?;
513
514    let (count, total_hours) =
515        storage.invoice_time_entries(&project_path, period, from, "invoiced", &actor)?;
516
517    if json {
518        let output = TimeInvoiceOutput {
519            period: period.to_string(),
520            count,
521            total_hours,
522            from_status: from.to_string(),
523            to_status: "invoiced".to_string(),
524        };
525        println!("{}", serde_json::to_string(&output)?);
526    } else if count == 0 {
527        println!("No {from} entries found for period: {period}");
528    } else {
529        println!(
530            "Invoiced {count} entries ({total_hours:.1}hrs) for period: {period}"
531        );
532    }
533
534    Ok(())
535}
536
537// ==================
538// Helpers
539// ==================
540
541fn validate_date(date: &str) -> Result<()> {
542    if chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").is_err() {
543        return Err(Error::InvalidArgument(format!(
544            "Invalid date format: '{date}'. Expected YYYY-MM-DD"
545        )));
546    }
547    Ok(())
548}
549
550fn validate_status(status: &str) -> Result<()> {
551    match status {
552        "logged" | "reviewed" | "invoiced" => Ok(()),
553        _ => Err(Error::InvalidArgument(format!(
554            "Invalid status: '{status}'. Expected: logged, reviewed, invoiced"
555        ))),
556    }
557}
558
559fn generate_short_id() -> String {
560    use std::time::{SystemTime, UNIX_EPOCH};
561    let now = SystemTime::now()
562        .duration_since(UNIX_EPOCH)
563        .unwrap()
564        .as_millis();
565    format!("{:04x}", (now & 0xFFFF) as u16)
566}
567
568fn csv_escape(s: &str) -> String {
569    if s.contains(',') || s.contains('"') || s.contains('\n') {
570        format!("\"{}\"", s.replace('"', "\"\""))
571    } else {
572        s.to_string()
573    }
574}