mirror of
https://github.com/robcholz/vibebox.git
synced 2026-04-02 00:20:13 +02:00
feat: added session manager
This commit is contained in:
413
Cargo.lock
generated
413
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
12
docs/tasks.md
Normal file
12
docs/tasks.md
Normal file
@@ -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.
|
||||
@@ -1,3 +1,7 @@
|
||||
mod session_manager;
|
||||
|
||||
pub use session_manager::{SessionError, SessionManager, SessionRecord};
|
||||
|
||||
use std::{
|
||||
env,
|
||||
ffi::OsString,
|
||||
|
||||
487
src/session_manager.rs
Normal file
487
src/session_manager.rs
Normal file
@@ -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<SessionRecord>,
|
||||
}
|
||||
|
||||
#[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<Self, SessionError> {
|
||||
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<SessionRecord, SessionError> {
|
||||
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<SessionRecord, SessionError> {
|
||||
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<Vec<SessionRecord>, 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<bool, SessionError> {
|
||||
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<SessionRecord, SessionError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
self.update_session(id, |session| {
|
||||
session.last_active = now;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn increment_ref_count(&self, id: Uuid) -> Result<SessionRecord, SessionError> {
|
||||
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<SessionRecord, SessionError> {
|
||||
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<usize, SessionError> {
|
||||
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<F>(&self, id: Uuid, mut update: F) -> Result<SessionRecord, SessionError>
|
||||
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<PathBuf, SessionError> {
|
||||
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<SessionIndex, SessionError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user