on main help sign in
feat: added session manager
commite5be12d8b7439e86e5fb470879e9406807a8a238 authorrobcholz <84130577+robcholz@users.noreply.github.com> dateThu 5 Feb 2026, 21:26 (8 months ago) parent07ea478
5 files changed+922
Cargo.lock+413 Cargo.toml+6 docs/tasks.md+12 src/main.rs+4 src/session_manager.rs+487
Cargo.lock+413
@@ -17,6 +17,28 @@ dependencies = [
1717 "objc2",
1818]
1919
20+[[package]]
21+name = "bumpalo"
22+version = "3.19.1"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
25+
26+[[package]]
27+name = "cfg-if"
28+version = "1.0.4"
29+source = "registry+https://github.com/rust-lang/crates.io-index"
30+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
31+
32+[[package]]
33+name = "deranged"
34+version = "0.5.5"
35+source = "registry+https://github.com/rust-lang/crates.io-index"
36+checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
37+dependencies = [
38+ "powerfmt",
39+ "serde_core",
40+]
41+
2042[[package]]
2143name = "dispatch2"
2244version = "0.3.0"
@@ -29,6 +51,72 @@ dependencies = [
2951 "objc2",
3052]
3153
54+[[package]]
55+name = "equivalent"
56+version = "1.0.2"
57+source = "registry+https://github.com/rust-lang/crates.io-index"
58+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
59+
60+[[package]]
61+name = "errno"
62+version = "0.3.14"
63+source = "registry+https://github.com/rust-lang/crates.io-index"
64+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
65+dependencies = [
66+ "libc",
67+ "windows-sys",
68+]
69+
70+[[package]]
71+name = "fastrand"
72+version = "2.3.0"
73+source = "registry+https://github.com/rust-lang/crates.io-index"
74+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
75+
76+[[package]]
77+name = "getrandom"
78+version = "0.3.4"
79+source = "registry+https://github.com/rust-lang/crates.io-index"
80+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
81+dependencies = [
82+ "cfg-if",
83+ "libc",
84+ "r-efi",
85+ "wasip2",
86+]
87+
88+[[package]]
89+name = "hashbrown"
90+version = "0.16.1"
91+source = "registry+https://github.com/rust-lang/crates.io-index"
92+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
93+
94+[[package]]
95+name = "indexmap"
96+version = "2.13.0"
97+source = "registry+https://github.com/rust-lang/crates.io-index"
98+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
99+dependencies = [
100+ "equivalent",
101+ "hashbrown",
102+]
103+
104+[[package]]
105+name = "itoa"
106+version = "1.0.17"
107+source = "registry+https://github.com/rust-lang/crates.io-index"
108+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
109+
110+[[package]]
111+name = "js-sys"
112+version = "0.3.85"
113+source = "registry+https://github.com/rust-lang/crates.io-index"
114+checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
115+dependencies = [
116+ "once_cell",
117+ "wasm-bindgen",
118+]
119+
32120[[package]]
33121name = "lexopt"
34122version = "0.3.1"
@@ -41,6 +129,18 @@ version = "0.2.180"
41129source = "registry+https://github.com/rust-lang/crates.io-index"
42130checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
43131
132+[[package]]
133+name = "linux-raw-sys"
134+version = "0.11.0"
135+source = "registry+https://github.com/rust-lang/crates.io-index"
136+checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
137+
138+[[package]]
139+name = "num-conv"
140+version = "0.2.0"
141+source = "registry+https://github.com/rust-lang/crates.io-index"
142+checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
143+
44144[[package]]
45145name = "objc2"
46146version = "0.6.3"
@@ -106,6 +206,232 @@ dependencies = [
106206 "objc2-foundation",
107207]
108208
209+[[package]]
210+name = "once_cell"
211+version = "1.21.3"
212+source = "registry+https://github.com/rust-lang/crates.io-index"
213+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
214+
215+[[package]]
216+name = "powerfmt"
217+version = "0.2.0"
218+source = "registry+https://github.com/rust-lang/crates.io-index"
219+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
220+
221+[[package]]
222+name = "proc-macro2"
223+version = "1.0.106"
224+source = "registry+https://github.com/rust-lang/crates.io-index"
225+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
226+dependencies = [
227+ "unicode-ident",
228+]
229+
230+[[package]]
231+name = "quote"
232+version = "1.0.44"
233+source = "registry+https://github.com/rust-lang/crates.io-index"
234+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
235+dependencies = [
236+ "proc-macro2",
237+]
238+
239+[[package]]
240+name = "r-efi"
241+version = "5.3.0"
242+source = "registry+https://github.com/rust-lang/crates.io-index"
243+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
244+
245+[[package]]
246+name = "rustix"
247+version = "1.1.3"
248+source = "registry+https://github.com/rust-lang/crates.io-index"
249+checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
250+dependencies = [
251+ "bitflags",
252+ "errno",
253+ "libc",
254+ "linux-raw-sys",
255+ "windows-sys",
256+]
257+
258+[[package]]
259+name = "rustversion"
260+version = "1.0.22"
261+source = "registry+https://github.com/rust-lang/crates.io-index"
262+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
263+
264+[[package]]
265+name = "serde"
266+version = "1.0.228"
267+source = "registry+https://github.com/rust-lang/crates.io-index"
268+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
269+dependencies = [
270+ "serde_core",
271+ "serde_derive",
272+]
273+
274+[[package]]
275+name = "serde_core"
276+version = "1.0.228"
277+source = "registry+https://github.com/rust-lang/crates.io-index"
278+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
279+dependencies = [
280+ "serde_derive",
281+]
282+
283+[[package]]
284+name = "serde_derive"
285+version = "1.0.228"
286+source = "registry+https://github.com/rust-lang/crates.io-index"
287+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
288+dependencies = [
289+ "proc-macro2",
290+ "quote",
291+ "syn",
292+]
293+
294+[[package]]
295+name = "serde_spanned"
296+version = "1.0.4"
297+source = "registry+https://github.com/rust-lang/crates.io-index"
298+checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
299+dependencies = [
300+ "serde_core",
301+]
302+
303+[[package]]
304+name = "syn"
305+version = "2.0.114"
306+source = "registry+https://github.com/rust-lang/crates.io-index"
307+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
308+dependencies = [
309+ "proc-macro2",
310+ "quote",
311+ "unicode-ident",
312+]
313+
314+[[package]]
315+name = "tempfile"
316+version = "3.24.0"
317+source = "registry+https://github.com/rust-lang/crates.io-index"
318+checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
319+dependencies = [
320+ "fastrand",
321+ "getrandom",
322+ "once_cell",
323+ "rustix",
324+ "windows-sys",
325+]
326+
327+[[package]]
328+name = "thiserror"
329+version = "2.0.18"
330+source = "registry+https://github.com/rust-lang/crates.io-index"
331+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
332+dependencies = [
333+ "thiserror-impl",
334+]
335+
336+[[package]]
337+name = "thiserror-impl"
338+version = "2.0.18"
339+source = "registry+https://github.com/rust-lang/crates.io-index"
340+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
341+dependencies = [
342+ "proc-macro2",
343+ "quote",
344+ "syn",
345+]
346+
347+[[package]]
348+name = "time"
349+version = "0.3.47"
350+source = "registry+https://github.com/rust-lang/crates.io-index"
351+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
352+dependencies = [
353+ "deranged",
354+ "itoa",
355+ "num-conv",
356+ "powerfmt",
357+ "serde_core",
358+ "time-core",
359+ "time-macros",
360+]
361+
362+[[package]]
363+name = "time-core"
364+version = "0.1.8"
365+source = "registry+https://github.com/rust-lang/crates.io-index"
366+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
367+
368+[[package]]
369+name = "time-macros"
370+version = "0.2.27"
371+source = "registry+https://github.com/rust-lang/crates.io-index"
372+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
373+dependencies = [
374+ "num-conv",
375+ "time-core",
376+]
377+
378+[[package]]
379+name = "toml"
380+version = "0.9.11+spec-1.1.0"
381+source = "registry+https://github.com/rust-lang/crates.io-index"
382+checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
383+dependencies = [
384+ "indexmap",
385+ "serde_core",
386+ "serde_spanned",
387+ "toml_datetime",
388+ "toml_parser",
389+ "toml_writer",
390+ "winnow",
391+]
392+
393+[[package]]
394+name = "toml_datetime"
395+version = "0.7.5+spec-1.1.0"
396+source = "registry+https://github.com/rust-lang/crates.io-index"
397+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
398+dependencies = [
399+ "serde_core",
400+]
401+
402+[[package]]
403+name = "toml_parser"
404+version = "1.0.6+spec-1.1.0"
405+source = "registry+https://github.com/rust-lang/crates.io-index"
406+checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
407+dependencies = [
408+ "winnow",
409+]
410+
411+[[package]]
412+name = "toml_writer"
413+version = "1.0.6+spec-1.1.0"
414+source = "registry+https://github.com/rust-lang/crates.io-index"
415+checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
416+
417+[[package]]
418+name = "unicode-ident"
419+version = "1.0.22"
420+source = "registry+https://github.com/rust-lang/crates.io-index"
421+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
422+
423+[[package]]
424+name = "uuid"
425+version = "1.20.0"
426+source = "registry+https://github.com/rust-lang/crates.io-index"
427+checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
428+dependencies = [
429+ "getrandom",
430+ "js-sys",
431+ "serde_core",
432+ "wasm-bindgen",
433+]
434+
109435[[package]]
110436name = "vibebox"
111437version = "0.1.0"
@@ -117,4 +443,91 @@ dependencies = [
117443 "objc2",
118444 "objc2-foundation",
119445 "objc2-virtualization",
446+ "serde",
447+ "tempfile",
448+ "thiserror",
449+ "time",
450+ "toml",
451+ "uuid",
452+]
453+
454+[[package]]
455+name = "wasip2"
456+version = "1.0.2+wasi-0.2.9"
457+source = "registry+https://github.com/rust-lang/crates.io-index"
458+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
459+dependencies = [
460+ "wit-bindgen",
461+]
462+
463+[[package]]
464+name = "wasm-bindgen"
465+version = "0.2.108"
466+source = "registry+https://github.com/rust-lang/crates.io-index"
467+checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
468+dependencies = [
469+ "cfg-if",
470+ "once_cell",
471+ "rustversion",
472+ "wasm-bindgen-macro",
473+ "wasm-bindgen-shared",
474+]
475+
476+[[package]]
477+name = "wasm-bindgen-macro"
478+version = "0.2.108"
479+source = "registry+https://github.com/rust-lang/crates.io-index"
480+checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
481+dependencies = [
482+ "quote",
483+ "wasm-bindgen-macro-support",
120484]
485+
486+[[package]]
487+name = "wasm-bindgen-macro-support"
488+version = "0.2.108"
489+source = "registry+https://github.com/rust-lang/crates.io-index"
490+checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
491+dependencies = [
492+ "bumpalo",
493+ "proc-macro2",
494+ "quote",
495+ "syn",
496+ "wasm-bindgen-shared",
497+]
498+
499+[[package]]
500+name = "wasm-bindgen-shared"
501+version = "0.2.108"
502+source = "registry+https://github.com/rust-lang/crates.io-index"
503+checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
504+dependencies = [
505+ "unicode-ident",
506+]
507+
508+[[package]]
509+name = "windows-link"
510+version = "0.2.1"
511+source = "registry+https://github.com/rust-lang/crates.io-index"
512+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
513+
514+[[package]]
515+name = "windows-sys"
516+version = "0.61.2"
517+source = "registry+https://github.com/rust-lang/crates.io-index"
518+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
519+dependencies = [
520+ "windows-link",
521+]
522+
523+[[package]]
524+name = "winnow"
525+version = "0.7.14"
526+source = "registry+https://github.com/rust-lang/crates.io-index"
527+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
528+
529+[[package]]
530+name = "wit-bindgen"
531+version = "0.51.0"
532+source = "registry+https://github.com/rust-lang/crates.io-index"
533+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
Cargo.toml+6
@@ -21,3 +21,9 @@ block2 = "*"
2121dispatch2 = "*"
2222libc = "*"
2323lexopt = "0.3"
24+serde = { version = "1", features = ["derive"] }
25+tempfile = "3"
26+thiserror = "2.0.18"
27+time = { version = "0.3", features = ["serde", "formatting", "parsing"] }
28+toml = "0.9.8"
29+uuid = { version = "1", features = ["v7", "serde"] }
docs/tasks.mdnew file+12
@@ -0,0 +1,12 @@
1+# Tasks
2+
3+1. [x] Confirm requirements and scope from `implementations.md`.
4+2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, refcount handling, cleanup orphaned index entries).
5+3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, `thiserror`).
6+4. [x] Write user journeys and unit test cases first (happy paths + error paths) for session lifecycle and index persistence.
7+5. [x] Implement `SessionManager` and supporting types with `Result`-based errors, filesystem IO, and atomic writes.
8+6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on missing instance dir).
9+7. [ ] Run tests and coverage; target >=80% line/branch coverage using a Rust coverage tool (e.g., `cargo llvm-cov`).
10+8. [x] Refactor for clarity and reliability while keeping tests green.
11+9. [ ] Add TUI interface.
12+10. [ ] Integrate VM and SessionManager together.
src/main.rs+4
@@ -1,3 +1,7 @@
1+mod session_manager;
2+
3+pub use session_manager::{SessionError, SessionManager, SessionRecord};
4+
15use std::{
26 env,
37 ffi::OsString,
src/session_manager.rsnew file+487
@@ -0,0 +1,487 @@
1+use std::{
2+ env, fs,
3+ io::{self, Write},
4+ path::{Path, PathBuf},
5+};
6+
7+use serde::{Deserialize, Serialize};
8+use time::OffsetDateTime;
9+use uuid::Uuid;
10+
11+pub const INSTANCE_DIR_NAME: &str = ".vibebox";
12+pub const GLOBAL_DIR_NAME: &str = ".vibebox";
13+pub const SESSION_INDEX_FILENAME: &str = "sessions.toml";
14+pub const SESSION_TEMP_PREFIX: &str = "sessions";
15+pub const SESSION_TOML_SUFFIX: &str = ".toml";
16+
17+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18+pub struct SessionRecord {
19+ pub id: Uuid,
20+ pub directory: PathBuf,
21+ #[serde(with = "time::serde::rfc3339")]
22+ pub last_active: OffsetDateTime,
23+ #[serde(default)]
24+ pub ref_count: u64,
25+}
26+
27+#[derive(Debug, Default, Serialize, Deserialize)]
28+struct SessionIndex {
29+ #[serde(default)]
30+ sessions: Vec<SessionRecord>,
31+}
32+
33+#[derive(Debug)]
34+pub struct SessionManager {
35+ global_dir: PathBuf,
36+ index_path: PathBuf,
37+}
38+
39+#[derive(Debug, thiserror::Error)]
40+pub enum SessionError {
41+ #[error("HOME environment variable is not set")]
42+ MissingHome,
43+ #[error("Session directory must be absolute: {0}")]
44+ NonAbsoluteDirectory(PathBuf),
45+ #[error("Session directory does not exist: {0}")]
46+ MissingDirectory(PathBuf),
47+ #[error("Session already exists for directory: {0}")]
48+ DirectoryAlreadyHasSession(PathBuf),
49+ #[error("Session not found: {0}")]
50+ SessionNotFound(Uuid),
51+ #[error("Ref count underflow for session: {0}")]
52+ RefCountUnderflow(Uuid),
53+ #[error(transparent)]
54+ Io(#[from] std::io::Error),
55+ #[error(transparent)]
56+ TomlDe(#[from] toml::de::Error),
57+ #[error(transparent)]
58+ TomlSer(#[from] toml::ser::Error),
59+}
60+
61+impl SessionManager {
62+ pub fn new() -> Result<Self, SessionError> {
63+ let home = env::var_os("HOME").ok_or(SessionError::MissingHome)?;
64+ Ok(Self::with_global_dir(
65+ PathBuf::from(home).join(GLOBAL_DIR_NAME),
66+ ))
67+ }
68+
69+ pub fn with_global_dir(global_dir: PathBuf) -> Self {
70+ let index_path = global_dir.join(SESSION_INDEX_FILENAME);
71+ Self {
72+ global_dir,
73+ index_path,
74+ }
75+ }
76+
77+ pub fn index_path(&self) -> &Path {
78+ &self.index_path
79+ }
80+
81+ pub fn create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
82+ let directory = self.normalize_directory(directory)?;
83+ let mut index = self.read_index()?;
84+
85+ if index.sessions.iter().any(|s| s.directory == directory) {
86+ return Err(SessionError::DirectoryAlreadyHasSession(directory));
87+ }
88+
89+ let now = OffsetDateTime::now_utc();
90+ let session = SessionRecord {
91+ id: Uuid::now_v7(),
92+ directory: directory.clone(),
93+ last_active: now,
94+ ref_count: 1,
95+ };
96+
97+ fs::create_dir_all(self.instance_dir_for(&directory))?;
98+ index.sessions.push(session.clone());
99+ self.write_index(&index)?;
100+
101+ Ok(session)
102+ }
103+
104+ pub fn get_or_create_session(&self, directory: &Path) -> Result<SessionRecord, SessionError> {
105+ let directory = self.normalize_directory(directory)?;
106+ let mut index = self.read_index()?;
107+
108+ if let Some(pos) = index.sessions.iter().position(|s| s.directory == directory) {
109+ if !self
110+ .instance_dir_for(&index.sessions[pos].directory)
111+ .is_dir()
112+ {
113+ index.sessions.remove(pos);
114+ } else {
115+ let now = OffsetDateTime::now_utc();
116+ let session = &mut index.sessions[pos];
117+ session.ref_count = session.ref_count.saturating_add(1);
118+ session.last_active = now;
119+ let updated = session.clone();
120+ self.write_index(&index)?;
121+ return Ok(updated);
122+ }
123+ }
124+
125+ let now = OffsetDateTime::now_utc();
126+ let session = SessionRecord {
127+ id: Uuid::now_v7(),
128+ directory: directory.clone(),
129+ last_active: now,
130+ ref_count: 1,
131+ };
132+
133+ fs::create_dir_all(self.instance_dir_for(&directory))?;
134+ index.sessions.push(session.clone());
135+ self.write_index(&index)?;
136+
137+ Ok(session)
138+ }
139+
140+ pub fn list_sessions(&self) -> Result<Vec<SessionRecord>, SessionError> {
141+ let mut index = self.read_index()?;
142+ let removed = self.remove_orphans(&mut index);
143+ if removed > 0 {
144+ self.write_index(&index)?;
145+ }
146+ Ok(index.sessions)
147+ }
148+
149+ pub fn delete_session(&self, id: Uuid) -> Result<bool, SessionError> {
150+ let mut index = self.read_index()?;
151+ let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
152+ return Ok(false);
153+ };
154+
155+ let session = index.sessions.remove(pos);
156+ let instance_dir = self.instance_dir_for(&session.directory);
157+ match fs::remove_dir_all(&instance_dir) {
158+ Ok(_) => {}
159+ Err(err) if err.kind() == io::ErrorKind::NotFound => {}
160+ Err(err) => return Err(err.into()),
161+ }
162+
163+ self.write_index(&index)?;
164+ Ok(true)
165+ }
166+
167+ pub fn bump_last_active(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
168+ let now = OffsetDateTime::now_utc();
169+ self.update_session(id, |session| {
170+ session.last_active = now;
171+ Ok(())
172+ })
173+ }
174+
175+ pub fn increment_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
176+ let now = OffsetDateTime::now_utc();
177+ self.update_session(id, |session| {
178+ session.ref_count = session.ref_count.saturating_add(1);
179+ session.last_active = now;
180+ Ok(())
181+ })
182+ }
183+
184+ pub fn decrement_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
185+ let now = OffsetDateTime::now_utc();
186+ self.update_session(id, |session| {
187+ if session.ref_count == 0 {
188+ return Err(SessionError::RefCountUnderflow(id));
189+ }
190+ session.ref_count -= 1;
191+ session.last_active = now;
192+ Ok(())
193+ })
194+ }
195+
196+ pub fn cleanup_orphans(&self) -> Result<usize, SessionError> {
197+ let mut index = self.read_index()?;
198+ let removed = self.remove_orphans(&mut index);
199+ if removed > 0 {
200+ self.write_index(&index)?;
201+ }
202+ Ok(removed)
203+ }
204+
205+ fn update_session<F>(&self, id: Uuid, mut update: F) -> Result<SessionRecord, SessionError>
206+ where
207+ F: FnMut(&mut SessionRecord) -> Result<(), SessionError>,
208+ {
209+ let mut index = self.read_index()?;
210+ let Some(pos) = index.sessions.iter().position(|s| s.id == id) else {
211+ return Err(SessionError::SessionNotFound(id));
212+ };
213+
214+ if !self
215+ .instance_dir_for(&index.sessions[pos].directory)
216+ .is_dir()
217+ {
218+ index.sessions.remove(pos);
219+ self.write_index(&index)?;
220+ return Err(SessionError::SessionNotFound(id));
221+ }
222+
223+ update(&mut index.sessions[pos])?;
224+ let updated = index.sessions[pos].clone();
225+ self.write_index(&index)?;
226+ Ok(updated)
227+ }
228+
229+ fn normalize_directory(&self, directory: &Path) -> Result<PathBuf, SessionError> {
230+ if !directory.is_absolute() {
231+ return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf()));
232+ }
233+ if !directory.exists() {
234+ return Err(SessionError::MissingDirectory(directory.to_path_buf()));
235+ }
236+ Ok(directory.canonicalize()?)
237+ }
238+
239+ fn instance_dir_for(&self, directory: &Path) -> PathBuf {
240+ directory.join(INSTANCE_DIR_NAME)
241+ }
242+
243+ fn remove_orphans(&self, index: &mut SessionIndex) -> usize {
244+ let before = index.sessions.len();
245+ index
246+ .sessions
247+ .retain(|s| self.instance_dir_for(&s.directory).is_dir());
248+ before - index.sessions.len()
249+ }
250+
251+ fn read_index(&self) -> Result<SessionIndex, SessionError> {
252+ if !self.index_path.exists() {
253+ return Ok(SessionIndex::default());
254+ }
255+ let content = fs::read_to_string(&self.index_path)?;
256+ Ok(toml::from_str(&content)?)
257+ }
258+
259+ fn write_index(&self, index: &SessionIndex) -> Result<(), SessionError> {
260+ fs::create_dir_all(&self.global_dir)?;
261+ let content = toml::to_string_pretty(index)?;
262+ atomic_write(&self.index_path, content.as_bytes())?;
263+ Ok(())
264+ }
265+}
266+
267+fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
268+ let Some(parent) = path.parent() else {
269+ return Err(io::Error::new(
270+ io::ErrorKind::InvalidInput,
271+ "path has no parent directory",
272+ ));
273+ };
274+
275+ fs::create_dir_all(parent)?;
276+ let mut temp = tempfile::Builder::new()
277+ .prefix(SESSION_TEMP_PREFIX)
278+ .suffix(SESSION_TOML_SUFFIX)
279+ .tempfile_in(parent)?;
280+ temp.write_all(content)?;
281+ temp.flush()?;
282+ temp.persist(path).map_err(|err| err.error)?;
283+ Ok(())
284+}
285+
286+#[cfg(test)]
287+mod tests {
288+ use super::*;
289+ use std::fs;
290+ use tempfile::TempDir;
291+ use time::OffsetDateTime;
292+
293+ fn manager(temp: &TempDir) -> SessionManager {
294+ SessionManager::with_global_dir(temp.path().join("global"))
295+ }
296+
297+ fn create_project_dir(temp: &TempDir) -> PathBuf {
298+ let dir = temp.path().join("project");
299+ fs::create_dir_all(&dir).unwrap();
300+ dir
301+ }
302+
303+ #[test]
304+ fn create_session_writes_index_and_instance_dir() {
305+ let temp = TempDir::new().unwrap();
306+ let mgr = manager(&temp);
307+ let project_dir = create_project_dir(&temp);
308+
309+ let session = mgr.create_session(&project_dir).unwrap();
310+
311+ assert!(mgr.index_path().exists());
312+ assert_eq!(session.directory, project_dir.canonicalize().unwrap());
313+ assert_eq!(session.ref_count, 1);
314+ assert!(project_dir.join(INSTANCE_DIR_NAME).is_dir());
315+ }
316+
317+ #[test]
318+ fn create_session_rejects_non_absolute_directory() {
319+ let temp = TempDir::new().unwrap();
320+ let mgr = manager(&temp);
321+
322+ let err = mgr.create_session(Path::new("relative/path")).unwrap_err();
323+
324+ assert!(matches!(err, SessionError::NonAbsoluteDirectory(_)));
325+ }
326+
327+ #[test]
328+ fn create_session_rejects_missing_directory() {
329+ let temp = TempDir::new().unwrap();
330+ let mgr = manager(&temp);
331+ let missing = temp.path().join("missing");
332+
333+ let err = mgr.create_session(&missing).unwrap_err();
334+
335+ assert!(matches!(err, SessionError::MissingDirectory(_)));
336+ }
337+
338+ #[test]
339+ fn create_session_rejects_duplicate_directory() {
340+ let temp = TempDir::new().unwrap();
341+ let mgr = manager(&temp);
342+ let project_dir = create_project_dir(&temp);
343+
344+ let _session = mgr.create_session(&project_dir).unwrap();
345+ let err = mgr.create_session(&project_dir).unwrap_err();
346+
347+ assert!(matches!(err, SessionError::DirectoryAlreadyHasSession(_)));
348+ }
349+
350+ #[test]
351+ fn get_or_create_increments_ref_count_for_existing_session() {
352+ let temp = TempDir::new().unwrap();
353+ let mgr = manager(&temp);
354+ let project_dir = create_project_dir(&temp);
355+
356+ let first = mgr.create_session(&project_dir).unwrap();
357+ let second = mgr.get_or_create_session(&project_dir).unwrap();
358+
359+ assert_eq!(first.id, second.id);
360+ assert_eq!(second.ref_count, 2);
361+ }
362+
363+ #[test]
364+ fn decrement_ref_count_errors_on_underflow() {
365+ let temp = TempDir::new().unwrap();
366+ let mgr = manager(&temp);
367+ let project_dir = create_project_dir(&temp);
368+
369+ let session = mgr.create_session(&project_dir).unwrap();
370+ let session = mgr.decrement_ref_count(session.id).unwrap();
371+ assert_eq!(session.ref_count, 0);
372+
373+ let err = mgr.decrement_ref_count(session.id).unwrap_err();
374+ assert!(matches!(err, SessionError::RefCountUnderflow(_)));
375+ }
376+
377+ #[test]
378+ fn list_sessions_cleans_orphans() {
379+ let temp = TempDir::new().unwrap();
380+ let mgr = manager(&temp);
381+ let project_dir = create_project_dir(&temp);
382+
383+ let _session = mgr.create_session(&project_dir).unwrap();
384+ fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
385+
386+ let sessions = mgr.list_sessions().unwrap();
387+ assert!(sessions.is_empty());
388+ }
389+
390+ #[test]
391+ fn delete_session_removes_instance_dir_and_index() {
392+ let temp = TempDir::new().unwrap();
393+ let mgr = manager(&temp);
394+ let project_dir = create_project_dir(&temp);
395+
396+ let session = mgr.create_session(&project_dir).unwrap();
397+ let removed = mgr.delete_session(session.id).unwrap();
398+
399+ assert!(removed);
400+ assert!(!project_dir.join(INSTANCE_DIR_NAME).exists());
401+ assert!(mgr.list_sessions().unwrap().is_empty());
402+ }
403+
404+ #[test]
405+ fn invalid_toml_returns_error() {
406+ let temp = TempDir::new().unwrap();
407+ let mgr = manager(&temp);
408+ let index_path = mgr.index_path();
409+ fs::create_dir_all(index_path.parent().unwrap()).unwrap();
410+ fs::write(index_path, "this is not toml").unwrap();
411+
412+ let err = mgr.list_sessions().unwrap_err();
413+
414+ assert!(matches!(err, SessionError::TomlDe(_)));
415+ }
416+
417+ #[test]
418+ fn bump_last_active_updates_timestamp() {
419+ let temp = TempDir::new().unwrap();
420+ let mgr = manager(&temp);
421+ let project_dir = create_project_dir(&temp);
422+
423+ let session = mgr.create_session(&project_dir).unwrap();
424+ let before = session.last_active;
425+ std::thread::sleep(std::time::Duration::from_millis(5));
426+ let updated = mgr.bump_last_active(session.id).unwrap();
427+ let now = OffsetDateTime::now_utc();
428+
429+ assert!(updated.last_active >= before);
430+ assert!(updated.last_active <= now);
431+ }
432+
433+ #[test]
434+ fn cleanup_orphans_returns_removed_count() {
435+ let temp = TempDir::new().unwrap();
436+ let mgr = manager(&temp);
437+ let project_dir = create_project_dir(&temp);
438+
439+ let _session = mgr.create_session(&project_dir).unwrap();
440+ fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap();
441+
442+ let removed = mgr.cleanup_orphans().unwrap();
443+ assert_eq!(removed, 1);
444+ assert!(mgr.list_sessions().unwrap().is_empty());
445+ }
446+
447+ #[test]
448+ fn increment_ref_count_updates_last_active() {
449+ let temp = TempDir::new().unwrap();
450+ let mgr = manager(&temp);
451+ let project_dir = create_project_dir(&temp);
452+
453+ let session = mgr.create_session(&project_dir).unwrap();
454+ std::thread::sleep(std::time::Duration::from_millis(5));
455+ let updated = mgr.increment_ref_count(session.id).unwrap();
456+
457+ assert_eq!(updated.ref_count, session.ref_count + 1);
458+ assert!(updated.last_active >= session.last_active);
459+ }
460+
461+ #[test]
462+ fn decrement_ref_count_updates_last_active() {
463+ let temp = TempDir::new().unwrap();
464+ let mgr = manager(&temp);
465+ let project_dir = create_project_dir(&temp);
466+
467+ let session = mgr.create_session(&project_dir).unwrap();
468+ std::thread::sleep(std::time::Duration::from_millis(5));
469+ let updated = mgr.decrement_ref_count(session.id).unwrap();
470+
471+ assert_eq!(updated.ref_count, session.ref_count - 1);
472+ assert!(updated.last_active >= session.last_active);
473+ }
474+
475+ #[test]
476+ fn list_sessions_returns_active_sessions() {
477+ let temp = TempDir::new().unwrap();
478+ let mgr = manager(&temp);
479+ let project_dir = create_project_dir(&temp);
480+
481+ let session = mgr.create_session(&project_dir).unwrap();
482+ let sessions = mgr.list_sessions().unwrap();
483+
484+ assert_eq!(sessions.len(), 1);
485+ assert_eq!(sessions[0].id, session.id);
486+ }
487+}