Skip to main content

stint_core/
hook.rs

1//! Shell hook logic for automatic time tracking.
2//!
3//! The hook fires on every shell prompt render. It detects the current project
4//! from the working directory, manages sessions, and starts/stops timers.
5
6use std::path::Path;
7
8use time::OffsetDateTime;
9
10use crate::config::StintConfig;
11use crate::discover;
12use crate::error::StintError;
13use crate::models::entry::{EntrySource, TimeEntry};
14use crate::models::project::{Project, ProjectStatus};
15use crate::models::session::ShellSession;
16use crate::models::types::{EntryId, ProjectId, SessionId};
17use crate::storage::Storage;
18
19/// Stale session threshold in seconds (1 hour).
20const STALE_THRESHOLD_SECS: i64 = 3600;
21
22/// What happened as a result of the hook firing.
23#[derive(Debug, PartialEq, Eq)]
24pub enum HookAction {
25    /// Session heartbeat updated, no project change.
26    Heartbeat,
27    /// Started tracking a new project.
28    Started { project_name: String },
29    /// Switched from one project to another.
30    Switched { from: String, to: String },
31    /// Stopped tracking (left project directory).
32    Stopped { project_name: String },
33    /// New session created, no project detected.
34    SessionCreated,
35    /// New session created and started tracking.
36    SessionStarted { project_name: String },
37    /// Idle gap detected; previous entry stopped at last heartbeat, new one started.
38    IdleResume { project_name: String },
39}
40
41/// Handles a shell hook invocation.
42///
43/// Called on every prompt render. Detects the current project from `cwd`,
44/// manages the session lifecycle, and starts/stops/switches timers.
45pub fn handle_hook(
46    storage: &impl Storage,
47    pid: u32,
48    cwd: &Path,
49    shell: Option<&str>,
50    config: &StintConfig,
51) -> Result<HookAction, StintError> {
52    let now = OffsetDateTime::now_utc();
53
54    match storage.get_session_by_pid(pid)? {
55        None => handle_cold_start(storage, pid, cwd, shell, now, config),
56        Some(session) => handle_warm_path(storage, session, cwd, now, config),
57    }
58}
59
60/// Handles the first hook call in a new shell session.
61fn handle_cold_start(
62    storage: &impl Storage,
63    pid: u32,
64    cwd: &Path,
65    shell: Option<&str>,
66    now: OffsetDateTime,
67    config: &StintConfig,
68) -> Result<HookAction, StintError> {
69    // Reap stale sessions opportunistically
70    let _ = reap_stale_sessions(storage, now);
71
72    // Detect project from cwd (registered paths first, then .git auto-discovery)
73    let active_project = detect_or_discover(storage, cwd, now, config)?;
74
75    let project_id = active_project.as_ref().map(|p| p.id.clone());
76
77    let session = ShellSession {
78        id: SessionId::new(),
79        pid,
80        shell: shell.map(|s| s.to_string()),
81        cwd: cwd.to_path_buf(),
82        current_project_id: project_id,
83        started_at: now,
84        last_heartbeat: now,
85        ended_at: None,
86    };
87    storage.upsert_session(&session)?;
88
89    match active_project {
90        Some(project) => {
91            // Merge mode: only create entry if none is running for this project
92            try_create_hook_entry(storage, &project.id, &session.id, now)?;
93            Ok(HookAction::SessionStarted {
94                project_name: project.name,
95            })
96        }
97        None => Ok(HookAction::SessionCreated),
98    }
99}
100
101/// Handles subsequent hook calls in an existing session.
102fn handle_warm_path(
103    storage: &impl Storage,
104    mut session: ShellSession,
105    cwd: &Path,
106    now: OffsetDateTime,
107    config: &StintConfig,
108) -> Result<HookAction, StintError> {
109    let idle_gap = (now - session.last_heartbeat).whole_seconds();
110    let is_idle = idle_gap > config.idle_threshold_secs;
111    let cwd_changed = session.cwd != cwd;
112
113    // Fast path: no idle, no cwd change — just heartbeat
114    if !is_idle && !cwd_changed {
115        session.last_heartbeat = now;
116        storage.upsert_session(&session)?;
117        return Ok(HookAction::Heartbeat);
118    }
119
120    // Need to detect project (cwd changed or idle gap)
121    let new_active = detect_or_discover(storage, cwd, now, config)?;
122    let new_project_id = new_active.as_ref().map(|p| p.id.clone());
123
124    let old_project_id = session.current_project_id.clone();
125    let project_changed = new_project_id != old_project_id;
126
127    // Handle idle gap: stop old entry at last_heartbeat time
128    if is_idle {
129        if let Some(ref old_pid) = old_project_id {
130            // Only stop if no other active sessions share this project
131            let others = storage.count_active_sessions_for_project(old_pid, &session.id)?;
132            if others == 0 {
133                stop_hook_entry_for_project(storage, old_pid, session.last_heartbeat)?;
134            }
135        }
136
137        // Update session
138        session.cwd = cwd.to_path_buf();
139        session.current_project_id = new_project_id;
140        session.last_heartbeat = now;
141        storage.upsert_session(&session)?;
142
143        // Start new entry if we're in a project
144        if let Some(project) = new_active {
145            try_create_hook_entry(storage, &project.id, &session.id, now)?;
146            return Ok(HookAction::IdleResume {
147                project_name: project.name,
148            });
149        }
150        return Ok(HookAction::Heartbeat);
151    }
152
153    // cwd changed but no idle — check if project changed
154    if !project_changed {
155        session.cwd = cwd.to_path_buf();
156        session.last_heartbeat = now;
157        storage.upsert_session(&session)?;
158        return Ok(HookAction::Heartbeat);
159    }
160
161    // Project changed — stop old, start new
162    let old_name = if let Some(ref old_pid) = old_project_id {
163        let old_project = storage.get_project(old_pid)?;
164        // Only stop if no other active sessions share this project
165        let others = storage.count_active_sessions_for_project(old_pid, &session.id)?;
166        if others == 0 {
167            stop_hook_entry_for_project(storage, old_pid, now)?;
168        }
169        old_project.map(|p| p.name)
170    } else {
171        None
172    };
173
174    session.cwd = cwd.to_path_buf();
175    session.current_project_id = new_project_id;
176    session.last_heartbeat = now;
177    storage.upsert_session(&session)?;
178
179    match (old_name, new_active) {
180        (Some(from), Some(to_project)) => {
181            try_create_hook_entry(storage, &to_project.id, &session.id, now)?;
182            Ok(HookAction::Switched {
183                from,
184                to: to_project.name,
185            })
186        }
187        (Some(from), None) => Ok(HookAction::Stopped { project_name: from }),
188        (None, Some(to_project)) => {
189            try_create_hook_entry(storage, &to_project.id, &session.id, now)?;
190            Ok(HookAction::Started {
191                project_name: to_project.name,
192            })
193        }
194        (None, None) => Ok(HookAction::Heartbeat),
195    }
196}
197
198/// Handles shell exit: ends the session and conditionally stops the timer.
199///
200/// In merge mode, the entry is only stopped if no other active sessions
201/// share the same project. If the session was idle at exit, clamps the
202/// stop time to last_heartbeat to avoid counting idle time.
203pub fn handle_hook_exit(
204    storage: &impl Storage,
205    pid: u32,
206    config: &StintConfig,
207) -> Result<(), StintError> {
208    let session = match storage.get_session_by_pid(pid)? {
209        Some(s) => s,
210        None => return Ok(()), // No active session for this PID
211    };
212
213    let now = OffsetDateTime::now_utc();
214
215    // Clamp stop time to last_heartbeat if idle gap exceeds threshold
216    let idle_gap = (now - session.last_heartbeat).whole_seconds();
217    let stop_time = if idle_gap > config.idle_threshold_secs {
218        session.last_heartbeat
219    } else {
220        now
221    };
222
223    // End the session
224    storage.end_session(&session.id, stop_time)?;
225
226    // In merge mode, only stop the entry if no other sessions share this project
227    if let Some(ref project_id) = session.current_project_id {
228        let other_sessions = storage.count_active_sessions_for_project(project_id, &session.id)?;
229        if other_sessions == 0 {
230            stop_hook_entry_for_project(storage, project_id, stop_time)?;
231        }
232    }
233
234    Ok(())
235}
236
237/// Reaps stale sessions whose last heartbeat is older than the threshold.
238///
239/// Ends all stale sessions first, then stops hook entries only for projects
240/// with no remaining active sessions (preserving merge mode invariant).
241/// Returns the number of sessions reaped.
242pub fn reap_stale_sessions(
243    storage: &impl Storage,
244    now: OffsetDateTime,
245) -> Result<usize, StintError> {
246    let threshold = now - time::Duration::seconds(STALE_THRESHOLD_SECS);
247    let stale = storage.get_stale_sessions(threshold)?;
248    let count = stale.len();
249
250    if count == 0 {
251        return Ok(0);
252    }
253
254    // Group by project_id, tracking the max last_heartbeat per project
255    let mut project_max_heartbeat: std::collections::HashMap<String, (ProjectId, OffsetDateTime)> =
256        std::collections::HashMap::new();
257
258    // End all stale sessions first
259    for session in &stale {
260        if let Some(ref project_id) = session.current_project_id {
261            let key = project_id.as_str().to_owned();
262            project_max_heartbeat
263                .entry(key)
264                .and_modify(|(_, max_hb)| {
265                    if session.last_heartbeat > *max_hb {
266                        *max_hb = session.last_heartbeat;
267                    }
268                })
269                .or_insert((project_id.clone(), session.last_heartbeat));
270        }
271        storage.end_session(&session.id, session.last_heartbeat)?;
272    }
273
274    // Stop entries only for projects with no remaining active sessions
275    for (project_id, max_heartbeat) in project_max_heartbeat.values() {
276        let dummy_id = SessionId::new();
277        let active_count = storage.count_active_sessions_for_project(project_id, &dummy_id)?;
278        if active_count == 0 {
279            stop_hook_entry_for_project(storage, project_id, *max_heartbeat)?;
280        }
281    }
282
283    Ok(count)
284}
285
286/// Stops the running hook-sourced entry for a project.
287///
288/// Only stops entries with `source: Hook`. Manual entries are left untouched
289/// so that `stint start`/`stint stop` are not interfered with by the hook.
290fn stop_hook_entry_for_project(
291    storage: &impl Storage,
292    project_id: &ProjectId,
293    end_time: OffsetDateTime,
294) -> Result<(), StintError> {
295    if let Some(mut entry) = storage.get_running_hook_entry(project_id)? {
296        entry.end = Some(end_time);
297        entry.duration_secs = Some((end_time - entry.start).whole_seconds());
298        entry.updated_at = end_time;
299        storage.update_entry(&entry)?;
300    }
301    Ok(())
302}
303
304/// Tries to create a hook entry, skipping if one is already running (merge mode).
305fn try_create_hook_entry(
306    storage: &impl Storage,
307    project_id: &ProjectId,
308    session_id: &SessionId,
309    now: OffsetDateTime,
310) -> Result<(), StintError> {
311    if storage.get_running_entry(project_id)?.is_none() {
312        let entry = new_hook_entry(project_id, session_id, now);
313        storage.create_entry(&entry)?;
314    }
315    Ok(())
316}
317
318/// Detects a project from registered paths, or auto-discovers from `.git`.
319///
320/// 1. Checks registered project paths via `get_project_by_path`
321/// 2. If not found, checks if the path is ignored
322/// 3. If not ignored, looks for a `.git` directory up the tree
323/// 4. If found, auto-creates a project with `source: discovered`
324fn detect_or_discover(
325    storage: &impl Storage,
326    cwd: &Path,
327    now: OffsetDateTime,
328    config: &StintConfig,
329) -> Result<Option<Project>, StintError> {
330    // First: check registered projects
331    let registered = storage.get_project_by_path(cwd)?;
332    if let Some(project) = registered {
333        if project.status == ProjectStatus::Active {
334            return Ok(Some(project));
335        }
336        return Ok(None);
337    }
338
339    // Second: check if auto-discovery is enabled
340    if !config.auto_discover {
341        return Ok(None);
342    }
343
344    // Third: check if this path is ignored
345    if storage.is_path_ignored(cwd)? {
346        return Ok(None);
347    }
348
349    // Fourth: try .git auto-discovery
350    let discovered = match discover::discover_project(cwd) {
351        Some(d) => d,
352        None => return Ok(None),
353    };
354
355    // Check if the discovered root is ignored
356    if storage.is_path_ignored(&discovered.root)? {
357        return Ok(None);
358    }
359
360    // Try to create the project — if it already exists (race or archived), use the existing one
361    use crate::models::project::ProjectSource;
362    let project = Project {
363        id: ProjectId::new(),
364        name: discovered.name.clone(),
365        paths: vec![discovered.root],
366        tags: config.default_tags.clone(),
367        hourly_rate_cents: config.default_rate_cents,
368        status: ProjectStatus::Active,
369        source: ProjectSource::Discovered,
370        created_at: now,
371        updated_at: now,
372    };
373
374    match storage.create_project(&project) {
375        Ok(()) => Ok(Some(project)),
376        Err(crate::storage::error::StorageError::DuplicateProjectName(_)) => {
377            // Project already exists — use it only if active AND path matches
378            match storage.get_project_by_name(&discovered.name)? {
379                Some(p)
380                    if p.status == ProjectStatus::Active && p.paths.contains(&project.paths[0]) =>
381                {
382                    Ok(Some(p))
383                }
384                _ => Ok(None),
385            }
386        }
387        Err(e) => Err(e.into()),
388    }
389}
390
391/// Creates a new hook-sourced time entry.
392fn new_hook_entry(
393    project_id: &ProjectId,
394    session_id: &SessionId,
395    now: OffsetDateTime,
396) -> TimeEntry {
397    TimeEntry {
398        id: EntryId::new(),
399        project_id: project_id.clone(),
400        session_id: Some(session_id.clone()),
401        start: now,
402        end: None,
403        duration_secs: None,
404        source: EntrySource::Hook,
405        notes: None,
406        tags: vec![],
407        created_at: now,
408        updated_at: now,
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::config::StintConfig;
416    use crate::models::project::{Project, ProjectStatus};
417    use crate::storage::sqlite::SqliteStorage;
418    use crate::storage::Storage;
419
420    fn test_config() -> StintConfig {
421        StintConfig::default()
422    }
423    use std::path::PathBuf;
424
425    fn setup() -> SqliteStorage {
426        SqliteStorage::open_in_memory().unwrap()
427    }
428
429    fn create_project(storage: &SqliteStorage, name: &str, path: &str) {
430        let now = OffsetDateTime::now_utc();
431        let project = Project {
432            id: ProjectId::new(),
433            name: name.to_string(),
434            paths: vec![PathBuf::from(path)],
435            tags: vec![],
436            hourly_rate_cents: None,
437            status: ProjectStatus::Active,
438            source: crate::models::project::ProjectSource::Manual,
439            created_at: now,
440            updated_at: now,
441        };
442        storage.create_project(&project).unwrap();
443    }
444
445    #[test]
446    fn cold_start_in_project_creates_session_and_entry() {
447        let storage = setup();
448        create_project(&storage, "my-app", "/home/user/my-app");
449
450        let action = handle_hook(
451            &storage,
452            1234,
453            Path::new("/home/user/my-app/src"),
454            None,
455            &test_config(),
456        )
457        .unwrap();
458
459        assert!(matches!(action, HookAction::SessionStarted { .. }));
460
461        // Session exists
462        let session = storage.get_session_by_pid(1234).unwrap().unwrap();
463        assert!(session.current_project_id.is_some());
464
465        // Entry exists and is running
466        let entry = storage.get_any_running_entry().unwrap().unwrap();
467        assert_eq!(entry.source, EntrySource::Hook);
468    }
469
470    #[test]
471    fn cold_start_outside_project_creates_session_only() {
472        let storage = setup();
473        create_project(&storage, "my-app", "/home/user/my-app");
474
475        let action = handle_hook(
476            &storage,
477            1234,
478            Path::new("/home/user/other"),
479            None,
480            &test_config(),
481        )
482        .unwrap();
483
484        assert_eq!(action, HookAction::SessionCreated);
485
486        let session = storage.get_session_by_pid(1234).unwrap().unwrap();
487        assert!(session.current_project_id.is_none());
488        assert!(storage.get_any_running_entry().unwrap().is_none());
489    }
490
491    #[test]
492    fn warm_path_same_cwd_is_heartbeat() {
493        let storage = setup();
494        create_project(&storage, "my-app", "/home/user/my-app");
495
496        handle_hook(
497            &storage,
498            1234,
499            Path::new("/home/user/my-app"),
500            None,
501            &test_config(),
502        )
503        .unwrap();
504        let action = handle_hook(
505            &storage,
506            1234,
507            Path::new("/home/user/my-app"),
508            None,
509            &test_config(),
510        )
511        .unwrap();
512
513        assert_eq!(action, HookAction::Heartbeat);
514    }
515
516    #[test]
517    fn cwd_change_to_different_project_switches() {
518        let storage = setup();
519        create_project(&storage, "app-1", "/home/user/app-1");
520        create_project(&storage, "app-2", "/home/user/app-2");
521
522        handle_hook(
523            &storage,
524            1234,
525            Path::new("/home/user/app-1"),
526            None,
527            &test_config(),
528        )
529        .unwrap();
530        let action = handle_hook(
531            &storage,
532            1234,
533            Path::new("/home/user/app-2"),
534            None,
535            &test_config(),
536        )
537        .unwrap();
538
539        assert!(
540            matches!(action, HookAction::Switched { from, to } if from == "app-1" && to == "app-2")
541        );
542
543        // Old entry should be stopped
544        let app1 = storage.get_project_by_name("app-1").unwrap().unwrap();
545        assert!(storage.get_running_entry(&app1.id).unwrap().is_none());
546
547        // New entry should be running
548        let app2 = storage.get_project_by_name("app-2").unwrap().unwrap();
549        assert!(storage.get_running_entry(&app2.id).unwrap().is_some());
550    }
551
552    #[test]
553    fn cwd_change_to_non_project_stops() {
554        let storage = setup();
555        create_project(&storage, "my-app", "/home/user/my-app");
556
557        handle_hook(
558            &storage,
559            1234,
560            Path::new("/home/user/my-app"),
561            None,
562            &test_config(),
563        )
564        .unwrap();
565        let action = handle_hook(
566            &storage,
567            1234,
568            Path::new("/home/user/other"),
569            None,
570            &test_config(),
571        )
572        .unwrap();
573
574        assert!(matches!(action, HookAction::Stopped { .. }));
575        assert!(storage.get_any_running_entry().unwrap().is_none());
576    }
577
578    #[test]
579    fn cwd_change_from_non_project_to_project_starts() {
580        let storage = setup();
581        create_project(&storage, "my-app", "/home/user/my-app");
582
583        handle_hook(
584            &storage,
585            1234,
586            Path::new("/home/user/other"),
587            None,
588            &test_config(),
589        )
590        .unwrap();
591        let action = handle_hook(
592            &storage,
593            1234,
594            Path::new("/home/user/my-app"),
595            None,
596            &test_config(),
597        )
598        .unwrap();
599
600        assert!(matches!(action, HookAction::Started { .. }));
601        assert!(storage.get_any_running_entry().unwrap().is_some());
602    }
603
604    #[test]
605    fn manual_start_is_not_duplicated_by_hook() {
606        let storage = setup();
607        create_project(&storage, "my-app", "/home/user/my-app");
608
609        // Manually start a timer
610        let project = storage.get_project_by_name("my-app").unwrap().unwrap();
611        let now = OffsetDateTime::now_utc();
612        let manual_entry = TimeEntry {
613            id: EntryId::new(),
614            project_id: project.id.clone(),
615            session_id: None,
616            start: now,
617            end: None,
618            duration_secs: None,
619            source: EntrySource::Manual,
620            notes: None,
621            tags: vec![],
622            created_at: now,
623            updated_at: now,
624        };
625        storage.create_entry(&manual_entry).unwrap();
626
627        // Hook fires in the same project directory
628        handle_hook(
629            &storage,
630            1234,
631            Path::new("/home/user/my-app"),
632            None,
633            &test_config(),
634        )
635        .unwrap();
636
637        // Should still be only one running entry (the manual one)
638        let filter = crate::models::entry::EntryFilter::default();
639        let entries = storage.list_entries(&filter).unwrap();
640        let running: Vec<_> = entries.iter().filter(|e| e.is_running()).collect();
641        assert_eq!(running.len(), 1);
642        assert_eq!(running[0].source, EntrySource::Manual);
643    }
644
645    #[test]
646    fn hook_does_not_stop_manual_entry_on_exit() {
647        let storage = setup();
648        create_project(&storage, "my-app", "/home/user/my-app");
649
650        // Manually start a timer
651        let project = storage.get_project_by_name("my-app").unwrap().unwrap();
652        let now = OffsetDateTime::now_utc();
653        let manual_entry = TimeEntry {
654            id: EntryId::new(),
655            project_id: project.id.clone(),
656            session_id: None,
657            start: now,
658            end: None,
659            duration_secs: None,
660            source: EntrySource::Manual,
661            notes: None,
662            tags: vec![],
663            created_at: now,
664            updated_at: now,
665        };
666        storage.create_entry(&manual_entry).unwrap();
667
668        // Hook creates a session in the same project
669        handle_hook(
670            &storage,
671            1234,
672            Path::new("/home/user/my-app"),
673            None,
674            &test_config(),
675        )
676        .unwrap();
677
678        // Shell exits
679        handle_hook_exit(&storage, 1234, &test_config()).unwrap();
680
681        // Manual entry should still be running
682        let loaded = storage.get_entry(&manual_entry.id).unwrap().unwrap();
683        assert!(loaded.is_running());
684    }
685
686    #[test]
687    fn archived_project_is_not_tracked() {
688        let storage = setup();
689        create_project(&storage, "old-app", "/home/user/old-app");
690
691        // Archive the project
692        let mut project = storage.get_project_by_name("old-app").unwrap().unwrap();
693        project.status = ProjectStatus::Archived;
694        project.updated_at = OffsetDateTime::now_utc();
695        storage.update_project(&project).unwrap();
696
697        let action = handle_hook(
698            &storage,
699            1234,
700            Path::new("/home/user/old-app"),
701            None,
702            &test_config(),
703        )
704        .unwrap();
705
706        assert_eq!(action, HookAction::SessionCreated);
707        assert!(storage.get_any_running_entry().unwrap().is_none());
708    }
709
710    #[test]
711    fn exit_ends_session_and_stops_entry() {
712        let storage = setup();
713        create_project(&storage, "my-app", "/home/user/my-app");
714
715        handle_hook(
716            &storage,
717            1234,
718            Path::new("/home/user/my-app"),
719            None,
720            &test_config(),
721        )
722        .unwrap();
723        assert!(storage.get_any_running_entry().unwrap().is_some());
724
725        handle_hook_exit(&storage, 1234, &test_config()).unwrap();
726
727        // Session should be ended
728        assert!(storage.get_session_by_pid(1234).unwrap().is_none());
729
730        // Entry should be stopped
731        assert!(storage.get_any_running_entry().unwrap().is_none());
732    }
733
734    #[test]
735    fn exit_in_merge_mode_keeps_entry_if_other_sessions() {
736        let storage = setup();
737        create_project(&storage, "my-app", "/home/user/my-app");
738
739        // Two shells in the same project
740        handle_hook(
741            &storage,
742            1111,
743            Path::new("/home/user/my-app"),
744            None,
745            &test_config(),
746        )
747        .unwrap();
748        handle_hook(
749            &storage,
750            2222,
751            Path::new("/home/user/my-app"),
752            None,
753            &test_config(),
754        )
755        .unwrap();
756
757        // Only one running entry (merge mode)
758        let filter = crate::models::entry::EntryFilter::default();
759        let entries = storage.list_entries(&filter).unwrap();
760        let running: Vec<_> = entries.iter().filter(|e| e.is_running()).collect();
761        assert_eq!(running.len(), 1);
762
763        // First shell exits
764        handle_hook_exit(&storage, 1111, &test_config()).unwrap();
765
766        // Entry should still be running (shell 2222 still active)
767        assert!(storage.get_any_running_entry().unwrap().is_some());
768
769        // Second shell exits
770        handle_hook_exit(&storage, 2222, &test_config()).unwrap();
771
772        // Now the entry should be stopped
773        assert!(storage.get_any_running_entry().unwrap().is_none());
774    }
775
776    #[test]
777    fn switch_in_merge_mode_keeps_entry_if_other_sessions() {
778        let storage = setup();
779        create_project(&storage, "app-1", "/home/user/app-1");
780        create_project(&storage, "app-2", "/home/user/app-2");
781
782        // Two shells in the same project
783        handle_hook(
784            &storage,
785            1111,
786            Path::new("/home/user/app-1"),
787            None,
788            &test_config(),
789        )
790        .unwrap();
791        handle_hook(
792            &storage,
793            2222,
794            Path::new("/home/user/app-1"),
795            None,
796            &test_config(),
797        )
798        .unwrap();
799
800        // Shell 1 switches to app-2 — app-1's entry should NOT stop
801        handle_hook(
802            &storage,
803            1111,
804            Path::new("/home/user/app-2"),
805            None,
806            &test_config(),
807        )
808        .unwrap();
809
810        let app1 = storage.get_project_by_name("app-1").unwrap().unwrap();
811        assert!(
812            storage.get_running_entry(&app1.id).unwrap().is_some(),
813            "app-1 entry should still be running because shell 2222 is still there"
814        );
815    }
816
817    #[test]
818    fn exit_with_no_session_is_noop() {
819        let storage = setup();
820        // Should not error
821        handle_hook_exit(&storage, 9999, &test_config()).unwrap();
822    }
823
824    #[test]
825    fn stale_session_reaping() {
826        let storage = setup();
827        create_project(&storage, "my-app", "/home/user/my-app");
828
829        // Create a session with an old heartbeat
830        let old_time = OffsetDateTime::now_utc() - time::Duration::hours(2);
831        let project = storage.get_project_by_name("my-app").unwrap().unwrap();
832
833        let session = ShellSession {
834            id: SessionId::new(),
835            pid: 5555,
836            shell: Some("bash".to_string()),
837            cwd: PathBuf::from("/home/user/my-app"),
838            current_project_id: Some(project.id.clone()),
839            started_at: old_time,
840            last_heartbeat: old_time,
841            ended_at: None,
842        };
843        storage.upsert_session(&session).unwrap();
844
845        // Create a running entry for that session
846        let entry = new_hook_entry(&project.id, &session.id, old_time);
847        storage.create_entry(&entry).unwrap();
848
849        // Reap stale sessions
850        let now = OffsetDateTime::now_utc();
851        let reaped = reap_stale_sessions(&storage, now).unwrap();
852        assert_eq!(reaped, 1);
853
854        // Session should be ended
855        assert!(storage.get_session_by_pid(5555).unwrap().is_none());
856
857        // Entry should be stopped at last_heartbeat time
858        let stopped = storage.get_entry(&entry.id).unwrap().unwrap();
859        assert!(!stopped.is_running());
860    }
861
862    #[test]
863    fn auto_discovers_git_repo() {
864        let storage = setup();
865        let tmp = tempfile::TempDir::new().unwrap();
866        let project_dir = tmp.path().join("my-project");
867        std::fs::create_dir_all(project_dir.join(".git")).unwrap();
868
869        let action = handle_hook(&storage, 1234, &project_dir, None, &test_config()).unwrap();
870
871        assert!(matches!(action, HookAction::SessionStarted { .. }));
872
873        // Project should have been auto-created
874        let project = storage.get_project_by_name("my-project").unwrap().unwrap();
875        assert_eq!(project.paths[0], project_dir);
876
877        // Entry should be running
878        assert!(storage.get_any_running_entry().unwrap().is_some());
879    }
880
881    #[test]
882    fn auto_discovers_from_subdirectory() {
883        let storage = setup();
884        let tmp = tempfile::TempDir::new().unwrap();
885        let project_dir = tmp.path().join("my-project");
886        std::fs::create_dir_all(project_dir.join(".git")).unwrap();
887        let sub = project_dir.join("src").join("lib");
888        std::fs::create_dir_all(&sub).unwrap();
889
890        handle_hook(&storage, 1234, &sub, None, &test_config()).unwrap();
891
892        // Should discover the project root, not the subdirectory
893        let project = storage.get_project_by_name("my-project").unwrap().unwrap();
894        assert_eq!(project.paths[0], project_dir);
895    }
896
897    #[test]
898    fn ignored_path_prevents_discovery() {
899        let storage = setup();
900        let tmp = tempfile::TempDir::new().unwrap();
901        let project_dir = tmp.path().join("dotfiles");
902        std::fs::create_dir_all(project_dir.join(".git")).unwrap();
903
904        // Ignore this path
905        storage.add_ignored_path(&project_dir).unwrap();
906
907        let action = handle_hook(&storage, 1234, &project_dir, None, &test_config()).unwrap();
908
909        assert_eq!(action, HookAction::SessionCreated);
910        assert!(storage.get_project_by_name("dotfiles").unwrap().is_none());
911    }
912
913    #[test]
914    fn registered_project_takes_precedence_over_discovery() {
915        let storage = setup();
916        let tmp = tempfile::TempDir::new().unwrap();
917        let project_dir = tmp.path().join("my-project");
918        std::fs::create_dir_all(project_dir.join(".git")).unwrap();
919
920        // Register with a custom name
921        create_project(&storage, "custom-name", &project_dir.to_string_lossy());
922
923        handle_hook(&storage, 1234, &project_dir, None, &test_config()).unwrap();
924
925        // Should use the registered name, not the directory name
926        assert!(storage
927            .get_project_by_name("custom-name")
928            .unwrap()
929            .is_some());
930        // Should NOT have created a "my-project" entry
931        assert!(storage.get_project_by_name("my-project").unwrap().is_none());
932    }
933}