1use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10use kindling_service::KindlingService;
11use kindling_store::{project_db_path, project_id};
12
13use crate::error::ApiError;
14
15type Registry = Arc<Mutex<HashMap<String, Arc<Mutex<KindlingService>>>>>;
21
22#[derive(Clone)]
24pub struct AppState {
25 kindling_home: PathBuf,
26 services: Registry,
27 activity: Arc<Activity>,
28}
29
30impl AppState {
31 pub fn new(kindling_home: PathBuf) -> Self {
34 Self {
35 kindling_home,
36 services: Arc::new(Mutex::new(HashMap::new())),
37 activity: Arc::new(Activity::new()),
38 }
39 }
40
41 pub fn kindling_home(&self) -> &PathBuf {
43 &self.kindling_home
44 }
45
46 pub fn activity(&self) -> &Arc<Activity> {
48 &self.activity
49 }
50
51 pub fn known_project_ids(&self) -> Vec<String> {
54 let map = self.services.lock().expect("registry mutex poisoned");
55 let mut ids: Vec<String> = map.keys().cloned().collect();
56 ids.sort();
57 ids
58 }
59
60 pub fn service_for(&self, project_root: &str) -> Result<Arc<Mutex<KindlingService>>, ApiError> {
68 let key = project_id(project_root);
69
70 {
72 let map = self.services.lock().expect("registry mutex poisoned");
73 if let Some(svc) = map.get(&key) {
74 return Ok(Arc::clone(svc));
75 }
76 }
77
78 let mut map = self.services.lock().expect("registry mutex poisoned");
81 if let Some(svc) = map.get(&key) {
82 return Ok(Arc::clone(svc));
83 }
84 let db_path = project_db_path(&self.kindling_home, project_root);
85 let service = KindlingService::open(&db_path)
86 .map_err(|e| ApiError::Internal(format!("opening project db: {e}")))?;
87 let entry = Arc::new(Mutex::new(service));
88 map.insert(key, Arc::clone(&entry));
89 Ok(entry)
90 }
91}
92
93#[derive(Debug)]
95pub struct Activity {
96 in_flight: AtomicU64,
97 last_active_ms: AtomicU64,
99 start: Instant,
100}
101
102impl Activity {
103 fn new() -> Self {
104 Self {
105 in_flight: AtomicU64::new(0),
106 last_active_ms: AtomicU64::new(0),
107 start: Instant::now(),
108 }
109 }
110
111 fn now_ms(&self) -> u64 {
112 self.start.elapsed().as_millis() as u64
113 }
114
115 pub fn enter(&self) {
117 self.in_flight.fetch_add(1, Ordering::SeqCst);
118 self.last_active_ms.store(self.now_ms(), Ordering::SeqCst);
119 }
120
121 pub fn leave(&self) {
123 self.in_flight.fetch_sub(1, Ordering::SeqCst);
124 self.last_active_ms.store(self.now_ms(), Ordering::SeqCst);
125 }
126
127 pub fn is_idle_for(&self, timeout: Duration) -> bool {
130 if self.in_flight.load(Ordering::SeqCst) > 0 {
131 return false;
132 }
133 let idle_ms = self
134 .now_ms()
135 .saturating_sub(self.last_active_ms.load(Ordering::SeqCst));
136 idle_ms >= timeout.as_millis() as u64
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn idle_after_timeout_when_no_activity() {
146 let a = Activity::new();
147 assert!(a.is_idle_for(Duration::from_millis(0)));
150 }
151
152 #[test]
153 fn not_idle_while_in_flight() {
154 let a = Activity::new();
155 a.enter();
156 assert!(!a.is_idle_for(Duration::from_millis(0)));
157 a.leave();
158 assert!(a.is_idle_for(Duration::from_millis(0)));
159 }
160}