1use 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#[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#[derive(Serialize)]
26struct TimeListOutput {
27 entries: Vec<crate::storage::TimeEntry>,
28 count: usize,
29 total_hours: f64,
30}
31
32#[derive(Serialize)]
34struct TimeTotalOutput {
35 total_hours: f64,
36 period: Option<String>,
37 status: Option<String>,
38}
39
40#[derive(Serialize)]
42struct TimeInvoiceOutput {
43 period: String,
44 count: usize,
45 total_hours: f64,
46 from_status: String,
47 to_status: String,
48}
49
50pub 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 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 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 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 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 if let Some(ref status) = args.status {
426 storage.update_time_entry_status(&args.id, &project_path, status, &actor)?;
427 }
428
429 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 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
537fn 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}