consolidate .g3/session -> .g3/sessions/*
This commit is contained in:
@@ -2,8 +2,13 @@
|
||||
//!
|
||||
//! This module provides functionality to save and restore session state,
|
||||
//! 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 std::path::{Path, PathBuf};
|
||||
use tracing::{debug, error, warn};
|
||||
@@ -11,6 +16,9 @@ use tracing::{debug, error, warn};
|
||||
/// Version of the session continuation format
|
||||
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
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
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 {
|
||||
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
current_dir.join(".g3").join("session")
|
||||
get_g3_dir().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
|
||||
/// This follows the symlink to get the actual path
|
||||
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> {
|
||||
let session_dir = get_session_dir();
|
||||
if !session_dir.exists() {
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
debug!("Created session directory: {:?}", session_dir);
|
||||
let g3_dir = get_g3_dir();
|
||||
if !g3_dir.exists() {
|
||||
std::fs::create_dir_all(&g3_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
|
||||
/// This saves latest.json in the session's directory and updates the symlink
|
||||
pub fn save_continuation(continuation: &SessionContinuation) -> Result<PathBuf> {
|
||||
let session_dir = ensure_session_dir()?;
|
||||
let latest_path = session_dir.join("latest.json");
|
||||
let session_id = &continuation.session_id;
|
||||
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)?;
|
||||
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);
|
||||
Ok(latest_path)
|
||||
}
|
||||
|
||||
/// Load the latest session continuation artifact if it exists
|
||||
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() {
|
||||
debug!("No continuation file found at {:?}", latest_path);
|
||||
@@ -117,13 +200,17 @@ pub fn load_continuation() -> Result<Option<SessionContinuation>> {
|
||||
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<()> {
|
||||
let session_dir = get_session_dir();
|
||||
let symlink_path = get_session_dir();
|
||||
|
||||
if session_dir.exists() {
|
||||
// Remove all files in the session directory
|
||||
for entry in std::fs::read_dir(&session_dir)? {
|
||||
if symlink_path.is_symlink() {
|
||||
std::fs::remove_file(&symlink_path)?;
|
||||
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 path = entry.path();
|
||||
if path.is_file() {
|
||||
@@ -131,9 +218,11 @@ pub fn clear_continuation() -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -186,7 +275,6 @@ pub fn load_context_from_session_log(session_log_path: &Path) -> Result<Option<s
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_session_continuation_creation() {
|
||||
|
||||
@@ -98,6 +98,10 @@ fn test_save_and_load_continuation() {
|
||||
let saved_path = save_continuation(&original).expect("Failed to save continuation");
|
||||
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
|
||||
let loaded = load_continuation()
|
||||
.expect("Failed to load continuation")
|
||||
@@ -141,14 +145,15 @@ fn test_clear_continuation() {
|
||||
);
|
||||
save_continuation(&continuation).expect("Failed to save");
|
||||
|
||||
// Verify it exists
|
||||
assert!(get_latest_continuation_path().exists());
|
||||
// Verify the symlink exists
|
||||
let session_dir = get_session_dir();
|
||||
assert!(session_dir.is_symlink(), "session should be a symlink after save");
|
||||
|
||||
// Clear it
|
||||
clear_continuation().expect("Failed to clear");
|
||||
|
||||
// Verify it's gone
|
||||
assert!(!get_latest_continuation_path().exists());
|
||||
// Verify the symlink is gone
|
||||
assert!(!session_dir.exists() && !session_dir.is_symlink(), "symlink should be removed");
|
||||
|
||||
// Loading should return None
|
||||
let result = load_continuation().expect("load should not error");
|
||||
@@ -158,17 +163,23 @@ fn test_clear_continuation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_session_dir_creates_directory() {
|
||||
fn test_ensure_session_dir_creates_g3_directory() {
|
||||
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();
|
||||
assert!(!session_dir.exists());
|
||||
let g3_dir = temp_dir.path().join(".g3");
|
||||
assert!(!g3_dir.exists());
|
||||
|
||||
ensure_session_dir().expect("Failed to ensure session dir");
|
||||
|
||||
assert!(session_dir.exists());
|
||||
assert!(session_dir.is_dir());
|
||||
// The .g3 directory should exist, but not the session symlink
|
||||
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);
|
||||
}
|
||||
@@ -257,9 +268,9 @@ fn test_continuation_serialization_format() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_saves_overwrite() {
|
||||
fn test_multiple_saves_update_symlink() {
|
||||
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
|
||||
let first = SessionContinuation::new(
|
||||
@@ -272,7 +283,12 @@ fn test_multiple_saves_overwrite() {
|
||||
);
|
||||
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(
|
||||
"second_session".to_string(),
|
||||
Some("Second summary".to_string()),
|
||||
@@ -283,6 +299,10 @@ fn test_multiple_saves_overwrite() {
|
||||
);
|
||||
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
|
||||
let loaded = load_continuation()
|
||||
.expect("Failed to load")
|
||||
@@ -293,5 +313,46 @@ fn test_multiple_saves_overwrite() {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user