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