Skip to main content

stint_core/models/
entry.rs

1//! Time entry domain model.
2
3use time::OffsetDateTime;
4
5use super::types::{EntryId, ProjectId, SessionId};
6
7/// How a time entry was created.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum EntrySource {
10    /// Created via `stint start` / `stint stop`.
11    Manual,
12    /// Created automatically by a shell hook.
13    Hook,
14    /// Added retroactively via `stint add`.
15    Added,
16}
17
18impl EntrySource {
19    /// Returns the source as a lowercase string for storage.
20    pub fn as_str(&self) -> &'static str {
21        match self {
22            Self::Manual => "manual",
23            Self::Hook => "hook",
24            Self::Added => "added",
25        }
26    }
27
28    /// Parses a source from a stored string value.
29    pub fn from_str_value(s: &str) -> Option<Self> {
30        match s {
31            "manual" => Some(Self::Manual),
32            "hook" => Some(Self::Hook),
33            "added" => Some(Self::Added),
34            _ => None,
35        }
36    }
37}
38
39/// A single time tracking entry.
40#[derive(Debug, Clone, PartialEq)]
41pub struct TimeEntry {
42    /// Unique identifier.
43    pub id: EntryId,
44    /// The project this entry belongs to.
45    pub project_id: ProjectId,
46    /// The shell session that created this entry (None for manual/retroactive).
47    pub session_id: Option<SessionId>,
48    /// When tracking started.
49    pub start: OffsetDateTime,
50    /// When tracking stopped (None means currently running).
51    pub end: Option<OffsetDateTime>,
52    /// Duration in seconds. Computed on stop, or set directly for `stint add`.
53    pub duration_secs: Option<i64>,
54    /// How this entry was created.
55    pub source: EntrySource,
56    /// Optional notes.
57    pub notes: Option<String>,
58    /// User-defined tags.
59    pub tags: Vec<String>,
60    /// When this entry was created.
61    pub created_at: OffsetDateTime,
62    /// When this entry was last updated.
63    pub updated_at: OffsetDateTime,
64}
65
66impl TimeEntry {
67    /// Returns true if this entry is currently running (no end time).
68    pub fn is_running(&self) -> bool {
69        self.end.is_none()
70    }
71
72    /// Computes the duration from start and end timestamps.
73    ///
74    /// Returns `duration_secs` if set, otherwise computes from `end - start`.
75    /// Returns None if the entry is still running and has no explicit duration.
76    pub fn computed_duration_secs(&self) -> Option<i64> {
77        if let Some(d) = self.duration_secs {
78            return Some(d);
79        }
80        self.end.map(|end| (end - self.start).whole_seconds())
81    }
82}
83
84/// Filters for querying time entries.
85#[derive(Debug, Default)]
86pub struct EntryFilter {
87    /// Filter by project.
88    pub project_id: Option<ProjectId>,
89    /// Include entries starting at or after this time.
90    pub from: Option<OffsetDateTime>,
91    /// Include entries starting before this time.
92    pub to: Option<OffsetDateTime>,
93    /// Filter by tags (all must match).
94    pub tags: Vec<String>,
95    /// Filter by entry source.
96    pub source: Option<EntrySource>,
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use time::macros::datetime;
103
104    fn make_entry(
105        start: OffsetDateTime,
106        end: Option<OffsetDateTime>,
107        duration_secs: Option<i64>,
108    ) -> TimeEntry {
109        TimeEntry {
110            id: EntryId::new(),
111            project_id: ProjectId::new(),
112            session_id: None,
113            start,
114            end,
115            duration_secs,
116            source: EntrySource::Manual,
117            notes: None,
118            tags: vec![],
119            created_at: start,
120            updated_at: start,
121        }
122    }
123
124    #[test]
125    fn running_entry_has_no_end() {
126        let entry = make_entry(datetime!(2026-01-01 9:00 UTC), None, None);
127        assert!(entry.is_running());
128    }
129
130    #[test]
131    fn stopped_entry_is_not_running() {
132        let entry = make_entry(
133            datetime!(2026-01-01 9:00 UTC),
134            Some(datetime!(2026-01-01 10:30 UTC)),
135            None,
136        );
137        assert!(!entry.is_running());
138    }
139
140    #[test]
141    fn computed_duration_from_timestamps() {
142        let entry = make_entry(
143            datetime!(2026-01-01 9:00 UTC),
144            Some(datetime!(2026-01-01 10:30 UTC)),
145            None,
146        );
147        assert_eq!(entry.computed_duration_secs(), Some(5400)); // 1.5 hours
148    }
149
150    #[test]
151    fn explicit_duration_takes_precedence() {
152        let entry = make_entry(
153            datetime!(2026-01-01 0:00 UTC),
154            None,
155            Some(9000), // 2.5 hours
156        );
157        assert_eq!(entry.computed_duration_secs(), Some(9000));
158    }
159
160    #[test]
161    fn running_entry_without_duration_returns_none() {
162        let entry = make_entry(datetime!(2026-01-01 9:00 UTC), None, None);
163        assert_eq!(entry.computed_duration_secs(), None);
164    }
165}