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 STALE_THRESHOLD_SECS: i64 = 3600;
21
22#[derive(Debug, PartialEq, Eq)]
24pub enum HookAction {
25 Heartbeat,
27 Started { project_name: String },
29 Switched { from: String, to: String },
31 Stopped { project_name: String },
33 SessionCreated,
35 SessionStarted { project_name: String },
37 IdleResume { project_name: String },
39}
40
41pub 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
60fn 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 let _ = reap_stale_sessions(storage, now);
71
72 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 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
101fn 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 if !is_idle && !cwd_changed {
115 session.last_heartbeat = now;
116 storage.upsert_session(&session)?;
117 return Ok(HookAction::Heartbeat);
118 }
119
120 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 if is_idle {
129 if let Some(ref old_pid) = old_project_id {
130 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 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 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 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 let old_name = if let Some(ref old_pid) = old_project_id {
163 let old_project = storage.get_project(old_pid)?;
164 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
198pub 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(()), };
212
213 let now = OffsetDateTime::now_utc();
214
215 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 storage.end_session(&session.id, stop_time)?;
225
226 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
237pub 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 let mut project_max_heartbeat: std::collections::HashMap<String, (ProjectId, OffsetDateTime)> =
256 std::collections::HashMap::new();
257
258 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 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
286fn 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
304fn 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
318fn detect_or_discover(
325 storage: &impl Storage,
326 cwd: &Path,
327 now: OffsetDateTime,
328 config: &StintConfig,
329) -> Result<Option<Project>, StintError> {
330 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 if !config.auto_discover {
341 return Ok(None);
342 }
343
344 if storage.is_path_ignored(cwd)? {
346 return Ok(None);
347 }
348
349 let discovered = match discover::discover_project(cwd) {
351 Some(d) => d,
352 None => return Ok(None),
353 };
354
355 if storage.is_path_ignored(&discovered.root)? {
357 return Ok(None);
358 }
359
360 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 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
391fn 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 let session = storage.get_session_by_pid(1234).unwrap().unwrap();
463 assert!(session.current_project_id.is_some());
464
465 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 let app1 = storage.get_project_by_name("app-1").unwrap().unwrap();
545 assert!(storage.get_running_entry(&app1.id).unwrap().is_none());
546
547 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 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 handle_hook(
629 &storage,
630 1234,
631 Path::new("/home/user/my-app"),
632 None,
633 &test_config(),
634 )
635 .unwrap();
636
637 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 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 handle_hook(
670 &storage,
671 1234,
672 Path::new("/home/user/my-app"),
673 None,
674 &test_config(),
675 )
676 .unwrap();
677
678 handle_hook_exit(&storage, 1234, &test_config()).unwrap();
680
681 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 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 assert!(storage.get_session_by_pid(1234).unwrap().is_none());
729
730 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 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 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 handle_hook_exit(&storage, 1111, &test_config()).unwrap();
765
766 assert!(storage.get_any_running_entry().unwrap().is_some());
768
769 handle_hook_exit(&storage, 2222, &test_config()).unwrap();
771
772 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 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 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 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 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 let entry = new_hook_entry(&project.id, &session.id, old_time);
847 storage.create_entry(&entry).unwrap();
848
849 let now = OffsetDateTime::now_utc();
851 let reaped = reap_stale_sessions(&storage, now).unwrap();
852 assert_eq!(reaped, 1);
853
854 assert!(storage.get_session_by_pid(5555).unwrap().is_none());
856
857 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 let project = storage.get_project_by_name("my-project").unwrap().unwrap();
875 assert_eq!(project.paths[0], project_dir);
876
877 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 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 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 create_project(&storage, "custom-name", &project_dir.to_string_lossy());
922
923 handle_hook(&storage, 1234, &project_dir, None, &test_config()).unwrap();
924
925 assert!(storage
927 .get_project_by_name("custom-name")
928 .unwrap()
929 .is_some());
930 assert!(storage.get_project_by_name("my-project").unwrap().is_none());
932 }
933}