1use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Duration;
16
17const DEFAULT_TIMEOUT_SECS: u64 = 60;
18const DEFAULT_MAX_OUTPUT: usize = 102_400; #[derive(Debug, Default)]
23pub struct SessionConfig {
24 pub root: PathBuf,
25 pub timeout_secs: Option<u64>,
26 pub max_output: Option<usize>,
27 pub global_recipe_dirs: Vec<PathBuf>,
34}
35
36#[derive(Debug, thiserror::Error)]
38pub enum SessionError {
39 #[error(
40 "session root path no longer exists, please call session_start again: {}",
41 _0.display()
42 )]
43 RootGone(PathBuf),
44}
45
46#[derive(Debug, thiserror::Error)]
48pub enum CoreError {
49 #[error("session root does not exist: {}", _0.display())]
50 RootNotFound(PathBuf),
51 #[error("no active session — call session_start first")]
52 NoSession,
53}
54
55#[derive(Debug, Clone)]
60pub struct Session {
61 root: PathBuf,
62 session_id: String,
63 timeout: Duration,
64 max_output: usize,
65 global_recipe_dirs: Vec<PathBuf>,
66}
67
68impl Session {
69 pub fn new(config: SessionConfig) -> Result<Self, CoreError> {
70 let root = config.root;
71 if !root.is_dir() {
72 tracing::warn!(root = %root.display(), "session root does not exist");
73 return Err(CoreError::RootNotFound(root));
74 }
75 let session_id = session_id_new();
76 let timeout = Duration::from_secs(config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
77 let max_output = config.max_output.unwrap_or(DEFAULT_MAX_OUTPUT);
78 tracing::info!(
79 root = %root.display(),
80 session_id = %session_id,
81 timeout_secs = timeout.as_secs(),
82 max_output,
83 "session started"
84 );
85 let global_recipe_dirs = config.global_recipe_dirs;
86 Ok(Self {
87 root,
88 session_id,
89 timeout,
90 max_output,
91 global_recipe_dirs,
92 })
93 }
94
95 pub fn root(&self) -> &Path {
96 &self.root
97 }
98
99 pub fn id(&self) -> &str {
100 &self.session_id
101 }
102
103 pub fn timeout(&self) -> Duration {
104 self.timeout
105 }
106
107 pub fn max_output(&self) -> usize {
108 self.max_output
109 }
110
111 pub fn global_recipe_dirs(&self) -> &[PathBuf] {
112 &self.global_recipe_dirs
113 }
114
115 pub fn ensure_alive(&self) -> Result<(), SessionError> {
121 if !self.root.is_dir() {
122 tracing::warn!(root = %self.root.display(), "session root no longer exists");
123 return Err(SessionError::RootGone(self.root.clone()));
124 }
125 Ok(())
126 }
127}
128
129#[derive(Debug, Clone)]
134pub struct LdsState {
135 session: Option<Arc<Session>>,
136}
137
138impl LdsState {
139 pub fn new() -> Self {
140 Self { session: None }
141 }
142
143 pub fn start_session(&mut self, config: SessionConfig) -> Result<Arc<Session>, CoreError> {
144 let session = Arc::new(Session::new(config)?);
145 self.session = Some(Arc::clone(&session));
146 Ok(session)
147 }
148
149 pub fn session(&self) -> Result<&Arc<Session>, CoreError> {
150 self.session.as_ref().ok_or(CoreError::NoSession)
151 }
152}
153
154impl Default for LdsState {
155 fn default() -> Self {
156 Self::new()
157 }
158}
159
160fn session_id_new() -> String {
167 use std::time::{SystemTime, UNIX_EPOCH};
168 let ts = SystemTime::now()
169 .duration_since(UNIX_EPOCH)
170 .unwrap_or_default()
171 .as_nanos();
172 let pid = std::process::id();
173 format!("{ts:x}-{pid:x}")
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
183 fn core_error_root_not_found_display_contains_prefix_and_path() {
184 let path = PathBuf::from("/some/missing/root");
185 let err = CoreError::RootNotFound(path.clone());
186 let msg = err.to_string();
187 assert!(
188 msg.contains("session root does not exist: "),
189 "I1: message must start with invariant prefix, got: {msg}"
190 );
191 assert!(
192 msg.contains("/some/missing/root"),
193 "I1: message must contain the path, got: {msg}"
194 );
195 }
196
197 #[test]
198 fn core_error_no_session_display_matches_invariant() {
199 let err = CoreError::NoSession;
200 let msg = err.to_string();
201 assert_eq!(
202 msg, "no active session \u{2014} call session_start first",
203 "I2: message must exactly match invariant string"
204 );
205 }
206
207 #[test]
210 fn session_error_root_gone_message_contains_invariant_substring() {
211 use std::path::PathBuf;
212 let path = PathBuf::from("/tmp/gone");
213 let err = SessionError::RootGone(path.clone());
214 let msg = err.to_string();
215 assert!(
216 msg.contains("session root path no longer exists, please call session_start again"),
217 "error message must contain the K-239 recovery substring, got: {msg}"
218 );
219 assert!(
220 msg.contains("/tmp/gone"),
221 "error message must include the path, got: {msg}"
222 );
223 }
224
225 #[test]
226 fn ensure_alive_ok_when_root_exists() {
227 let tmp = tempfile::tempdir().unwrap();
228 let session = Session::new(SessionConfig {
229 root: tmp.path().to_path_buf(),
230 ..Default::default()
231 })
232 .unwrap();
233 assert!(session.ensure_alive().is_ok());
234 }
235
236 #[test]
237 fn ensure_alive_err_when_root_deleted() {
238 let tmp = tempfile::tempdir().unwrap();
239 let path = tmp.path().to_path_buf();
240 let session = Session::new(SessionConfig {
241 root: path.clone(),
242 ..Default::default()
243 })
244 .unwrap();
245 std::fs::remove_dir_all(&path).unwrap();
246 let err = session.ensure_alive().unwrap_err();
247 let msg = err.to_string();
248 assert!(
249 msg.contains("session root path no longer exists, please call session_start again"),
250 "expected K-239 substring, got: {msg}"
251 );
252 }
253}