consolidate .g3/session -> .g3/sessions/*

This commit is contained in:
Dhanji R. Prasanna
2025-12-23 16:22:12 +11:00
parent 0b023b610f
commit ed246ce434
2 changed files with 183 additions and 34 deletions

View File

@@ -2,8 +2,13 @@
//! //!
//! This module provides functionality to save and restore session state, //! This module provides functionality to save and restore session state,
//! allowing users to resume work across multiple g3 invocations. //! allowing users to resume work across multiple g3 invocations.
//!
//! The session continuation uses a symlink-based approach:
//! - `.g3/session` is a symlink pointing to the current session directory
//! - `latest.json` is stored inside each session directory (`.g3/sessions/<session_id>/latest.json`)
//! - Following the symlink gives access to the current session's continuation data
use anyhow::Result; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tracing::{debug, error, warn}; use tracing::{debug, error, warn};
@@ -11,6 +16,9 @@ use tracing::{debug, error, warn};
/// Version of the session continuation format /// Version of the session continuation format
const CONTINUATION_VERSION: &str = "1.0"; const CONTINUATION_VERSION: &str = "1.0";
/// Name of the continuation file within each session directory
const CONTINUATION_FILENAME: &str = "latest.json";
/// Session continuation artifact containing all information needed to resume a session /// Session continuation artifact containing all information needed to resume a session
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionContinuation { pub struct SessionContinuation {
@@ -60,42 +68,117 @@ impl SessionContinuation {
} }
} }
/// Get the path to the .g3/session directory /// Get the path to the .g3 directory
fn get_g3_dir() -> PathBuf {
crate::get_g3_dir()
}
/// Get the path to the .g3/session symlink
pub fn get_session_dir() -> PathBuf { pub fn get_session_dir() -> PathBuf {
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); get_g3_dir().join("session")
current_dir.join(".g3").join("session") }
/// Get the path to the .g3/sessions directory (where all sessions are stored)
fn get_sessions_dir() -> PathBuf {
get_g3_dir().join("sessions")
}
/// Get the path to a specific session's directory
fn get_session_path(session_id: &str) -> PathBuf {
get_sessions_dir().join(session_id)
} }
/// Get the path to the latest.json continuation file /// Get the path to the latest.json continuation file
/// This follows the symlink to get the actual path
pub fn get_latest_continuation_path() -> PathBuf { pub fn get_latest_continuation_path() -> PathBuf {
get_session_dir().join("latest.json") get_session_dir().join(CONTINUATION_FILENAME)
} }
/// Ensure the .g3/session directory exists /// Ensure the .g3 directory exists (but not the session symlink)
pub fn ensure_session_dir() -> Result<PathBuf> { pub fn ensure_session_dir() -> Result<PathBuf> {
let session_dir = get_session_dir(); let g3_dir = get_g3_dir();
if !session_dir.exists() { if !g3_dir.exists() {
std::fs::create_dir_all(&session_dir)?; std::fs::create_dir_all(&g3_dir)?;
debug!("Created session directory: {:?}", session_dir); debug!("Created .g3 directory: {:?}", g3_dir);
} }
Ok(session_dir) Ok(get_session_dir())
}
/// Update the .g3/session symlink to point to the given session directory
fn update_session_symlink(session_id: &str) -> Result<()> {
let symlink_path = get_session_dir();
let target_path = get_session_path(session_id);
// Remove existing symlink or directory if it exists
if symlink_path.exists() || symlink_path.is_symlink() {
if symlink_path.is_symlink() {
std::fs::remove_file(&symlink_path)
.context("Failed to remove existing session symlink")?;
} else if symlink_path.is_dir() {
// Migration: if it's an old-style directory, remove it
std::fs::remove_dir_all(&symlink_path)
.context("Failed to remove old session directory")?;
debug!("Migrated old .g3/session directory to symlink");
}
}
// Create the symlink
#[cfg(unix)]
std::os::unix::fs::symlink(&target_path, &symlink_path)
.context("Failed to create session symlink")?;
#[cfg(windows)]
std::os::windows::fs::symlink_dir(&target_path, &symlink_path)
.context("Failed to create session symlink")?;
debug!("Updated session symlink: {:?} -> {:?}", symlink_path, target_path);
Ok(())
} }
/// Save a session continuation artifact /// Save a session continuation artifact
/// This saves latest.json in the session's directory and updates the symlink
pub fn save_continuation(continuation: &SessionContinuation) -> Result<PathBuf> { pub fn save_continuation(continuation: &SessionContinuation) -> Result<PathBuf> {
let session_dir = ensure_session_dir()?; let session_id = &continuation.session_id;
let latest_path = session_dir.join("latest.json"); let session_path = get_session_path(session_id);
// Ensure the session directory exists
if !session_path.exists() {
std::fs::create_dir_all(&session_path)
.context("Failed to create session directory")?;
}
// Save latest.json in the session directory
let latest_path = session_path.join(CONTINUATION_FILENAME);
let json = serde_json::to_string_pretty(continuation)?; let json = serde_json::to_string_pretty(continuation)?;
std::fs::write(&latest_path, &json)?; std::fs::write(&latest_path, &json)?;
// Update the symlink to point to this session
update_session_symlink(session_id)?;
debug!("Saved session continuation to {:?}", latest_path); debug!("Saved session continuation to {:?}", latest_path);
Ok(latest_path) Ok(latest_path)
} }
/// Load the latest session continuation artifact if it exists /// Load the latest session continuation artifact if it exists
pub fn load_continuation() -> Result<Option<SessionContinuation>> { pub fn load_continuation() -> Result<Option<SessionContinuation>> {
let latest_path = get_latest_continuation_path(); let symlink_path = get_session_dir();
// Check if the symlink exists and is valid
if !symlink_path.is_symlink() && !symlink_path.exists() {
debug!("No session symlink found at {:?}", symlink_path);
return Ok(None);
}
// If it's a symlink, check if the target exists
if symlink_path.is_symlink() {
let target = std::fs::read_link(&symlink_path)?;
if !target.exists() && !symlink_path.exists() {
debug!("Session symlink target does not exist: {:?}", target);
return Ok(None);
}
}
let latest_path = symlink_path.join(CONTINUATION_FILENAME);
if !latest_path.exists() { if !latest_path.exists() {
debug!("No continuation file found at {:?}", latest_path); debug!("No continuation file found at {:?}", latest_path);
@@ -117,13 +200,17 @@ pub fn load_continuation() -> Result<Option<SessionContinuation>> {
Ok(Some(continuation)) Ok(Some(continuation))
} }
/// Clear all session continuation artifacts (for /clear command) /// Clear the session continuation symlink (for /clear command)
/// This only removes the symlink, not the actual session data
pub fn clear_continuation() -> Result<()> { pub fn clear_continuation() -> Result<()> {
let session_dir = get_session_dir(); let symlink_path = get_session_dir();
if session_dir.exists() { if symlink_path.is_symlink() {
// Remove all files in the session directory std::fs::remove_file(&symlink_path)?;
for entry in std::fs::read_dir(&session_dir)? { debug!("Removed session symlink: {:?}", symlink_path);
} else if symlink_path.is_dir() {
// Handle old-style directory (migration case)
for entry in std::fs::read_dir(&symlink_path)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
if path.is_file() { if path.is_file() {
@@ -131,9 +218,11 @@ pub fn clear_continuation() -> Result<()> {
debug!("Removed session file: {:?}", path); debug!("Removed session file: {:?}", path);
} }
} }
debug!("Cleared session continuation artifacts"); std::fs::remove_dir(&symlink_path)?;
debug!("Removed old session directory: {:?}", symlink_path);
} }
debug!("Cleared session continuation");
Ok(()) Ok(())
} }
@@ -186,7 +275,6 @@ pub fn load_context_from_session_log(session_log_path: &Path) -> Result<Option<s
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tempfile::TempDir;
#[test] #[test]
fn test_session_continuation_creation() { fn test_session_continuation_creation() {

View File

@@ -98,6 +98,10 @@ fn test_save_and_load_continuation() {
let saved_path = save_continuation(&original).expect("Failed to save continuation"); let saved_path = save_continuation(&original).expect("Failed to save continuation");
assert!(saved_path.exists()); assert!(saved_path.exists());
// Verify the symlink was created
let session_dir = get_session_dir();
assert!(session_dir.is_symlink(), "session should be a symlink");
// Load it back // Load it back
let loaded = load_continuation() let loaded = load_continuation()
.expect("Failed to load continuation") .expect("Failed to load continuation")
@@ -141,14 +145,15 @@ fn test_clear_continuation() {
); );
save_continuation(&continuation).expect("Failed to save"); save_continuation(&continuation).expect("Failed to save");
// Verify it exists // Verify the symlink exists
assert!(get_latest_continuation_path().exists()); let session_dir = get_session_dir();
assert!(session_dir.is_symlink(), "session should be a symlink after save");
// Clear it // Clear it
clear_continuation().expect("Failed to clear"); clear_continuation().expect("Failed to clear");
// Verify it's gone // Verify the symlink is gone
assert!(!get_latest_continuation_path().exists()); assert!(!session_dir.exists() && !session_dir.is_symlink(), "symlink should be removed");
// Loading should return None // Loading should return None
let result = load_continuation().expect("load should not error"); let result = load_continuation().expect("load should not error");
@@ -158,17 +163,23 @@ fn test_clear_continuation() {
} }
#[test] #[test]
fn test_ensure_session_dir_creates_directory() { fn test_ensure_session_dir_creates_g3_directory() {
let _lock = TEST_MUTEX.lock().unwrap(); let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env(); let (temp_dir, original_dir) = setup_test_env();
let session_dir = get_session_dir(); let g3_dir = temp_dir.path().join(".g3");
assert!(!session_dir.exists()); assert!(!g3_dir.exists());
ensure_session_dir().expect("Failed to ensure session dir"); ensure_session_dir().expect("Failed to ensure session dir");
assert!(session_dir.exists()); // The .g3 directory should exist, but not the session symlink
assert!(session_dir.is_dir()); assert!(g3_dir.exists(), ".g3 directory should be created");
assert!(g3_dir.is_dir(), ".g3 should be a directory");
// The session symlink should NOT exist until save_continuation is called
let session_dir = get_session_dir();
assert!(!session_dir.exists() && !session_dir.is_symlink(),
"session symlink should not exist until save_continuation is called");
teardown_test_env(original_dir); teardown_test_env(original_dir);
} }
@@ -257,9 +268,9 @@ fn test_continuation_serialization_format() {
} }
#[test] #[test]
fn test_multiple_saves_overwrite() { fn test_multiple_saves_update_symlink() {
let _lock = TEST_MUTEX.lock().unwrap(); let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env(); let (temp_dir, original_dir) = setup_test_env();
// Save first continuation // Save first continuation
let first = SessionContinuation::new( let first = SessionContinuation::new(
@@ -272,7 +283,12 @@ fn test_multiple_saves_overwrite() {
); );
save_continuation(&first).expect("Failed to save first"); save_continuation(&first).expect("Failed to save first");
// Save second continuation (should overwrite) // Verify symlink points to first session
let session_dir = get_session_dir();
let first_target = fs::read_link(&session_dir).expect("Failed to read symlink");
assert!(first_target.to_string_lossy().contains("first_session"));
// Save second continuation (should update symlink)
let second = SessionContinuation::new( let second = SessionContinuation::new(
"second_session".to_string(), "second_session".to_string(),
Some("Second summary".to_string()), Some("Second summary".to_string()),
@@ -283,6 +299,10 @@ fn test_multiple_saves_overwrite() {
); );
save_continuation(&second).expect("Failed to save second"); save_continuation(&second).expect("Failed to save second");
// Verify symlink now points to second session
let second_target = fs::read_link(&session_dir).expect("Failed to read symlink");
assert!(second_target.to_string_lossy().contains("second_session"));
// Load should return the second one // Load should return the second one
let loaded = load_continuation() let loaded = load_continuation()
.expect("Failed to load") .expect("Failed to load")
@@ -293,5 +313,46 @@ fn test_multiple_saves_overwrite() {
Some("Second summary".to_string()) Some("Second summary".to_string())
); );
// Both session directories should exist with their own latest.json
let sessions_dir = temp_dir.path().join(".g3").join("sessions");
assert!(sessions_dir.join("first_session").join("latest.json").exists());
assert!(sessions_dir.join("second_session").join("latest.json").exists());
teardown_test_env(original_dir);
}
#[test]
fn test_symlink_migration_from_old_directory() {
let _lock = TEST_MUTEX.lock().unwrap();
let (temp_dir, original_dir) = setup_test_env();
// Create an old-style .g3/session directory with latest.json
let old_session_dir = temp_dir.path().join(".g3").join("session");
fs::create_dir_all(&old_session_dir).expect("Failed to create old session dir");
let old_latest = old_session_dir.join("latest.json");
fs::write(&old_latest, r#"{"version":"1.0","session_id":"old"}"#)
.expect("Failed to write old latest.json");
// Save a new continuation - this should migrate the old directory to a symlink
let continuation = SessionContinuation::new(
"new_session".to_string(),
Some("New summary".to_string()),
"/path/to/session.json".to_string(),
50.0,
None,
".".to_string(),
);
save_continuation(&continuation).expect("Failed to save");
// The session path should now be a symlink, not a directory
let session_dir = get_session_dir();
assert!(session_dir.is_symlink(), "session should be a symlink after migration");
// Load should return the new session
let loaded = load_continuation()
.expect("Failed to load")
.expect("No continuation");
assert_eq!(loaded.session_id, "new_session");
teardown_test_env(original_dir); teardown_test_env(original_dir);
} }