diff --git a/Cargo.lock b/Cargo.lock index 870debc..c3a50fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,28 @@ dependencies = [ "objc2", ] +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "dispatch2" version = "0.3.0" @@ -29,6 +51,72 @@ dependencies = [ "objc2", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lexopt" version = "0.3.1" @@ -41,6 +129,18 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "objc2" version = "0.6.3" @@ -106,6 +206,232 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "vibebox" version = "0.1.0" @@ -117,4 +443,91 @@ dependencies = [ "objc2", "objc2-foundation", "objc2-virtualization", + "serde", + "tempfile", + "thiserror", + "time", + "toml", + "uuid", ] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/Cargo.toml b/Cargo.toml index 54a5014..d683a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,9 @@ block2 = "*" dispatch2 = "*" libc = "*" lexopt = "0.3" +serde = { version = "1", features = ["derive"] } +tempfile = "3" +thiserror = "2.0.18" +time = { version = "0.3", features = ["serde", "formatting", "parsing"] } +toml = "0.9.8" +uuid = { version = "1", features = ["v7", "serde"] } diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 0000000..4a8ee9f --- /dev/null +++ b/docs/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +1. [x] Confirm requirements and scope from `implementations.md`. +2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, refcount handling, cleanup orphaned index entries). +3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, `thiserror`). +4. [x] Write user journeys and unit test cases first (happy paths + error paths) for session lifecycle and index persistence. +5. [x] Implement `SessionManager` and supporting types with `Result`-based errors, filesystem IO, and atomic writes. +6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on missing instance dir). +7. [ ] Run tests and coverage; target >=80% line/branch coverage using a Rust coverage tool (e.g., `cargo llvm-cov`). +8. [x] Refactor for clarity and reliability while keeping tests green. +9. [ ] Add TUI interface. +10. [ ] Integrate VM and SessionManager together. diff --git a/src/main.rs b/src/main.rs index 5e191ff..687c732 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +mod session_manager; + +pub use session_manager::{SessionError, SessionManager, SessionRecord}; + use std::{ env, ffi::OsString, diff --git a/src/session_manager.rs b/src/session_manager.rs new file mode 100644 index 0000000..45444a2 --- /dev/null +++ b/src/session_manager.rs @@ -0,0 +1,487 @@ +use std::{ + env, fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +pub const INSTANCE_DIR_NAME: &str = ".vibebox"; +pub const GLOBAL_DIR_NAME: &str = ".vibebox"; +pub const SESSION_INDEX_FILENAME: &str = "sessions.toml"; +pub const SESSION_TEMP_PREFIX: &str = "sessions"; +pub const SESSION_TOML_SUFFIX: &str = ".toml"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionRecord { + pub id: Uuid, + pub directory: PathBuf, + #[serde(with = "time::serde::rfc3339")] + pub last_active: OffsetDateTime, + #[serde(default)] + pub ref_count: u64, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct SessionIndex { + #[serde(default)] + sessions: Vec, +} + +#[derive(Debug)] +pub struct SessionManager { + global_dir: PathBuf, + index_path: PathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("HOME environment variable is not set")] + MissingHome, + #[error("Session directory must be absolute: {0}")] + NonAbsoluteDirectory(PathBuf), + #[error("Session directory does not exist: {0}")] + MissingDirectory(PathBuf), + #[error("Session already exists for directory: {0}")] + DirectoryAlreadyHasSession(PathBuf), + #[error("Session not found: {0}")] + SessionNotFound(Uuid), + #[error("Ref count underflow for session: {0}")] + RefCountUnderflow(Uuid), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + TomlDe(#[from] toml::de::Error), + #[error(transparent)] + TomlSer(#[from] toml::ser::Error), +} + +impl SessionManager { + pub fn new() -> Result { + let home = env::var_os("HOME").ok_or(SessionError::MissingHome)?; + Ok(Self::with_global_dir( + PathBuf::from(home).join(GLOBAL_DIR_NAME), + )) + } + + pub fn with_global_dir(global_dir: PathBuf) -> Self { + let index_path = global_dir.join(SESSION_INDEX_FILENAME); + Self { + global_dir, + index_path, + } + } + + pub fn index_path(&self) -> &Path { + &self.index_path + } + + pub fn create_session(&self, directory: &Path) -> Result { + let directory = self.normalize_directory(directory)?; + let mut index = self.read_index()?; + + if index.sessions.iter().any(|s| s.directory == directory) { + return Err(SessionError::DirectoryAlreadyHasSession(directory)); + } + + let now = OffsetDateTime::now_utc(); + let session = SessionRecord { + id: Uuid::now_v7(), + directory: directory.clone(), + last_active: now, + ref_count: 1, + }; + + fs::create_dir_all(self.instance_dir_for(&directory))?; + index.sessions.push(session.clone()); + self.write_index(&index)?; + + Ok(session) + } + + pub fn get_or_create_session(&self, directory: &Path) -> Result { + let directory = self.normalize_directory(directory)?; + let mut index = self.read_index()?; + + if let Some(pos) = index.sessions.iter().position(|s| s.directory == directory) { + if !self + .instance_dir_for(&index.sessions[pos].directory) + .is_dir() + { + index.sessions.remove(pos); + } else { + let now = OffsetDateTime::now_utc(); + let session = &mut index.sessions[pos]; + session.ref_count = session.ref_count.saturating_add(1); + session.last_active = now; + let updated = session.clone(); + self.write_index(&index)?; + return Ok(updated); + } + } + + let now = OffsetDateTime::now_utc(); + let session = SessionRecord { + id: Uuid::now_v7(), + directory: directory.clone(), + last_active: now, + ref_count: 1, + }; + + fs::create_dir_all(self.instance_dir_for(&directory))?; + index.sessions.push(session.clone()); + self.write_index(&index)?; + + Ok(session) + } + + pub fn list_sessions(&self) -> Result, SessionError> { + let mut index = self.read_index()?; + let removed = self.remove_orphans(&mut index); + if removed > 0 { + self.write_index(&index)?; + } + Ok(index.sessions) + } + + pub fn delete_session(&self, id: Uuid) -> Result { + let mut index = self.read_index()?; + let Some(pos) = index.sessions.iter().position(|s| s.id == id) else { + return Ok(false); + }; + + let session = index.sessions.remove(pos); + let instance_dir = self.instance_dir_for(&session.directory); + match fs::remove_dir_all(&instance_dir) { + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + self.write_index(&index)?; + Ok(true) + } + + pub fn bump_last_active(&self, id: Uuid) -> Result { + let now = OffsetDateTime::now_utc(); + self.update_session(id, |session| { + session.last_active = now; + Ok(()) + }) + } + + pub fn increment_ref_count(&self, id: Uuid) -> Result { + let now = OffsetDateTime::now_utc(); + self.update_session(id, |session| { + session.ref_count = session.ref_count.saturating_add(1); + session.last_active = now; + Ok(()) + }) + } + + pub fn decrement_ref_count(&self, id: Uuid) -> Result { + let now = OffsetDateTime::now_utc(); + self.update_session(id, |session| { + if session.ref_count == 0 { + return Err(SessionError::RefCountUnderflow(id)); + } + session.ref_count -= 1; + session.last_active = now; + Ok(()) + }) + } + + pub fn cleanup_orphans(&self) -> Result { + let mut index = self.read_index()?; + let removed = self.remove_orphans(&mut index); + if removed > 0 { + self.write_index(&index)?; + } + Ok(removed) + } + + fn update_session(&self, id: Uuid, mut update: F) -> Result + where + F: FnMut(&mut SessionRecord) -> Result<(), SessionError>, + { + let mut index = self.read_index()?; + let Some(pos) = index.sessions.iter().position(|s| s.id == id) else { + return Err(SessionError::SessionNotFound(id)); + }; + + if !self + .instance_dir_for(&index.sessions[pos].directory) + .is_dir() + { + index.sessions.remove(pos); + self.write_index(&index)?; + return Err(SessionError::SessionNotFound(id)); + } + + update(&mut index.sessions[pos])?; + let updated = index.sessions[pos].clone(); + self.write_index(&index)?; + Ok(updated) + } + + fn normalize_directory(&self, directory: &Path) -> Result { + if !directory.is_absolute() { + return Err(SessionError::NonAbsoluteDirectory(directory.to_path_buf())); + } + if !directory.exists() { + return Err(SessionError::MissingDirectory(directory.to_path_buf())); + } + Ok(directory.canonicalize()?) + } + + fn instance_dir_for(&self, directory: &Path) -> PathBuf { + directory.join(INSTANCE_DIR_NAME) + } + + fn remove_orphans(&self, index: &mut SessionIndex) -> usize { + let before = index.sessions.len(); + index + .sessions + .retain(|s| self.instance_dir_for(&s.directory).is_dir()); + before - index.sessions.len() + } + + fn read_index(&self) -> Result { + if !self.index_path.exists() { + return Ok(SessionIndex::default()); + } + let content = fs::read_to_string(&self.index_path)?; + Ok(toml::from_str(&content)?) + } + + fn write_index(&self, index: &SessionIndex) -> Result<(), SessionError> { + fs::create_dir_all(&self.global_dir)?; + let content = toml::to_string_pretty(index)?; + atomic_write(&self.index_path, content.as_bytes())?; + Ok(()) + } +} + +fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> { + let Some(parent) = path.parent() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path has no parent directory", + )); + }; + + fs::create_dir_all(parent)?; + let mut temp = tempfile::Builder::new() + .prefix(SESSION_TEMP_PREFIX) + .suffix(SESSION_TOML_SUFFIX) + .tempfile_in(parent)?; + temp.write_all(content)?; + temp.flush()?; + temp.persist(path).map_err(|err| err.error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + use time::OffsetDateTime; + + fn manager(temp: &TempDir) -> SessionManager { + SessionManager::with_global_dir(temp.path().join("global")) + } + + fn create_project_dir(temp: &TempDir) -> PathBuf { + let dir = temp.path().join("project"); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn create_session_writes_index_and_instance_dir() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let session = mgr.create_session(&project_dir).unwrap(); + + assert!(mgr.index_path().exists()); + assert_eq!(session.directory, project_dir.canonicalize().unwrap()); + assert_eq!(session.ref_count, 1); + assert!(project_dir.join(INSTANCE_DIR_NAME).is_dir()); + } + + #[test] + fn create_session_rejects_non_absolute_directory() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + + let err = mgr.create_session(Path::new("relative/path")).unwrap_err(); + + assert!(matches!(err, SessionError::NonAbsoluteDirectory(_))); + } + + #[test] + fn create_session_rejects_missing_directory() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let missing = temp.path().join("missing"); + + let err = mgr.create_session(&missing).unwrap_err(); + + assert!(matches!(err, SessionError::MissingDirectory(_))); + } + + #[test] + fn create_session_rejects_duplicate_directory() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let _session = mgr.create_session(&project_dir).unwrap(); + let err = mgr.create_session(&project_dir).unwrap_err(); + + assert!(matches!(err, SessionError::DirectoryAlreadyHasSession(_))); + } + + #[test] + fn get_or_create_increments_ref_count_for_existing_session() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let first = mgr.create_session(&project_dir).unwrap(); + let second = mgr.get_or_create_session(&project_dir).unwrap(); + + assert_eq!(first.id, second.id); + assert_eq!(second.ref_count, 2); + } + + #[test] + fn decrement_ref_count_errors_on_underflow() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let session = mgr.create_session(&project_dir).unwrap(); + let session = mgr.decrement_ref_count(session.id).unwrap(); + assert_eq!(session.ref_count, 0); + + let err = mgr.decrement_ref_count(session.id).unwrap_err(); + assert!(matches!(err, SessionError::RefCountUnderflow(_))); + } + + #[test] + fn list_sessions_cleans_orphans() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let _session = mgr.create_session(&project_dir).unwrap(); + fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap(); + + let sessions = mgr.list_sessions().unwrap(); + assert!(sessions.is_empty()); + } + + #[test] + fn delete_session_removes_instance_dir_and_index() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let session = mgr.create_session(&project_dir).unwrap(); + let removed = mgr.delete_session(session.id).unwrap(); + + assert!(removed); + assert!(!project_dir.join(INSTANCE_DIR_NAME).exists()); + assert!(mgr.list_sessions().unwrap().is_empty()); + } + + #[test] + fn invalid_toml_returns_error() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let index_path = mgr.index_path(); + fs::create_dir_all(index_path.parent().unwrap()).unwrap(); + fs::write(index_path, "this is not toml").unwrap(); + + let err = mgr.list_sessions().unwrap_err(); + + assert!(matches!(err, SessionError::TomlDe(_))); + } + + #[test] + fn bump_last_active_updates_timestamp() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let session = mgr.create_session(&project_dir).unwrap(); + let before = session.last_active; + std::thread::sleep(std::time::Duration::from_millis(5)); + let updated = mgr.bump_last_active(session.id).unwrap(); + let now = OffsetDateTime::now_utc(); + + assert!(updated.last_active >= before); + assert!(updated.last_active <= now); + } + + #[test] + fn cleanup_orphans_returns_removed_count() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let _session = mgr.create_session(&project_dir).unwrap(); + fs::remove_dir_all(project_dir.join(INSTANCE_DIR_NAME)).unwrap(); + + let removed = mgr.cleanup_orphans().unwrap(); + assert_eq!(removed, 1); + assert!(mgr.list_sessions().unwrap().is_empty()); + } + + #[test] + fn increment_ref_count_updates_last_active() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let session = mgr.create_session(&project_dir).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + let updated = mgr.increment_ref_count(session.id).unwrap(); + + assert_eq!(updated.ref_count, session.ref_count + 1); + assert!(updated.last_active >= session.last_active); + } + + #[test] + fn decrement_ref_count_updates_last_active() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let session = mgr.create_session(&project_dir).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + let updated = mgr.decrement_ref_count(session.id).unwrap(); + + assert_eq!(updated.ref_count, session.ref_count - 1); + assert!(updated.last_active >= session.last_active); + } + + #[test] + fn list_sessions_returns_active_sessions() { + let temp = TempDir::new().unwrap(); + let mgr = manager(&temp); + let project_dir = create_project_dir(&temp); + + let session = mgr.create_session(&project_dir).unwrap(); + let sessions = mgr.list_sessions().unwrap(); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].id, session.id); + } +}