1use 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
19const MIN_STALE_THRESHOLD_SECS: i64 = 600;
23
24#[derive(Debug, PartialEq, Eq)]
26pub enum HookAction {
27 Heartbeat,
29 Started { project_name: String },
31 Switched { from: String, to: String },
33 Stopped { project_name: String },
35 SessionCreated,
37 SessionStarted { project_name: String },
39 IdleResume { project_name: String },
41}
42
43pub 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
62fn 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 let _ = reap_stale_sessions(storage, now, config);
73
74 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 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
103fn 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 if !is_idle && !cwd_changed {
117 session.last_heartbeat = now;
118 storage.upsert_session(&session)?;
119 return Ok(HookAction::Heartbeat);
120 }
121
122 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 if is_idle {
131 if let Some(ref old_pid) = old_project_id {
132 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 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 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 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 let old_name = if let Some(ref old_pid) = old_project_id {
165 let old_project = storage.get_project(old_pid)?;
166 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
200pub 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(()), };
214
215 let now = OffsetDateTime::now_utc();
216
217 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 storage.end_session(&session.id, stop_time)?;
227
228 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
239pub 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 let mut project_max_heartbeat: std::collections::HashMap<String, (ProjectId, OffsetDateTime)> =
263 std::collections::HashMap::new();
264
265 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 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
293fn 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
311fn 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
325fn detect_or_discover(
332 storage: &impl Storage,
333 cwd: &Path,
334 now: OffsetDateTime,
335 config: &StintConfig,
336) -> Result<Option<Project>, StintError> {
337 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 if !config.auto_discover {
348 return Ok(None);
349 }
350
351 if storage.is_path_ignored(cwd)? {
353 return Ok(None);
354 }
355
356 let discovered = match discover::discover_project(cwd) {
358 Some(d) => d,
359 None => return Ok(None),
360 };
361
362 if storage.is_path_ignored(&discovered.root)? {
364 return Ok(None);
365 }
366
367 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 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
398fn 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 let session = storage.get_session_by_pid(1234).unwrap().unwrap();
470 assert!(session.current_project_id.is_some());
471
472 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 let app1 = storage.get_project_by_name("app-1").unwrap().unwrap();
552 assert!(storage.get_running_entry(&app1.id).unwrap().is_none());
553
554 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 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 handle_hook(
636 &storage,
637 1234,
638 Path::new("/home/user/my-app"),
639 None,
640 &test_config(),
641 )
642 .unwrap();
643
644 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 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 handle_hook(
677 &storage,
678 1234,
679 Path::new("/home/user/my-app"),
680 None,
681 &test_config(),
682 )
683 .unwrap();
684
685 handle_hook_exit(&storage, 1234, &test_config()).unwrap();
687
688 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 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 assert!(storage.get_session_by_pid(1234).unwrap().is_none());
736
737 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 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 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 handle_hook_exit(&storage, 1111, &test_config()).unwrap();
772
773 assert!(storage.get_any_running_entry().unwrap().is_some());
775
776 handle_hook_exit(&storage, 2222, &test_config()).unwrap();
778
779 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 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 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 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 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 let entry = new_hook_entry(&project.id, &session.id, old_time);
854 storage.create_entry(&entry).unwrap();
855
856 let now = OffsetDateTime::now_utc();
858 let reaped = reap_stale_sessions(&storage, now, &test_config()).unwrap();
859 assert_eq!(reaped, 1);
860
861 assert!(storage.get_session_by_pid(5555).unwrap().is_none());
863
864 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 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 let mut config = test_config();
905 config.idle_threshold_secs = 480; 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 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 let project = storage.get_project_by_name("my-project").unwrap().unwrap();
964 assert_eq!(project.paths[0], project_dir);
965
966 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 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 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 create_project(&storage, "custom-name", &project_dir.to_string_lossy());
1011
1012 handle_hook(&storage, 1234, &project_dir, None, &test_config()).unwrap();
1013
1014 assert!(storage
1016 .get_project_by_name("custom-name")
1017 .unwrap()
1018 .is_some());
1019 assert!(storage.get_project_by_name("my-project").unwrap().is_none());
1021 }
1022}