handoff-mcp-server 0.3.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/get_task.rs +3 -0
- package/src/mcp/handlers/import_context.rs +39 -5
- package/src/mcp/handlers/list_tasks.rs +3 -0
- package/src/mcp/handlers/load_context.rs +50 -21
- package/src/mcp/handlers/save_context.rs +106 -9
- package/src/mcp/handlers/update_task.rs +47 -0
- package/src/mcp/resources.rs +3 -2
- package/src/mcp/router.rs +1 -1
- package/src/mcp/tools.rs +69 -25
- package/src/storage/sessions.rs +161 -33
- package/src/storage/tasks.rs +162 -1
- package/tests/storage_sessions.rs +189 -25
- package/tests/storage_tasks.rs +3 -0
- package/tests/tool_import_context.rs +1 -1
- package/tests/tool_sessions.rs +638 -5
package/src/storage/tasks.rs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
use std::collections::{HashMap, HashSet};
|
|
1
2
|
use std::path::{Path, PathBuf};
|
|
2
3
|
|
|
3
4
|
use anyhow::{Context, Result};
|
|
5
|
+
use chrono::Utc;
|
|
4
6
|
use serde::{Deserialize, Serialize};
|
|
5
7
|
|
|
6
8
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -23,6 +25,26 @@ pub struct TaskData {
|
|
|
23
25
|
pub links: Vec<String>,
|
|
24
26
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
25
27
|
pub done_criteria: Vec<DoneCriterion>,
|
|
28
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
29
|
+
pub schedule: Option<Schedule>,
|
|
30
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
31
|
+
pub dependencies: Vec<String>,
|
|
32
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
33
|
+
pub order: Option<u32>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
37
|
+
pub struct Schedule {
|
|
38
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
39
|
+
pub start_date: Option<String>,
|
|
40
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
41
|
+
pub due_date: Option<String>,
|
|
42
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
43
|
+
pub estimate_hours: Option<f64>,
|
|
44
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
45
|
+
pub actual_hours: Option<f64>,
|
|
46
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
47
|
+
pub milestone: Option<String>,
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -37,6 +59,12 @@ pub struct TaskIndex {
|
|
|
37
59
|
pub id: String,
|
|
38
60
|
pub title: String,
|
|
39
61
|
pub status: String,
|
|
62
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
63
|
+
pub schedule: Option<Schedule>,
|
|
64
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
65
|
+
pub dependencies: Vec<String>,
|
|
66
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
67
|
+
pub order: Option<u32>,
|
|
40
68
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
41
69
|
pub children: Vec<TaskIndex>,
|
|
42
70
|
}
|
|
@@ -45,6 +73,14 @@ pub struct TaskIndex {
|
|
|
45
73
|
pub struct TaskSummary {
|
|
46
74
|
pub total: u32,
|
|
47
75
|
pub by_status: std::collections::HashMap<String, u32>,
|
|
76
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
77
|
+
pub total_estimate_hours: Option<f64>,
|
|
78
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
79
|
+
pub total_actual_hours: Option<f64>,
|
|
80
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
81
|
+
pub completion_rate: Option<f64>,
|
|
82
|
+
#[serde(default)]
|
|
83
|
+
pub overdue_count: u32,
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
const VALID_STATUSES: &[&str] = &[
|
|
@@ -269,9 +305,17 @@ pub fn build_task_index(
|
|
|
269
305
|
let mut tree = Vec::new();
|
|
270
306
|
let mut summary = TaskSummary {
|
|
271
307
|
total: 0,
|
|
272
|
-
by_status:
|
|
308
|
+
by_status: HashMap::new(),
|
|
309
|
+
total_estimate_hours: None,
|
|
310
|
+
total_actual_hours: None,
|
|
311
|
+
completion_rate: None,
|
|
312
|
+
overdue_count: 0,
|
|
273
313
|
};
|
|
274
314
|
let mut done_count: u32 = 0;
|
|
315
|
+
let mut estimate_sum: f64 = 0.0;
|
|
316
|
+
let mut actual_sum: f64 = 0.0;
|
|
317
|
+
let mut has_hours = false;
|
|
318
|
+
let today = Utc::now().format("%Y-%m-%d").to_string();
|
|
275
319
|
|
|
276
320
|
build_index_recursive(
|
|
277
321
|
tasks_dir,
|
|
@@ -279,17 +323,37 @@ pub fn build_task_index(
|
|
|
279
323
|
&mut summary,
|
|
280
324
|
&mut done_count,
|
|
281
325
|
done_task_limit,
|
|
326
|
+
&mut estimate_sum,
|
|
327
|
+
&mut actual_sum,
|
|
328
|
+
&mut has_hours,
|
|
329
|
+
&today,
|
|
282
330
|
)?;
|
|
283
331
|
|
|
332
|
+
if has_hours {
|
|
333
|
+
summary.total_estimate_hours = Some(estimate_sum);
|
|
334
|
+
summary.total_actual_hours = Some(actual_sum);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if summary.total > 0 {
|
|
338
|
+
let done = *summary.by_status.get("done").unwrap_or(&0) as f64;
|
|
339
|
+
let skipped = *summary.by_status.get("skipped").unwrap_or(&0) as f64;
|
|
340
|
+
summary.completion_rate = Some((done + skipped) / summary.total as f64);
|
|
341
|
+
}
|
|
342
|
+
|
|
284
343
|
Ok((tree, summary))
|
|
285
344
|
}
|
|
286
345
|
|
|
346
|
+
#[allow(clippy::too_many_arguments)]
|
|
287
347
|
fn build_index_recursive(
|
|
288
348
|
dir: &Path,
|
|
289
349
|
tree: &mut Vec<TaskIndex>,
|
|
290
350
|
summary: &mut TaskSummary,
|
|
291
351
|
done_count: &mut u32,
|
|
292
352
|
done_task_limit: u32,
|
|
353
|
+
estimate_sum: &mut f64,
|
|
354
|
+
actual_sum: &mut f64,
|
|
355
|
+
has_hours: &mut bool,
|
|
356
|
+
today: &str,
|
|
293
357
|
) -> Result<()> {
|
|
294
358
|
if !dir.exists() {
|
|
295
359
|
return Ok(());
|
|
@@ -316,6 +380,22 @@ fn build_index_recursive(
|
|
|
316
380
|
summary.total += 1;
|
|
317
381
|
*summary.by_status.entry(status.clone()).or_insert(0) += 1;
|
|
318
382
|
|
|
383
|
+
if let Some(ref sched) = data.schedule {
|
|
384
|
+
if let Some(est) = sched.estimate_hours {
|
|
385
|
+
*estimate_sum += est;
|
|
386
|
+
*has_hours = true;
|
|
387
|
+
}
|
|
388
|
+
if let Some(act) = sched.actual_hours {
|
|
389
|
+
*actual_sum += act;
|
|
390
|
+
*has_hours = true;
|
|
391
|
+
}
|
|
392
|
+
if let Some(ref due) = sched.due_date {
|
|
393
|
+
if !is_terminal_status(&status) && due.as_str() < today {
|
|
394
|
+
summary.overdue_count += 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
319
399
|
if is_terminal_status(&status) {
|
|
320
400
|
*done_count += 1;
|
|
321
401
|
if *done_count > done_task_limit {
|
|
@@ -330,12 +410,19 @@ fn build_index_recursive(
|
|
|
330
410
|
summary,
|
|
331
411
|
done_count,
|
|
332
412
|
done_task_limit,
|
|
413
|
+
estimate_sum,
|
|
414
|
+
actual_sum,
|
|
415
|
+
has_hours,
|
|
416
|
+
today,
|
|
333
417
|
)?;
|
|
334
418
|
|
|
335
419
|
tree.push(TaskIndex {
|
|
336
420
|
id: data.id,
|
|
337
421
|
title: data.title,
|
|
338
422
|
status,
|
|
423
|
+
schedule: data.schedule,
|
|
424
|
+
dependencies: data.dependencies,
|
|
425
|
+
order: data.order,
|
|
339
426
|
children,
|
|
340
427
|
});
|
|
341
428
|
}
|
|
@@ -343,6 +430,80 @@ fn build_index_recursive(
|
|
|
343
430
|
Ok(())
|
|
344
431
|
}
|
|
345
432
|
|
|
433
|
+
pub fn validate_dependencies(tasks_dir: &Path, task_id: &str, new_deps: &[String]) -> Result<()> {
|
|
434
|
+
let dep_graph = build_dependency_graph(tasks_dir)?;
|
|
435
|
+
|
|
436
|
+
let mut graph = dep_graph;
|
|
437
|
+
graph.insert(task_id.to_string(), new_deps.to_vec());
|
|
438
|
+
|
|
439
|
+
let mut visited = HashSet::new();
|
|
440
|
+
let mut stack = HashSet::new();
|
|
441
|
+
|
|
442
|
+
if has_cycle(&graph, task_id, &mut visited, &mut stack) {
|
|
443
|
+
anyhow::bail!(
|
|
444
|
+
"Circular dependency detected: setting dependencies {:?} on task {task_id} would create a cycle",
|
|
445
|
+
new_deps
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
Ok(())
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
fn build_dependency_graph(tasks_dir: &Path) -> Result<HashMap<String, Vec<String>>> {
|
|
453
|
+
let mut graph = HashMap::new();
|
|
454
|
+
build_dep_graph_recursive(tasks_dir, &mut graph)?;
|
|
455
|
+
Ok(graph)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
fn build_dep_graph_recursive(dir: &Path, graph: &mut HashMap<String, Vec<String>>) -> Result<()> {
|
|
459
|
+
if !dir.exists() {
|
|
460
|
+
return Ok(());
|
|
461
|
+
}
|
|
462
|
+
for entry in std::fs::read_dir(dir)? {
|
|
463
|
+
let entry = entry?;
|
|
464
|
+
if !entry.file_type()?.is_dir() {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
468
|
+
if name.starts_with('.') || name.starts_with('_') {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
let task_dir = entry.path();
|
|
472
|
+
if let Some((data, _)) = read_task(&task_dir)? {
|
|
473
|
+
graph.insert(data.id.clone(), data.dependencies.clone());
|
|
474
|
+
build_dep_graph_recursive(&task_dir, graph)?;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
Ok(())
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fn has_cycle(
|
|
481
|
+
graph: &HashMap<String, Vec<String>>,
|
|
482
|
+
node: &str,
|
|
483
|
+
visited: &mut HashSet<String>,
|
|
484
|
+
stack: &mut HashSet<String>,
|
|
485
|
+
) -> bool {
|
|
486
|
+
if stack.contains(node) {
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
if visited.contains(node) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
visited.insert(node.to_string());
|
|
493
|
+
stack.insert(node.to_string());
|
|
494
|
+
|
|
495
|
+
if let Some(deps) = graph.get(node) {
|
|
496
|
+
for dep in deps {
|
|
497
|
+
if has_cycle(graph, dep, visited, stack) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
stack.remove(node);
|
|
504
|
+
false
|
|
505
|
+
}
|
|
506
|
+
|
|
346
507
|
pub fn validate_done_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
|
|
347
508
|
for criterion in &data.done_criteria {
|
|
348
509
|
if !criterion.checked {
|
|
@@ -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
|
+
}
|
package/tests/storage_tasks.rs
CHANGED
|
@@ -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
|
|