handoff-mcp-server 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/mcp/handlers/dashboard.rs +4 -2
- package/src/mcp/handlers/import_context.rs +10 -5
- package/src/mcp/handlers/load_context.rs +50 -21
- package/src/mcp/handlers/save_context.rs +106 -9
- package/src/mcp/resources.rs +3 -2
- package/src/mcp/router.rs +1 -1
- package/src/mcp/tools.rs +49 -25
- package/src/storage/sessions.rs +161 -33
- package/tests/storage_sessions.rs +189 -25
- package/tests/tool_import_context.rs +1 -1
- package/tests/tool_sessions.rs +638 -5
package/src/storage/sessions.rs
CHANGED
|
@@ -7,6 +7,8 @@ use serde_json::Value;
|
|
|
7
7
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
8
8
|
pub struct SessionData {
|
|
9
9
|
pub version: u32,
|
|
10
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
11
|
+
pub id: Option<String>,
|
|
10
12
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
11
13
|
pub ended_at: Option<String>,
|
|
12
14
|
pub summary: String,
|
|
@@ -32,6 +34,11 @@ pub struct SessionData {
|
|
|
32
34
|
pub environment: Option<Value>,
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
pub fn generate_session_id() -> String {
|
|
38
|
+
let now = chrono::Utc::now();
|
|
39
|
+
format!("s-{}", now.format("%Y%m%d-%H%M%S-%6f"))
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
pub fn generate_session_filename(summary: &str, timestamp: &str) -> String {
|
|
36
43
|
let slug = summary_to_slug(summary);
|
|
37
44
|
format!("{timestamp}-{slug}")
|
|
@@ -72,53 +79,57 @@ fn summary_to_slug(summary: &str) -> String {
|
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
fn compact_timestamp(data: &SessionData) -> String {
|
|
76
83
|
let timestamp = data.ended_at.as_deref().unwrap_or("00000000-000000");
|
|
77
84
|
let ts_compact = timestamp
|
|
78
85
|
.replace(['-', ':'], "")
|
|
79
86
|
.replace('T', "-")
|
|
80
87
|
.replace('Z', "");
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
if ts_compact.len() >= 15 {
|
|
89
|
+
ts_compact[..15].to_string()
|
|
83
90
|
} else {
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
let base = generate_session_filename(&data.summary, ts_part);
|
|
88
|
-
let filename = format!("{base}.active.json");
|
|
89
|
-
let path = sessions_dir.join(&filename);
|
|
90
|
-
|
|
91
|
-
let content = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
|
|
92
|
-
std::fs::write(&path, content)
|
|
93
|
-
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
94
|
-
|
|
95
|
-
Ok(path)
|
|
91
|
+
ts_compact
|
|
92
|
+
}
|
|
96
93
|
}
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
fn synthesize_id_from_filename(filename: &str) -> String {
|
|
96
|
+
// Old format: YYYYMMDD-HHMMSS-slug.status.json
|
|
97
|
+
// Extract timestamp part and create s-YYYYMMDD-HHMMSS-000000
|
|
98
|
+
let base = filename
|
|
99
|
+
.rsplit_once('.')
|
|
100
|
+
.and_then(|(rest, _)| rest.rsplit_once('.'))
|
|
101
|
+
.map(|(rest, _)| rest)
|
|
102
|
+
.unwrap_or(filename);
|
|
100
103
|
|
|
101
|
-
if
|
|
102
|
-
|
|
104
|
+
if base.len() >= 15 {
|
|
105
|
+
let ts = &base[..15]; // YYYYMMDD-HHMMSS
|
|
106
|
+
format!("s-{ts}-000000")
|
|
107
|
+
} else {
|
|
108
|
+
format!("s-{base}-000000")
|
|
103
109
|
}
|
|
110
|
+
}
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let new_name = name.replace(".active.json", ".closed.json");
|
|
110
|
-
let new_path = sessions_dir.join(&new_name);
|
|
111
|
-
std::fs::rename(entry.path(), &new_path)
|
|
112
|
-
.with_context(|| format!("Failed to close session: {}", entry.path().display()))?;
|
|
113
|
-
closed.push(new_path);
|
|
114
|
-
}
|
|
112
|
+
pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
|
|
113
|
+
let mut data = data.clone();
|
|
114
|
+
if data.id.is_none() {
|
|
115
|
+
data.id = Some(generate_session_id());
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
|
|
118
|
+
let ts_part = compact_timestamp(&data);
|
|
119
|
+
let base = generate_session_filename(&data.summary, &ts_part);
|
|
120
|
+
let filename = format!("{base}.open.json");
|
|
121
|
+
let path = sessions_dir.join(&filename);
|
|
122
|
+
|
|
123
|
+
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
124
|
+
std::fs::write(&path, content)
|
|
125
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
126
|
+
|
|
127
|
+
Ok(path)
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
pub fn
|
|
130
|
+
pub fn read_sessions_by_status(sessions_dir: &Path, status: &str) -> Result<Vec<SessionData>> {
|
|
121
131
|
let mut sessions = Vec::new();
|
|
132
|
+
let suffix = format!(".{status}.json");
|
|
122
133
|
|
|
123
134
|
if !sessions_dir.exists() {
|
|
124
135
|
return Ok(sessions);
|
|
@@ -131,11 +142,14 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
|
131
142
|
|
|
132
143
|
for entry in entries {
|
|
133
144
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
134
|
-
if name.ends_with(
|
|
145
|
+
if name.ends_with(&suffix) {
|
|
135
146
|
let content = std::fs::read_to_string(entry.path())
|
|
136
147
|
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
137
|
-
let data: SessionData = serde_json::from_str(&content)
|
|
148
|
+
let mut data: SessionData = serde_json::from_str(&content)
|
|
138
149
|
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
150
|
+
if data.id.is_none() {
|
|
151
|
+
data.id = Some(synthesize_id_from_filename(&name));
|
|
152
|
+
}
|
|
139
153
|
sessions.push(data);
|
|
140
154
|
}
|
|
141
155
|
}
|
|
@@ -143,6 +157,114 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
|
143
157
|
Ok(sessions)
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
pub fn read_open_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
161
|
+
read_sessions_by_status(sessions_dir, "open")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
165
|
+
read_sessions_by_status(sessions_dir, "active")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
|
|
169
|
+
let mut transitioned = Vec::new();
|
|
170
|
+
let from_suffix = format!(".{from}.json");
|
|
171
|
+
let to_suffix = format!(".{to}.json");
|
|
172
|
+
|
|
173
|
+
if !sessions_dir.exists() {
|
|
174
|
+
return Ok(transitioned);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
178
|
+
let entry = entry?;
|
|
179
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
180
|
+
if name.ends_with(&from_suffix) {
|
|
181
|
+
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
182
|
+
let new_path = sessions_dir.join(&new_name);
|
|
183
|
+
std::fs::rename(entry.path(), &new_path).with_context(|| {
|
|
184
|
+
format!(
|
|
185
|
+
"Failed to transition session {from}->{to}: {}",
|
|
186
|
+
entry.path().display()
|
|
187
|
+
)
|
|
188
|
+
})?;
|
|
189
|
+
transitioned.push(new_path);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
Ok(transitioned)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn transition_session_by_id(
|
|
197
|
+
sessions_dir: &Path,
|
|
198
|
+
session_id: &str,
|
|
199
|
+
from: &str,
|
|
200
|
+
to: &str,
|
|
201
|
+
) -> Result<Option<PathBuf>> {
|
|
202
|
+
let from_suffix = format!(".{from}.json");
|
|
203
|
+
let to_suffix = format!(".{to}.json");
|
|
204
|
+
|
|
205
|
+
if !sessions_dir.exists() {
|
|
206
|
+
return Ok(None);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
210
|
+
let entry = entry?;
|
|
211
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
212
|
+
if !name.ends_with(&from_suffix) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let content = std::fs::read_to_string(entry.path())
|
|
217
|
+
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
218
|
+
let data: SessionData = serde_json::from_str(&content)
|
|
219
|
+
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
220
|
+
|
|
221
|
+
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
222
|
+
let synthesized = if file_id.is_empty() {
|
|
223
|
+
synthesize_id_from_filename(&name)
|
|
224
|
+
} else {
|
|
225
|
+
file_id
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if synthesized == session_id {
|
|
229
|
+
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
230
|
+
let new_path = sessions_dir.join(&new_name);
|
|
231
|
+
std::fs::rename(entry.path(), &new_path).with_context(|| {
|
|
232
|
+
format!(
|
|
233
|
+
"Failed to transition session {from}->{to}: {}",
|
|
234
|
+
entry.path().display()
|
|
235
|
+
)
|
|
236
|
+
})?;
|
|
237
|
+
return Ok(Some(new_path));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
Ok(None)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub fn activate_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
245
|
+
transition_sessions(sessions_dir, "open", "active")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
pub fn activate_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
249
|
+
transition_session_by_id(sessions_dir, session_id, "open", "active")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pub fn close_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
253
|
+
transition_sessions(sessions_dir, "active", "closed")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
pub fn close_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
257
|
+
transition_sessions(sessions_dir, "open", "closed")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
pub fn close_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
261
|
+
// Try active first, then open
|
|
262
|
+
if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "closed")? {
|
|
263
|
+
return Ok(Some(path));
|
|
264
|
+
}
|
|
265
|
+
transition_session_by_id(sessions_dir, session_id, "open", "closed")
|
|
266
|
+
}
|
|
267
|
+
|
|
146
268
|
pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
147
269
|
if !sessions_dir.exists() {
|
|
148
270
|
return Ok(0);
|
|
@@ -172,3 +294,9 @@ pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
|
172
294
|
|
|
173
295
|
Ok(removed)
|
|
174
296
|
}
|
|
297
|
+
|
|
298
|
+
// Backward-compatible aliases for tests and migration
|
|
299
|
+
#[doc(hidden)]
|
|
300
|
+
pub fn write_active_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
|
|
301
|
+
write_open_session(sessions_dir, data)
|
|
302
|
+
}
|
|
@@ -9,6 +9,7 @@ fn setup() -> TempDir {
|
|
|
9
9
|
fn make_session(summary: &str, ended_at: &str) -> SessionData {
|
|
10
10
|
SessionData {
|
|
11
11
|
version: 2,
|
|
12
|
+
id: None,
|
|
12
13
|
ended_at: Some(ended_at.to_string()),
|
|
13
14
|
summary: summary.to_string(),
|
|
14
15
|
branch: Some("main".to_string()),
|
|
@@ -25,17 +26,17 @@ fn make_session(summary: &str, ended_at: &str) -> SessionData {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
#[test]
|
|
28
|
-
fn
|
|
29
|
+
fn write_open_session_creates_file() {
|
|
29
30
|
let dir = setup();
|
|
30
31
|
let sessions_dir = dir.path().join("sessions");
|
|
31
32
|
fs::create_dir_all(&sessions_dir).unwrap();
|
|
32
33
|
|
|
33
34
|
let data = make_session("test session", "2026-06-13T14:30:00Z");
|
|
34
|
-
let path =
|
|
35
|
+
let path = write_open_session(&sessions_dir, &data).unwrap();
|
|
35
36
|
|
|
36
37
|
assert!(path.exists());
|
|
37
38
|
assert!(
|
|
38
|
-
path.to_string_lossy().ends_with(".
|
|
39
|
+
path.to_string_lossy().ends_with(".open.json"),
|
|
39
40
|
"filename: {}",
|
|
40
41
|
path.display()
|
|
41
42
|
);
|
|
@@ -47,26 +48,26 @@ fn write_active_session_creates_file() {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
#[test]
|
|
50
|
-
fn
|
|
51
|
+
fn read_open_sessions_empty() {
|
|
51
52
|
let dir = setup();
|
|
52
53
|
let sessions_dir = dir.path().join("sessions");
|
|
53
54
|
fs::create_dir_all(&sessions_dir).unwrap();
|
|
54
55
|
|
|
55
|
-
let sessions =
|
|
56
|
+
let sessions = read_open_sessions(&sessions_dir).unwrap();
|
|
56
57
|
assert!(sessions.is_empty());
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
#[test]
|
|
60
|
-
fn
|
|
61
|
+
fn read_open_sessions_returns_open_only() {
|
|
61
62
|
let dir = setup();
|
|
62
63
|
let sessions_dir = dir.path().join("sessions");
|
|
63
64
|
fs::create_dir_all(&sessions_dir).unwrap();
|
|
64
65
|
|
|
65
|
-
let s1 = make_session("
|
|
66
|
-
|
|
66
|
+
let s1 = make_session("open one", "2026-06-13T10:00:00Z");
|
|
67
|
+
write_open_session(&sessions_dir, &s1).unwrap();
|
|
67
68
|
|
|
68
|
-
let s2 = make_session("
|
|
69
|
-
|
|
69
|
+
let s2 = make_session("open two", "2026-06-13T11:00:00Z");
|
|
70
|
+
write_open_session(&sessions_dir, &s2).unwrap();
|
|
70
71
|
|
|
71
72
|
fs::write(
|
|
72
73
|
sessions_dir.join("20260612-090000-old.closed.json"),
|
|
@@ -74,10 +75,26 @@ fn read_active_sessions_returns_active_only() {
|
|
|
74
75
|
)
|
|
75
76
|
.unwrap();
|
|
76
77
|
|
|
77
|
-
let sessions =
|
|
78
|
+
let sessions = read_open_sessions(&sessions_dir).unwrap();
|
|
78
79
|
assert_eq!(sessions.len(), 2);
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
#[test]
|
|
83
|
+
fn activate_open_sessions_renames_to_active() {
|
|
84
|
+
let dir = setup();
|
|
85
|
+
let sessions_dir = dir.path().join("sessions");
|
|
86
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
87
|
+
|
|
88
|
+
let s1 = make_session("session one", "2026-06-13T10:00:00Z");
|
|
89
|
+
let open_path = write_open_session(&sessions_dir, &s1).unwrap();
|
|
90
|
+
|
|
91
|
+
let activated = activate_open_sessions(&sessions_dir).unwrap();
|
|
92
|
+
assert_eq!(activated.len(), 1);
|
|
93
|
+
assert!(!open_path.exists());
|
|
94
|
+
assert!(activated[0].exists());
|
|
95
|
+
assert!(activated[0].to_string_lossy().contains(".active.json"));
|
|
96
|
+
}
|
|
97
|
+
|
|
81
98
|
#[test]
|
|
82
99
|
fn close_active_sessions_renames_to_closed() {
|
|
83
100
|
let dir = setup();
|
|
@@ -85,7 +102,9 @@ fn close_active_sessions_renames_to_closed() {
|
|
|
85
102
|
fs::create_dir_all(&sessions_dir).unwrap();
|
|
86
103
|
|
|
87
104
|
let s1 = make_session("session one", "2026-06-13T10:00:00Z");
|
|
88
|
-
|
|
105
|
+
write_open_session(&sessions_dir, &s1).unwrap();
|
|
106
|
+
let activated = activate_open_sessions(&sessions_dir).unwrap();
|
|
107
|
+
let active_path = &activated[0];
|
|
89
108
|
|
|
90
109
|
let closed = close_active_sessions(&sessions_dir).unwrap();
|
|
91
110
|
assert_eq!(closed.len(), 1);
|
|
@@ -95,22 +114,51 @@ fn close_active_sessions_renames_to_closed() {
|
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
#[test]
|
|
98
|
-
fn
|
|
117
|
+
fn close_open_sessions_renames_to_closed() {
|
|
118
|
+
let dir = setup();
|
|
119
|
+
let sessions_dir = dir.path().join("sessions");
|
|
120
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
121
|
+
|
|
122
|
+
let s1 = make_session("session one", "2026-06-13T10:00:00Z");
|
|
123
|
+
let open_path = write_open_session(&sessions_dir, &s1).unwrap();
|
|
124
|
+
|
|
125
|
+
let closed = close_open_sessions(&sessions_dir).unwrap();
|
|
126
|
+
assert_eq!(closed.len(), 1);
|
|
127
|
+
assert!(!open_path.exists());
|
|
128
|
+
assert!(closed[0].exists());
|
|
129
|
+
assert!(closed[0].to_string_lossy().contains(".closed.json"));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[test]
|
|
133
|
+
fn full_session_lifecycle_open_active_closed() {
|
|
99
134
|
let dir = setup();
|
|
100
135
|
let sessions_dir = dir.path().join("sessions");
|
|
101
136
|
fs::create_dir_all(&sessions_dir).unwrap();
|
|
102
137
|
|
|
103
138
|
let s1 = make_session("first session", "2026-06-13T10:00:00Z");
|
|
104
|
-
|
|
139
|
+
write_open_session(&sessions_dir, &s1).unwrap();
|
|
105
140
|
|
|
106
|
-
|
|
141
|
+
let open = read_open_sessions(&sessions_dir).unwrap();
|
|
142
|
+
assert_eq!(open.len(), 1);
|
|
143
|
+
assert_eq!(open[0].summary, "first session");
|
|
107
144
|
|
|
108
|
-
|
|
109
|
-
write_active_session(&sessions_dir, &s2).unwrap();
|
|
145
|
+
activate_open_sessions(&sessions_dir).unwrap();
|
|
110
146
|
|
|
147
|
+
assert!(read_open_sessions(&sessions_dir).unwrap().is_empty());
|
|
111
148
|
let active = read_active_sessions(&sessions_dir).unwrap();
|
|
112
149
|
assert_eq!(active.len(), 1);
|
|
113
|
-
assert_eq!(active[0].summary, "
|
|
150
|
+
assert_eq!(active[0].summary, "first session");
|
|
151
|
+
|
|
152
|
+
close_active_sessions(&sessions_dir).unwrap();
|
|
153
|
+
|
|
154
|
+
assert!(read_active_sessions(&sessions_dir).unwrap().is_empty());
|
|
155
|
+
|
|
156
|
+
let s2 = make_session("second session", "2026-06-13T14:00:00Z");
|
|
157
|
+
write_open_session(&sessions_dir, &s2).unwrap();
|
|
158
|
+
|
|
159
|
+
let open2 = read_open_sessions(&sessions_dir).unwrap();
|
|
160
|
+
assert_eq!(open2.len(), 1);
|
|
161
|
+
assert_eq!(open2[0].summary, "second session");
|
|
114
162
|
}
|
|
115
163
|
|
|
116
164
|
#[test]
|
|
@@ -140,7 +188,7 @@ fn enforce_history_limit_removes_oldest() {
|
|
|
140
188
|
}
|
|
141
189
|
|
|
142
190
|
#[test]
|
|
143
|
-
fn
|
|
191
|
+
fn enforce_history_limit_ignores_open_and_active() {
|
|
144
192
|
let dir = setup();
|
|
145
193
|
let sessions_dir = dir.path().join("sessions");
|
|
146
194
|
fs::create_dir_all(&sessions_dir).unwrap();
|
|
@@ -154,13 +202,13 @@ fn enforce_history_limit_ignores_active() {
|
|
|
154
202
|
.unwrap();
|
|
155
203
|
}
|
|
156
204
|
|
|
157
|
-
let s = make_session("
|
|
158
|
-
|
|
205
|
+
let s = make_session("open session", "2026-06-13T10:00:00Z");
|
|
206
|
+
write_open_session(&sessions_dir, &s).unwrap();
|
|
159
207
|
|
|
160
208
|
enforce_history_limit(&sessions_dir, 2).unwrap();
|
|
161
209
|
|
|
162
|
-
let
|
|
163
|
-
assert_eq!(
|
|
210
|
+
let open = read_open_sessions(&sessions_dir).unwrap();
|
|
211
|
+
assert_eq!(open.len(), 1);
|
|
164
212
|
}
|
|
165
213
|
|
|
166
214
|
#[test]
|
|
@@ -187,9 +235,125 @@ fn generate_session_filename_format() {
|
|
|
187
235
|
}
|
|
188
236
|
|
|
189
237
|
#[test]
|
|
190
|
-
fn
|
|
238
|
+
fn read_open_sessions_nonexistent_dir() {
|
|
191
239
|
let dir = setup();
|
|
192
240
|
let sessions_dir = dir.path().join("nonexistent");
|
|
193
|
-
let sessions =
|
|
241
|
+
let sessions = read_open_sessions(&sessions_dir).unwrap();
|
|
194
242
|
assert!(sessions.is_empty());
|
|
195
243
|
}
|
|
244
|
+
|
|
245
|
+
#[test]
|
|
246
|
+
fn generate_session_id_format() {
|
|
247
|
+
let id = generate_session_id();
|
|
248
|
+
assert!(id.starts_with("s-"), "should start with s-: {id}");
|
|
249
|
+
assert!(id.len() > 20, "should be long enough: {id}");
|
|
250
|
+
let parts: Vec<&str> = id.splitn(2, '-').collect();
|
|
251
|
+
assert_eq!(parts[0], "s");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn write_open_session_assigns_id() {
|
|
256
|
+
let dir = setup();
|
|
257
|
+
let sessions_dir = dir.path().join("sessions");
|
|
258
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
259
|
+
|
|
260
|
+
let s = make_session("test", "2026-06-15T10:00:00Z");
|
|
261
|
+
assert!(s.id.is_none());
|
|
262
|
+
|
|
263
|
+
write_open_session(&sessions_dir, &s).unwrap();
|
|
264
|
+
|
|
265
|
+
let sessions = read_open_sessions(&sessions_dir).unwrap();
|
|
266
|
+
assert_eq!(sessions.len(), 1);
|
|
267
|
+
assert!(
|
|
268
|
+
sessions[0].id.is_some(),
|
|
269
|
+
"written session should have an id"
|
|
270
|
+
);
|
|
271
|
+
assert!(sessions[0].id.as_ref().unwrap().starts_with("s-"));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#[test]
|
|
275
|
+
fn read_old_session_without_id_gets_synthesized_id() {
|
|
276
|
+
let dir = setup();
|
|
277
|
+
let sessions_dir = dir.path().join("sessions");
|
|
278
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
279
|
+
|
|
280
|
+
// Write a session file without id field (simulating pre-upgrade file)
|
|
281
|
+
fs::write(
|
|
282
|
+
sessions_dir.join("20260613-143000-old-session.open.json"),
|
|
283
|
+
r#"{"version":2,"summary":"old session"}"#,
|
|
284
|
+
)
|
|
285
|
+
.unwrap();
|
|
286
|
+
|
|
287
|
+
let sessions = read_open_sessions(&sessions_dir).unwrap();
|
|
288
|
+
assert_eq!(sessions.len(), 1);
|
|
289
|
+
assert!(
|
|
290
|
+
sessions[0].id.is_some(),
|
|
291
|
+
"old session should get a synthesized id"
|
|
292
|
+
);
|
|
293
|
+
let id = sessions[0].id.as_ref().unwrap();
|
|
294
|
+
assert!(id.starts_with("s-"), "synthesized id format: {id}");
|
|
295
|
+
assert!(
|
|
296
|
+
id.contains("20260613-143000"),
|
|
297
|
+
"synthesized id should contain original timestamp: {id}"
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[test]
|
|
302
|
+
fn close_session_by_id_closes_only_targeted() {
|
|
303
|
+
let dir = setup();
|
|
304
|
+
let sessions_dir = dir.path().join("sessions");
|
|
305
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
306
|
+
|
|
307
|
+
let s1 = make_session("session one", "2026-06-15T10:00:00Z");
|
|
308
|
+
let s2 = make_session("session two", "2026-06-15T11:00:00Z");
|
|
309
|
+
write_open_session(&sessions_dir, &s1).unwrap();
|
|
310
|
+
write_open_session(&sessions_dir, &s2).unwrap();
|
|
311
|
+
|
|
312
|
+
let sessions = read_open_sessions(&sessions_dir).unwrap();
|
|
313
|
+
assert_eq!(sessions.len(), 2);
|
|
314
|
+
|
|
315
|
+
let target_id = sessions[0].id.as_ref().unwrap().clone();
|
|
316
|
+
let other_id = sessions[1].id.as_ref().unwrap().clone();
|
|
317
|
+
|
|
318
|
+
let result = close_session_by_id(&sessions_dir, &target_id).unwrap();
|
|
319
|
+
assert!(result.is_some(), "should close the targeted session");
|
|
320
|
+
|
|
321
|
+
let remaining_open = read_open_sessions(&sessions_dir).unwrap();
|
|
322
|
+
assert_eq!(remaining_open.len(), 1);
|
|
323
|
+
assert_eq!(remaining_open[0].id.as_deref().unwrap(), other_id);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#[test]
|
|
327
|
+
fn activate_session_by_id_activates_only_targeted() {
|
|
328
|
+
let dir = setup();
|
|
329
|
+
let sessions_dir = dir.path().join("sessions");
|
|
330
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
331
|
+
|
|
332
|
+
let s1 = make_session("session one", "2026-06-15T10:00:00Z");
|
|
333
|
+
let s2 = make_session("session two", "2026-06-15T11:00:00Z");
|
|
334
|
+
write_open_session(&sessions_dir, &s1).unwrap();
|
|
335
|
+
write_open_session(&sessions_dir, &s2).unwrap();
|
|
336
|
+
|
|
337
|
+
let sessions = read_open_sessions(&sessions_dir).unwrap();
|
|
338
|
+
let target_id = sessions[0].id.as_ref().unwrap().clone();
|
|
339
|
+
|
|
340
|
+
let result = activate_session_by_id(&sessions_dir, &target_id).unwrap();
|
|
341
|
+
assert!(result.is_some(), "should activate the targeted session");
|
|
342
|
+
|
|
343
|
+
let remaining_open = read_open_sessions(&sessions_dir).unwrap();
|
|
344
|
+
assert_eq!(remaining_open.len(), 1, "one session should remain open");
|
|
345
|
+
|
|
346
|
+
let active = read_active_sessions(&sessions_dir).unwrap();
|
|
347
|
+
assert_eq!(active.len(), 1, "one session should be active");
|
|
348
|
+
assert_eq!(active[0].id.as_deref().unwrap(), target_id);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn close_session_by_id_nonexistent_returns_none() {
|
|
353
|
+
let dir = setup();
|
|
354
|
+
let sessions_dir = dir.path().join("sessions");
|
|
355
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
356
|
+
|
|
357
|
+
let result = close_session_by_id(&sessions_dir, "s-nonexistent").unwrap();
|
|
358
|
+
assert!(result.is_none());
|
|
359
|
+
}
|
|
@@ -360,7 +360,7 @@ fn import_source_recorded_in_environment() {
|
|
|
360
360
|
let entries: Vec<_> = std::fs::read_dir(&sessions_dir)
|
|
361
361
|
.unwrap()
|
|
362
362
|
.filter_map(|e| e.ok())
|
|
363
|
-
.filter(|e| e.file_name().to_string_lossy().ends_with(".
|
|
363
|
+
.filter(|e| e.file_name().to_string_lossy().ends_with(".open.json"))
|
|
364
364
|
.collect();
|
|
365
365
|
assert_eq!(entries.len(), 1);
|
|
366
366
|
|