handoff-mcp-server 0.1.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 +781 -0
- package/Cargo.toml +24 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/bin/handoff-mcp.js +22 -0
- package/package.json +43 -0
- package/scripts/postinstall.js +44 -0
- package/src/lib.rs +2 -0
- package/src/main.rs +29 -0
- package/src/mcp/handlers/config.rs +115 -0
- package/src/mcp/handlers/dashboard.rs +109 -0
- package/src/mcp/handlers/init.rs +28 -0
- package/src/mcp/handlers/list_tasks.rs +61 -0
- package/src/mcp/handlers/load_context.rs +94 -0
- package/src/mcp/handlers/mod.rs +58 -0
- package/src/mcp/handlers/save_context.rs +96 -0
- package/src/mcp/handlers/update_task.rs +207 -0
- package/src/mcp/mod.rs +6 -0
- package/src/mcp/protocol.rs +41 -0
- package/src/mcp/resources.rs +55 -0
- package/src/mcp/router.rs +150 -0
- package/src/mcp/tools.rs +284 -0
- package/src/mcp/types.rs +108 -0
- package/src/storage/config.rs +112 -0
- package/src/storage/git.rs +47 -0
- package/src/storage/mod.rs +41 -0
- package/src/storage/sessions.rs +167 -0
- package/src/storage/tasks.rs +365 -0
- package/tests/mcp_protocol.rs +204 -0
- package/tests/storage_config.rs +107 -0
- package/tests/storage_sessions.rs +195 -0
- package/tests/storage_tasks.rs +302 -0
- package/tests/tool_dashboard.rs +165 -0
- package/tests/tool_init.rs +176 -0
- package/tests/tool_sessions.rs +318 -0
- package/tests/tool_tasks.rs +408 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
use serde_json::{json, Value};
|
|
2
|
+
|
|
3
|
+
fn send(input: &str) -> Option<Value> {
|
|
4
|
+
let result = handoff_mcp::mcp::protocol::process_line(input)?;
|
|
5
|
+
Some(serde_json::from_str(&result).expect("response should be valid JSON"))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
#[test]
|
|
9
|
+
fn initialize_returns_capabilities() {
|
|
10
|
+
let resp = send(
|
|
11
|
+
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}"#,
|
|
12
|
+
)
|
|
13
|
+
.expect("initialize should return a response");
|
|
14
|
+
|
|
15
|
+
assert_eq!(resp["jsonrpc"], "2.0");
|
|
16
|
+
assert_eq!(resp["id"], 1);
|
|
17
|
+
assert!(resp["error"].is_null());
|
|
18
|
+
|
|
19
|
+
let result = &resp["result"];
|
|
20
|
+
assert_eq!(result["protocolVersion"], "2025-03-26");
|
|
21
|
+
assert!(result["capabilities"]["tools"].is_object());
|
|
22
|
+
assert!(result["capabilities"]["resources"].is_object());
|
|
23
|
+
assert_eq!(result["serverInfo"]["name"], "handoff-mcp");
|
|
24
|
+
assert!(result["serverInfo"]["version"].is_string());
|
|
25
|
+
assert!(result["instructions"].is_string());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[test]
|
|
29
|
+
fn initialized_notification_returns_nothing() {
|
|
30
|
+
let resp = send(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
|
|
31
|
+
assert!(
|
|
32
|
+
resp.is_none(),
|
|
33
|
+
"notifications should not produce a response"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[test]
|
|
38
|
+
fn tools_list_returns_all_tools() {
|
|
39
|
+
let resp = send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#)
|
|
40
|
+
.expect("tools/list should return a response");
|
|
41
|
+
|
|
42
|
+
assert_eq!(resp["id"], 2);
|
|
43
|
+
assert!(resp["error"].is_null());
|
|
44
|
+
|
|
45
|
+
let tools = resp["result"]["tools"]
|
|
46
|
+
.as_array()
|
|
47
|
+
.expect("tools should be an array");
|
|
48
|
+
|
|
49
|
+
let expected_names = [
|
|
50
|
+
"handoff_init",
|
|
51
|
+
"handoff_load_context",
|
|
52
|
+
"handoff_save_context",
|
|
53
|
+
"handoff_list_tasks",
|
|
54
|
+
"handoff_update_task",
|
|
55
|
+
"handoff_get_config",
|
|
56
|
+
"handoff_update_config",
|
|
57
|
+
"handoff_dashboard",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
let tool_names: Vec<&str> = tools
|
|
61
|
+
.iter()
|
|
62
|
+
.map(|t| t["name"].as_str().expect("tool name should be a string"))
|
|
63
|
+
.collect();
|
|
64
|
+
|
|
65
|
+
for expected in &expected_names {
|
|
66
|
+
assert!(tool_names.contains(expected), "missing tool: {expected}");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for tool in tools {
|
|
70
|
+
assert!(
|
|
71
|
+
tool["description"].is_string(),
|
|
72
|
+
"tool should have description"
|
|
73
|
+
);
|
|
74
|
+
assert!(
|
|
75
|
+
tool["inputSchema"].is_object(),
|
|
76
|
+
"tool should have inputSchema"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn resources_list_returns_resources() {
|
|
83
|
+
let resp = send(r#"{"jsonrpc":"2.0","id":3,"method":"resources/list","params":{}}"#)
|
|
84
|
+
.expect("resources/list should return a response");
|
|
85
|
+
|
|
86
|
+
assert_eq!(resp["id"], 3);
|
|
87
|
+
assert!(resp["error"].is_null());
|
|
88
|
+
|
|
89
|
+
let resources = resp["result"]["resources"]
|
|
90
|
+
.as_array()
|
|
91
|
+
.expect("resources should be an array");
|
|
92
|
+
|
|
93
|
+
assert_eq!(resources.len(), 2);
|
|
94
|
+
|
|
95
|
+
let uris: Vec<&str> = resources
|
|
96
|
+
.iter()
|
|
97
|
+
.map(|r| r["uri"].as_str().expect("resource uri"))
|
|
98
|
+
.collect();
|
|
99
|
+
|
|
100
|
+
assert!(uris.contains(&"handoff://sessions"));
|
|
101
|
+
assert!(uris.contains(&"handoff://config"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn invalid_json_returns_parse_error() {
|
|
106
|
+
let resp = send("not valid json at all").expect("should return error response");
|
|
107
|
+
|
|
108
|
+
assert_eq!(resp["jsonrpc"], "2.0");
|
|
109
|
+
assert!(resp["id"].is_null());
|
|
110
|
+
|
|
111
|
+
let error = &resp["error"];
|
|
112
|
+
assert_eq!(error["code"], -32700);
|
|
113
|
+
assert!(error["message"].as_str().unwrap().contains("Parse error"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn unknown_method_returns_method_not_found() {
|
|
118
|
+
let resp = send(r#"{"jsonrpc":"2.0","id":99,"method":"nonexistent/method","params":{}}"#)
|
|
119
|
+
.expect("should return error response");
|
|
120
|
+
|
|
121
|
+
assert_eq!(resp["id"], 99);
|
|
122
|
+
|
|
123
|
+
let error = &resp["error"];
|
|
124
|
+
assert_eq!(error["code"], -32601);
|
|
125
|
+
assert!(error["message"]
|
|
126
|
+
.as_str()
|
|
127
|
+
.unwrap()
|
|
128
|
+
.contains("Method not found"));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn invalid_jsonrpc_version_returns_error() {
|
|
133
|
+
let resp = send(r#"{"jsonrpc":"1.0","id":5,"method":"initialize","params":{}}"#)
|
|
134
|
+
.expect("should return error response");
|
|
135
|
+
|
|
136
|
+
assert_eq!(resp["id"], 5);
|
|
137
|
+
let error = &resp["error"];
|
|
138
|
+
assert_eq!(error["code"], -32600);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[test]
|
|
142
|
+
fn empty_line_returns_none() {
|
|
143
|
+
assert!(send("").is_none());
|
|
144
|
+
assert!(send(" ").is_none());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#[test]
|
|
148
|
+
fn request_id_is_preserved() {
|
|
149
|
+
let resp_str = send(r#"{"jsonrpc":"2.0","id":"abc-123","method":"tools/list","params":{}}"#)
|
|
150
|
+
.expect("should return response");
|
|
151
|
+
assert_eq!(resp_str["id"], "abc-123");
|
|
152
|
+
|
|
153
|
+
let resp_num = send(r#"{"jsonrpc":"2.0","id":42,"method":"tools/list","params":{}}"#)
|
|
154
|
+
.expect("should return response");
|
|
155
|
+
assert_eq!(resp_num["id"], 42);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#[test]
|
|
159
|
+
fn tools_have_valid_input_schemas() {
|
|
160
|
+
let resp = send(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#)
|
|
161
|
+
.expect("tools/list response");
|
|
162
|
+
|
|
163
|
+
let tools = resp["result"]["tools"].as_array().unwrap();
|
|
164
|
+
for tool in tools {
|
|
165
|
+
let schema = &tool["inputSchema"];
|
|
166
|
+
assert_eq!(
|
|
167
|
+
schema["type"], "object",
|
|
168
|
+
"inputSchema for {} should have type: object",
|
|
169
|
+
tool["name"]
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[test]
|
|
175
|
+
fn multiple_requests_in_sequence() {
|
|
176
|
+
let r1 = send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#).unwrap();
|
|
177
|
+
assert!(r1["result"].is_object());
|
|
178
|
+
|
|
179
|
+
send(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
|
|
180
|
+
|
|
181
|
+
let r2 = send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#).unwrap();
|
|
182
|
+
assert!(r2["result"]["tools"].is_array());
|
|
183
|
+
|
|
184
|
+
let r3 = send(r#"{"jsonrpc":"2.0","id":3,"method":"resources/list","params":{}}"#).unwrap();
|
|
185
|
+
assert!(r3["result"]["resources"].is_array());
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[test]
|
|
189
|
+
fn e2e_json_roundtrip() {
|
|
190
|
+
let input = json!({
|
|
191
|
+
"jsonrpc": "2.0",
|
|
192
|
+
"id": 1,
|
|
193
|
+
"method": "initialize",
|
|
194
|
+
"params": {
|
|
195
|
+
"protocolVersion": "2025-03-26",
|
|
196
|
+
"capabilities": {},
|
|
197
|
+
"clientInfo": { "name": "test-client", "version": "1.0" }
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
let resp = send(&input.to_string()).unwrap();
|
|
202
|
+
assert_eq!(resp["result"]["protocolVersion"], "2025-03-26");
|
|
203
|
+
assert_eq!(resp["result"]["serverInfo"]["name"], "handoff-mcp");
|
|
204
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
use handoff_mcp::storage::config::{read_config, write_config, Config};
|
|
2
|
+
use std::fs;
|
|
3
|
+
use tempfile::TempDir;
|
|
4
|
+
|
|
5
|
+
fn setup() -> TempDir {
|
|
6
|
+
tempfile::tempdir().expect("failed to create temp dir")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
#[test]
|
|
10
|
+
fn write_and_read_config() {
|
|
11
|
+
let dir = setup();
|
|
12
|
+
let path = dir.path().join("config.toml");
|
|
13
|
+
|
|
14
|
+
let config = Config::new("test-project", "A test project");
|
|
15
|
+
write_config(&path, &config).unwrap();
|
|
16
|
+
|
|
17
|
+
let read_back = read_config(&path).unwrap();
|
|
18
|
+
assert_eq!(read_back.project.name, "test-project");
|
|
19
|
+
assert_eq!(
|
|
20
|
+
read_back.project.description.as_deref(),
|
|
21
|
+
Some("A test project")
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[test]
|
|
26
|
+
fn config_has_correct_defaults() {
|
|
27
|
+
let dir = setup();
|
|
28
|
+
let path = dir.path().join("config.toml");
|
|
29
|
+
|
|
30
|
+
let config = Config::new("proj", "");
|
|
31
|
+
write_config(&path, &config).unwrap();
|
|
32
|
+
|
|
33
|
+
let read_back = read_config(&path).unwrap();
|
|
34
|
+
assert_eq!(read_back.settings.history_limit, 20);
|
|
35
|
+
assert_eq!(read_back.settings.done_task_limit, 10);
|
|
36
|
+
assert!(read_back.settings.auto_git_summary);
|
|
37
|
+
assert!(read_back.settings.context_files.is_empty());
|
|
38
|
+
assert_eq!(read_back.dashboard.scan_dirs, vec!["~/pro/"]);
|
|
39
|
+
assert!(read_back.dashboard.exclude_patterns.is_empty());
|
|
40
|
+
assert!(read_back.project.description.is_none());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[test]
|
|
44
|
+
fn config_with_custom_values() {
|
|
45
|
+
let dir = setup();
|
|
46
|
+
let path = dir.path().join("config.toml");
|
|
47
|
+
|
|
48
|
+
let toml_content = r#"
|
|
49
|
+
[project]
|
|
50
|
+
name = "custom-proj"
|
|
51
|
+
description = "Custom"
|
|
52
|
+
|
|
53
|
+
[settings]
|
|
54
|
+
history_limit = 50
|
|
55
|
+
done_task_limit = 5
|
|
56
|
+
auto_git_summary = false
|
|
57
|
+
context_files = ["README.md", "CLAUDE.md"]
|
|
58
|
+
|
|
59
|
+
[dashboard]
|
|
60
|
+
scan_dirs = ["~/work/", "~/projects/"]
|
|
61
|
+
exclude_patterns = ["*/target", "*/node_modules"]
|
|
62
|
+
"#;
|
|
63
|
+
fs::write(&path, toml_content).unwrap();
|
|
64
|
+
|
|
65
|
+
let config = read_config(&path).unwrap();
|
|
66
|
+
assert_eq!(config.project.name, "custom-proj");
|
|
67
|
+
assert_eq!(config.settings.history_limit, 50);
|
|
68
|
+
assert_eq!(config.settings.done_task_limit, 5);
|
|
69
|
+
assert!(!config.settings.auto_git_summary);
|
|
70
|
+
assert_eq!(config.settings.context_files.len(), 2);
|
|
71
|
+
assert_eq!(config.dashboard.scan_dirs.len(), 2);
|
|
72
|
+
assert_eq!(config.dashboard.exclude_patterns.len(), 2);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[test]
|
|
76
|
+
fn config_missing_file_returns_error() {
|
|
77
|
+
let dir = setup();
|
|
78
|
+
let path = dir.path().join("nonexistent.toml");
|
|
79
|
+
assert!(read_config(&path).is_err());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[test]
|
|
83
|
+
fn config_invalid_toml_returns_error() {
|
|
84
|
+
let dir = setup();
|
|
85
|
+
let path = dir.path().join("bad.toml");
|
|
86
|
+
fs::write(&path, "this is not valid toml [[[").unwrap();
|
|
87
|
+
assert!(read_config(&path).is_err());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[test]
|
|
91
|
+
fn config_partial_toml_uses_defaults() {
|
|
92
|
+
let dir = setup();
|
|
93
|
+
let path = dir.path().join("config.toml");
|
|
94
|
+
fs::write(
|
|
95
|
+
&path,
|
|
96
|
+
r#"
|
|
97
|
+
[project]
|
|
98
|
+
name = "minimal"
|
|
99
|
+
"#,
|
|
100
|
+
)
|
|
101
|
+
.unwrap();
|
|
102
|
+
|
|
103
|
+
let config = read_config(&path).unwrap();
|
|
104
|
+
assert_eq!(config.project.name, "minimal");
|
|
105
|
+
assert_eq!(config.settings.history_limit, 20);
|
|
106
|
+
assert_eq!(config.dashboard.scan_dirs, vec!["~/pro/"]);
|
|
107
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
use handoff_mcp::storage::sessions::*;
|
|
2
|
+
use std::fs;
|
|
3
|
+
use tempfile::TempDir;
|
|
4
|
+
|
|
5
|
+
fn setup() -> TempDir {
|
|
6
|
+
tempfile::tempdir().expect("failed to create temp dir")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
fn make_session(summary: &str, ended_at: &str) -> SessionData {
|
|
10
|
+
SessionData {
|
|
11
|
+
version: 2,
|
|
12
|
+
ended_at: Some(ended_at.to_string()),
|
|
13
|
+
summary: summary.to_string(),
|
|
14
|
+
branch: Some("main".to_string()),
|
|
15
|
+
commit: Some("abc1234".to_string()),
|
|
16
|
+
dirty_files: Vec::new(),
|
|
17
|
+
decisions: Vec::new(),
|
|
18
|
+
blockers: Vec::new(),
|
|
19
|
+
checklist: Vec::new(),
|
|
20
|
+
handoff_notes: Vec::new(),
|
|
21
|
+
references: Vec::new(),
|
|
22
|
+
context_pointers: Vec::new(),
|
|
23
|
+
environment: None,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[test]
|
|
28
|
+
fn write_active_session_creates_file() {
|
|
29
|
+
let dir = setup();
|
|
30
|
+
let sessions_dir = dir.path().join("sessions");
|
|
31
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
32
|
+
|
|
33
|
+
let data = make_session("test session", "2026-06-13T14:30:00Z");
|
|
34
|
+
let path = write_active_session(&sessions_dir, &data).unwrap();
|
|
35
|
+
|
|
36
|
+
assert!(path.exists());
|
|
37
|
+
assert!(
|
|
38
|
+
path.to_string_lossy().ends_with(".active.json"),
|
|
39
|
+
"filename: {}",
|
|
40
|
+
path.display()
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
let content = fs::read_to_string(&path).unwrap();
|
|
44
|
+
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
|
|
45
|
+
assert_eq!(parsed["summary"], "test session");
|
|
46
|
+
assert_eq!(parsed["version"], 2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn read_active_sessions_empty() {
|
|
51
|
+
let dir = setup();
|
|
52
|
+
let sessions_dir = dir.path().join("sessions");
|
|
53
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
54
|
+
|
|
55
|
+
let sessions = read_active_sessions(&sessions_dir).unwrap();
|
|
56
|
+
assert!(sessions.is_empty());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[test]
|
|
60
|
+
fn read_active_sessions_returns_active_only() {
|
|
61
|
+
let dir = setup();
|
|
62
|
+
let sessions_dir = dir.path().join("sessions");
|
|
63
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
64
|
+
|
|
65
|
+
let s1 = make_session("active one", "2026-06-13T10:00:00Z");
|
|
66
|
+
write_active_session(&sessions_dir, &s1).unwrap();
|
|
67
|
+
|
|
68
|
+
let s2 = make_session("active two", "2026-06-13T11:00:00Z");
|
|
69
|
+
write_active_session(&sessions_dir, &s2).unwrap();
|
|
70
|
+
|
|
71
|
+
fs::write(
|
|
72
|
+
sessions_dir.join("20260612-090000-old.closed.json"),
|
|
73
|
+
r#"{"version":2,"summary":"old closed","branch":"main","commit":"111"}"#,
|
|
74
|
+
)
|
|
75
|
+
.unwrap();
|
|
76
|
+
|
|
77
|
+
let sessions = read_active_sessions(&sessions_dir).unwrap();
|
|
78
|
+
assert_eq!(sessions.len(), 2);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn close_active_sessions_renames_to_closed() {
|
|
83
|
+
let dir = setup();
|
|
84
|
+
let sessions_dir = dir.path().join("sessions");
|
|
85
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
86
|
+
|
|
87
|
+
let s1 = make_session("session one", "2026-06-13T10:00:00Z");
|
|
88
|
+
let active_path = write_active_session(&sessions_dir, &s1).unwrap();
|
|
89
|
+
|
|
90
|
+
let closed = close_active_sessions(&sessions_dir).unwrap();
|
|
91
|
+
assert_eq!(closed.len(), 1);
|
|
92
|
+
assert!(!active_path.exists());
|
|
93
|
+
assert!(closed[0].exists());
|
|
94
|
+
assert!(closed[0].to_string_lossy().contains(".closed.json"));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[test]
|
|
98
|
+
fn close_active_then_create_new() {
|
|
99
|
+
let dir = setup();
|
|
100
|
+
let sessions_dir = dir.path().join("sessions");
|
|
101
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
102
|
+
|
|
103
|
+
let s1 = make_session("first session", "2026-06-13T10:00:00Z");
|
|
104
|
+
write_active_session(&sessions_dir, &s1).unwrap();
|
|
105
|
+
|
|
106
|
+
close_active_sessions(&sessions_dir).unwrap();
|
|
107
|
+
|
|
108
|
+
let s2 = make_session("second session", "2026-06-13T14:00:00Z");
|
|
109
|
+
write_active_session(&sessions_dir, &s2).unwrap();
|
|
110
|
+
|
|
111
|
+
let active = read_active_sessions(&sessions_dir).unwrap();
|
|
112
|
+
assert_eq!(active.len(), 1);
|
|
113
|
+
assert_eq!(active[0].summary, "second session");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn enforce_history_limit_removes_oldest() {
|
|
118
|
+
let dir = setup();
|
|
119
|
+
let sessions_dir = dir.path().join("sessions");
|
|
120
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
121
|
+
|
|
122
|
+
for i in 1..=5 {
|
|
123
|
+
let name = format!("20260610-{i:06}-s{i}.closed.json");
|
|
124
|
+
fs::write(
|
|
125
|
+
sessions_dir.join(&name),
|
|
126
|
+
format!(r#"{{"version":2,"summary":"session {i}"}}"#),
|
|
127
|
+
)
|
|
128
|
+
.unwrap();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let removed = enforce_history_limit(&sessions_dir, 3).unwrap();
|
|
132
|
+
assert_eq!(removed, 2);
|
|
133
|
+
|
|
134
|
+
let remaining: Vec<_> = fs::read_dir(&sessions_dir)
|
|
135
|
+
.unwrap()
|
|
136
|
+
.filter_map(|e| e.ok())
|
|
137
|
+
.filter(|e| e.file_name().to_string_lossy().ends_with(".closed.json"))
|
|
138
|
+
.collect();
|
|
139
|
+
assert_eq!(remaining.len(), 3);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn enforce_history_limit_ignores_active() {
|
|
144
|
+
let dir = setup();
|
|
145
|
+
let sessions_dir = dir.path().join("sessions");
|
|
146
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
147
|
+
|
|
148
|
+
for i in 1..=3 {
|
|
149
|
+
let name = format!("20260610-{i:06}-s{i}.closed.json");
|
|
150
|
+
fs::write(
|
|
151
|
+
sessions_dir.join(&name),
|
|
152
|
+
format!(r#"{{"version":2,"summary":"closed {i}"}}"#),
|
|
153
|
+
)
|
|
154
|
+
.unwrap();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let s = make_session("active", "2026-06-13T10:00:00Z");
|
|
158
|
+
write_active_session(&sessions_dir, &s).unwrap();
|
|
159
|
+
|
|
160
|
+
enforce_history_limit(&sessions_dir, 2).unwrap();
|
|
161
|
+
|
|
162
|
+
let active = read_active_sessions(&sessions_dir).unwrap();
|
|
163
|
+
assert_eq!(active.len(), 1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[test]
|
|
167
|
+
fn enforce_history_limit_under_limit_removes_nothing() {
|
|
168
|
+
let dir = setup();
|
|
169
|
+
let sessions_dir = dir.path().join("sessions");
|
|
170
|
+
fs::create_dir_all(&sessions_dir).unwrap();
|
|
171
|
+
|
|
172
|
+
fs::write(
|
|
173
|
+
sessions_dir.join("20260610-000001-s1.closed.json"),
|
|
174
|
+
r#"{"version":2,"summary":"s1"}"#,
|
|
175
|
+
)
|
|
176
|
+
.unwrap();
|
|
177
|
+
|
|
178
|
+
let removed = enforce_history_limit(&sessions_dir, 5).unwrap();
|
|
179
|
+
assert_eq!(removed, 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn generate_session_filename_format() {
|
|
184
|
+
let name = generate_session_filename("Pattern run fix", "20260613-143000");
|
|
185
|
+
assert!(name.starts_with("20260613-143000-"));
|
|
186
|
+
assert!(name.contains("pattern-run-fix"));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[test]
|
|
190
|
+
fn read_active_sessions_nonexistent_dir() {
|
|
191
|
+
let dir = setup();
|
|
192
|
+
let sessions_dir = dir.path().join("nonexistent");
|
|
193
|
+
let sessions = read_active_sessions(&sessions_dir).unwrap();
|
|
194
|
+
assert!(sessions.is_empty());
|
|
195
|
+
}
|