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.
@@ -0,0 +1,302 @@
1
+ use handoff_mcp::storage::tasks::*;
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 create_task_dir(base: &std::path::Path, dir_name: &str, status: &str, data: &TaskData) {
10
+ let dir = base.join(dir_name);
11
+ fs::create_dir_all(&dir).unwrap();
12
+ write_task(&dir, status, data).unwrap();
13
+ }
14
+
15
+ fn make_task(id: &str, title: &str) -> TaskData {
16
+ TaskData {
17
+ id: id.to_string(),
18
+ title: title.to_string(),
19
+ notes: None,
20
+ priority: None,
21
+ created_at: None,
22
+ updated_at: None,
23
+ completed_at: None,
24
+ labels: Vec::new(),
25
+ links: Vec::new(),
26
+ done_criteria: Vec::new(),
27
+ }
28
+ }
29
+
30
+ #[test]
31
+ fn write_and_read_task() {
32
+ let dir = setup();
33
+ let task_dir = dir.path().join("t1-test");
34
+ fs::create_dir_all(&task_dir).unwrap();
35
+
36
+ let data = make_task("t1", "Test task");
37
+ write_task(&task_dir, "todo", &data).unwrap();
38
+
39
+ let (read_data, status) = read_task(&task_dir).unwrap().unwrap();
40
+ assert_eq!(read_data.id, "t1");
41
+ assert_eq!(read_data.title, "Test task");
42
+ assert_eq!(status, "todo");
43
+ }
44
+
45
+ #[test]
46
+ fn change_task_status() {
47
+ let dir = setup();
48
+ let task_dir = dir.path().join("t1-test");
49
+ fs::create_dir_all(&task_dir).unwrap();
50
+
51
+ let data = make_task("t1", "Test");
52
+ write_task(&task_dir, "todo", &data).unwrap();
53
+
54
+ change_status(&task_dir, "in_progress").unwrap();
55
+
56
+ let (_, status) = read_task(&task_dir).unwrap().unwrap();
57
+ assert_eq!(status, "in_progress");
58
+
59
+ assert!(!task_dir.join("_task.todo.json").exists());
60
+ assert!(task_dir.join("_task.in_progress.json").exists());
61
+ }
62
+
63
+ #[test]
64
+ fn change_to_same_status_is_noop() {
65
+ let dir = setup();
66
+ let task_dir = dir.path().join("t1-test");
67
+ fs::create_dir_all(&task_dir).unwrap();
68
+
69
+ let data = make_task("t1", "Test");
70
+ write_task(&task_dir, "todo", &data).unwrap();
71
+
72
+ change_status(&task_dir, "todo").unwrap();
73
+ let (_, status) = read_task(&task_dir).unwrap().unwrap();
74
+ assert_eq!(status, "todo");
75
+ }
76
+
77
+ #[test]
78
+ fn invalid_status_returns_error() {
79
+ let dir = setup();
80
+ let task_dir = dir.path().join("t1-test");
81
+ fs::create_dir_all(&task_dir).unwrap();
82
+
83
+ let data = make_task("t1", "Test");
84
+ write_task(&task_dir, "todo", &data).unwrap();
85
+
86
+ assert!(change_status(&task_dir, "invalid_status").is_err());
87
+ }
88
+
89
+ #[test]
90
+ fn next_top_level_id_empty() {
91
+ let dir = setup();
92
+ let tasks_dir = dir.path().join("tasks");
93
+ fs::create_dir_all(&tasks_dir).unwrap();
94
+
95
+ assert_eq!(next_top_level_id(&tasks_dir).unwrap(), "t1");
96
+ }
97
+
98
+ #[test]
99
+ fn next_top_level_id_with_existing() {
100
+ let dir = setup();
101
+ let tasks_dir = dir.path().join("tasks");
102
+ fs::create_dir_all(tasks_dir.join("t1-first")).unwrap();
103
+ fs::create_dir_all(tasks_dir.join("t3-third")).unwrap();
104
+
105
+ assert_eq!(next_top_level_id(&tasks_dir).unwrap(), "t4");
106
+ }
107
+
108
+ #[test]
109
+ fn next_child_id_empty_parent() {
110
+ let dir = setup();
111
+ let parent_dir = dir.path().join("tasks/t1-parent");
112
+ fs::create_dir_all(&parent_dir).unwrap();
113
+
114
+ assert_eq!(next_child_id(&parent_dir, "t1").unwrap(), "t1.1");
115
+ }
116
+
117
+ #[test]
118
+ fn next_child_id_with_existing_children() {
119
+ let dir = setup();
120
+ let parent_dir = dir.path().join("tasks/t1-parent");
121
+ fs::create_dir_all(parent_dir.join("t1.1-first")).unwrap();
122
+ fs::create_dir_all(parent_dir.join("t1.2-second")).unwrap();
123
+
124
+ assert_eq!(next_child_id(&parent_dir, "t1").unwrap(), "t1.3");
125
+ }
126
+
127
+ #[test]
128
+ fn title_to_slug_basic() {
129
+ assert_eq!(title_to_slug("Hello World"), "hello-world");
130
+ assert_eq!(title_to_slug("P0: SM_RESTART"), "p0-sm-restart");
131
+ assert_eq!(title_to_slug(" Multiple Spaces "), "multiple-spaces");
132
+ }
133
+
134
+ #[test]
135
+ fn title_to_slug_empty() {
136
+ assert_eq!(title_to_slug(""), "task");
137
+ }
138
+
139
+ #[test]
140
+ fn find_task_dir_by_id_finds_nested() {
141
+ let dir = setup();
142
+ let tasks_dir = dir.path().join("tasks");
143
+ let nested = tasks_dir.join("t1-parent/t1.1-child");
144
+ fs::create_dir_all(&nested).unwrap();
145
+ write_task(&nested, "todo", &make_task("t1.1", "child")).unwrap();
146
+
147
+ let found = find_task_dir_by_id(&tasks_dir, "t1.1").unwrap();
148
+ assert!(found.is_some());
149
+ assert!(found.unwrap().ends_with("t1.1-child"));
150
+ }
151
+
152
+ #[test]
153
+ fn find_task_dir_by_id_not_found() {
154
+ let dir = setup();
155
+ let tasks_dir = dir.path().join("tasks");
156
+ fs::create_dir_all(&tasks_dir).unwrap();
157
+
158
+ assert!(find_task_dir_by_id(&tasks_dir, "t99").unwrap().is_none());
159
+ }
160
+
161
+ #[test]
162
+ fn build_task_index_basic() {
163
+ let dir = setup();
164
+ let tasks_dir = dir.path().join("tasks");
165
+
166
+ create_task_dir(
167
+ &tasks_dir,
168
+ "t1-parent",
169
+ "in_progress",
170
+ &make_task("t1", "Parent"),
171
+ );
172
+ create_task_dir(
173
+ &tasks_dir.join("t1-parent"),
174
+ "t1.1-child",
175
+ "done",
176
+ &make_task("t1.1", "Child Done"),
177
+ );
178
+ create_task_dir(&tasks_dir, "t2-other", "todo", &make_task("t2", "Other"));
179
+
180
+ let (tree, summary) = build_task_index(&tasks_dir, 10).unwrap();
181
+ assert_eq!(summary.total, 3);
182
+ assert_eq!(*summary.by_status.get("in_progress").unwrap_or(&0), 1);
183
+ assert_eq!(*summary.by_status.get("done").unwrap_or(&0), 1);
184
+ assert_eq!(*summary.by_status.get("todo").unwrap_or(&0), 1);
185
+
186
+ assert_eq!(tree.len(), 2);
187
+ let t1 = tree.iter().find(|t| t.id == "t1").unwrap();
188
+ assert_eq!(t1.children.len(), 1);
189
+ assert_eq!(t1.children[0].id, "t1.1");
190
+ }
191
+
192
+ #[test]
193
+ fn build_task_index_done_limit() {
194
+ let dir = setup();
195
+ let tasks_dir = dir.path().join("tasks");
196
+
197
+ for i in 1..=5 {
198
+ create_task_dir(
199
+ &tasks_dir,
200
+ &format!("t{i}-task-{i}"),
201
+ "done",
202
+ &make_task(&format!("t{i}"), &format!("Task {i}")),
203
+ );
204
+ }
205
+
206
+ let (tree, summary) = build_task_index(&tasks_dir, 3).unwrap();
207
+ assert_eq!(summary.total, 5);
208
+ assert_eq!(*summary.by_status.get("done").unwrap(), 5);
209
+ assert_eq!(tree.len(), 3);
210
+ }
211
+
212
+ #[test]
213
+ fn validate_done_unchecked_criteria_fails() {
214
+ let dir = setup();
215
+ let task_dir = dir.path().join("t1-test");
216
+ fs::create_dir_all(&task_dir).unwrap();
217
+
218
+ let data = TaskData {
219
+ done_criteria: vec![
220
+ DoneCriterion {
221
+ item: "test passes".to_string(),
222
+ checked: true,
223
+ },
224
+ DoneCriterion {
225
+ item: "review done".to_string(),
226
+ checked: false,
227
+ },
228
+ ],
229
+ ..make_task("t1", "Test")
230
+ };
231
+
232
+ assert!(validate_done_transition(&task_dir, &data).is_err());
233
+ }
234
+
235
+ #[test]
236
+ fn validate_done_all_checked_passes() {
237
+ let dir = setup();
238
+ let task_dir = dir.path().join("t1-test");
239
+ fs::create_dir_all(&task_dir).unwrap();
240
+
241
+ let data = TaskData {
242
+ done_criteria: vec![DoneCriterion {
243
+ item: "test passes".to_string(),
244
+ checked: true,
245
+ }],
246
+ ..make_task("t1", "Test")
247
+ };
248
+
249
+ assert!(validate_done_transition(&task_dir, &data).is_ok());
250
+ }
251
+
252
+ #[test]
253
+ fn validate_done_child_not_terminal_fails() {
254
+ let dir = setup();
255
+ let task_dir = dir.path().join("t1-test");
256
+ fs::create_dir_all(&task_dir).unwrap();
257
+
258
+ create_task_dir(
259
+ &task_dir,
260
+ "t1.1-child",
261
+ "in_progress",
262
+ &make_task("t1.1", "Child"),
263
+ );
264
+
265
+ let data = make_task("t1", "Parent");
266
+ assert!(validate_done_transition(&task_dir, &data).is_err());
267
+ }
268
+
269
+ #[test]
270
+ fn validate_done_child_all_terminal_passes() {
271
+ let dir = setup();
272
+ let task_dir = dir.path().join("t1-test");
273
+ fs::create_dir_all(&task_dir).unwrap();
274
+
275
+ create_task_dir(
276
+ &task_dir,
277
+ "t1.1-child-a",
278
+ "done",
279
+ &make_task("t1.1", "Child A"),
280
+ );
281
+ create_task_dir(
282
+ &task_dir,
283
+ "t1.2-child-b",
284
+ "skipped",
285
+ &make_task("t1.2", "Child B"),
286
+ );
287
+
288
+ let data = make_task("t1", "Parent");
289
+ assert!(validate_done_transition(&task_dir, &data).is_ok());
290
+ }
291
+
292
+ #[test]
293
+ fn validate_skipped_child_not_terminal_fails() {
294
+ let dir = setup();
295
+ let task_dir = dir.path().join("t1-test");
296
+ fs::create_dir_all(&task_dir).unwrap();
297
+
298
+ create_task_dir(&task_dir, "t1.1-child", "todo", &make_task("t1.1", "Child"));
299
+
300
+ let data = make_task("t1", "Parent");
301
+ assert!(validate_skipped_transition(&task_dir, &data).is_err());
302
+ }
@@ -0,0 +1,165 @@
1
+ use serde_json::{json, Value};
2
+ use std::fs;
3
+ use tempfile::TempDir;
4
+
5
+ fn send(input: &str) -> Option<Value> {
6
+ let result = handoff_mcp::mcp::protocol::process_line(input)?;
7
+ Some(serde_json::from_str(&result).expect("response should be valid JSON"))
8
+ }
9
+
10
+ fn call_tool(name: &str, arguments: Value) -> Value {
11
+ let req = json!({
12
+ "jsonrpc": "2.0", "id": 1,
13
+ "method": "tools/call",
14
+ "params": { "name": name, "arguments": arguments }
15
+ });
16
+ send(&req.to_string()).unwrap()
17
+ }
18
+
19
+ fn get_text(resp: &Value) -> String {
20
+ resp["result"]["content"][0]["text"]
21
+ .as_str()
22
+ .unwrap_or("")
23
+ .to_string()
24
+ }
25
+
26
+ fn is_error(resp: &Value) -> bool {
27
+ resp["result"]["isError"].as_bool().unwrap_or(false)
28
+ }
29
+
30
+ fn init_project(base: &std::path::Path, name: &str) {
31
+ let project_dir = base.join(name);
32
+ fs::create_dir_all(&project_dir).unwrap();
33
+
34
+ let req = json!({
35
+ "jsonrpc": "2.0", "id": 0,
36
+ "method": "tools/call",
37
+ "params": {
38
+ "name": "handoff_init",
39
+ "arguments": {
40
+ "project_dir": project_dir.to_string_lossy(),
41
+ "project_name": name
42
+ }
43
+ }
44
+ });
45
+ send(&req.to_string()).unwrap();
46
+ }
47
+
48
+ fn setup_scan_dir() -> TempDir {
49
+ tempfile::tempdir().expect("failed to create temp dir")
50
+ }
51
+
52
+ #[test]
53
+ fn dashboard_multiple_projects() {
54
+ let scan_dir = setup_scan_dir();
55
+
56
+ init_project(scan_dir.path(), "project-a");
57
+ init_project(scan_dir.path(), "project-b");
58
+
59
+ let resp = call_tool(
60
+ "handoff_dashboard",
61
+ json!({ "scan_dirs": [scan_dir.path().to_string_lossy()] }),
62
+ );
63
+
64
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
65
+ let text = get_text(&resp);
66
+ let parsed: Value = serde_json::from_str(&text).unwrap();
67
+
68
+ let projects = parsed["projects"].as_array().unwrap();
69
+ assert_eq!(projects.len(), 2);
70
+
71
+ let names: Vec<&str> = projects
72
+ .iter()
73
+ .map(|p| p["name"].as_str().unwrap())
74
+ .collect();
75
+ assert!(names.contains(&"project-a"));
76
+ assert!(names.contains(&"project-b"));
77
+ }
78
+
79
+ #[test]
80
+ fn dashboard_skips_non_handoff_dirs() {
81
+ let scan_dir = setup_scan_dir();
82
+
83
+ init_project(scan_dir.path(), "real-project");
84
+ fs::create_dir_all(scan_dir.path().join("not-a-project")).unwrap();
85
+
86
+ let resp = call_tool(
87
+ "handoff_dashboard",
88
+ json!({ "scan_dirs": [scan_dir.path().to_string_lossy()] }),
89
+ );
90
+
91
+ let text = get_text(&resp);
92
+ let parsed: Value = serde_json::from_str(&text).unwrap();
93
+ let projects = parsed["projects"].as_array().unwrap();
94
+ assert_eq!(projects.len(), 1);
95
+ }
96
+
97
+ #[test]
98
+ fn dashboard_with_tasks() {
99
+ let scan_dir = setup_scan_dir();
100
+ init_project(scan_dir.path(), "proj-tasks");
101
+
102
+ let pd = scan_dir
103
+ .path()
104
+ .join("proj-tasks")
105
+ .to_string_lossy()
106
+ .to_string();
107
+
108
+ call_tool(
109
+ "handoff_update_task",
110
+ json!({
111
+ "project_dir": &pd,
112
+ "task": { "title": "Task 1", "status": "in_progress" }
113
+ }),
114
+ );
115
+ call_tool(
116
+ "handoff_update_task",
117
+ json!({
118
+ "project_dir": &pd,
119
+ "task": { "title": "Task 2", "status": "blocked", "notes": "Waiting" }
120
+ }),
121
+ );
122
+
123
+ let resp = call_tool(
124
+ "handoff_dashboard",
125
+ json!({ "scan_dirs": [scan_dir.path().to_string_lossy()] }),
126
+ );
127
+
128
+ let text = get_text(&resp);
129
+ let parsed: Value = serde_json::from_str(&text).unwrap();
130
+
131
+ assert_eq!(parsed["total_active_tasks"], 1);
132
+ assert_eq!(parsed["total_blocked"], 1);
133
+
134
+ let proj = &parsed["projects"][0];
135
+ assert_eq!(proj["active_tasks"], 1);
136
+ assert_eq!(proj["blocked_tasks"], 1);
137
+ }
138
+
139
+ #[test]
140
+ fn dashboard_empty_scan_dir() {
141
+ let scan_dir = setup_scan_dir();
142
+
143
+ let resp = call_tool(
144
+ "handoff_dashboard",
145
+ json!({ "scan_dirs": [scan_dir.path().to_string_lossy()] }),
146
+ );
147
+
148
+ let text = get_text(&resp);
149
+ let parsed: Value = serde_json::from_str(&text).unwrap();
150
+ assert!(parsed["projects"].as_array().unwrap().is_empty());
151
+ assert_eq!(parsed["total_active_tasks"], 0);
152
+ }
153
+
154
+ #[test]
155
+ fn dashboard_nonexistent_scan_dir() {
156
+ let resp = call_tool(
157
+ "handoff_dashboard",
158
+ json!({ "scan_dirs": ["/nonexistent/path/that/does/not/exist"] }),
159
+ );
160
+
161
+ assert!(!is_error(&resp));
162
+ let text = get_text(&resp);
163
+ let parsed: Value = serde_json::from_str(&text).unwrap();
164
+ assert!(parsed["projects"].as_array().unwrap().is_empty());
165
+ }
@@ -0,0 +1,176 @@
1
+ use serde_json::{json, Value};
2
+ use tempfile::TempDir;
3
+
4
+ fn send(input: &str) -> Option<Value> {
5
+ let result = handoff_mcp::mcp::protocol::process_line(input)?;
6
+ Some(serde_json::from_str(&result).expect("response should be valid JSON"))
7
+ }
8
+
9
+ fn setup() -> TempDir {
10
+ tempfile::tempdir().expect("failed to create temp dir")
11
+ }
12
+
13
+ #[test]
14
+ fn init_creates_handoff_directory() {
15
+ let dir = setup();
16
+ let project_dir = dir.path().to_string_lossy();
17
+
18
+ let req = json!({
19
+ "jsonrpc": "2.0",
20
+ "id": 1,
21
+ "method": "tools/call",
22
+ "params": {
23
+ "name": "handoff_init",
24
+ "arguments": {
25
+ "project_dir": project_dir,
26
+ "project_name": "test-project",
27
+ "description": "A test project"
28
+ }
29
+ }
30
+ });
31
+
32
+ let resp = send(&req.to_string()).expect("should return response");
33
+ assert_eq!(resp["id"], 1);
34
+ assert!(resp["error"].is_null(), "error: {:?}", resp["error"]);
35
+
36
+ let content = &resp["result"]["content"][0]["text"];
37
+ assert!(
38
+ content.as_str().unwrap().contains("test-project"),
39
+ "response: {content}"
40
+ );
41
+
42
+ let handoff = dir.path().join(".handoff");
43
+ assert!(handoff.exists());
44
+ assert!(handoff.join("config.toml").exists());
45
+ assert!(handoff.join("sessions").is_dir());
46
+ assert!(handoff.join("tasks").is_dir());
47
+
48
+ let config_content = std::fs::read_to_string(handoff.join("config.toml")).unwrap();
49
+ assert!(config_content.contains("test-project"));
50
+ assert!(config_content.contains("A test project"));
51
+ }
52
+
53
+ #[test]
54
+ fn init_double_init_returns_error() {
55
+ let dir = setup();
56
+ let project_dir = dir.path().to_string_lossy();
57
+
58
+ let req = json!({
59
+ "jsonrpc": "2.0",
60
+ "id": 1,
61
+ "method": "tools/call",
62
+ "params": {
63
+ "name": "handoff_init",
64
+ "arguments": {
65
+ "project_dir": project_dir,
66
+ "project_name": "test-project"
67
+ }
68
+ }
69
+ });
70
+
71
+ send(&req.to_string()).unwrap();
72
+
73
+ let req2 = json!({
74
+ "jsonrpc": "2.0",
75
+ "id": 2,
76
+ "method": "tools/call",
77
+ "params": {
78
+ "name": "handoff_init",
79
+ "arguments": {
80
+ "project_dir": project_dir,
81
+ "project_name": "test-project"
82
+ }
83
+ }
84
+ });
85
+
86
+ let resp = send(&req2.to_string()).unwrap();
87
+ let content = &resp["result"]["content"][0]["text"];
88
+ assert!(content.as_str().unwrap().contains("already"));
89
+ assert_eq!(resp["result"]["isError"], true);
90
+ }
91
+
92
+ #[test]
93
+ fn init_without_project_name_returns_error() {
94
+ let dir = setup();
95
+ let project_dir = dir.path().to_string_lossy();
96
+
97
+ let req = json!({
98
+ "jsonrpc": "2.0",
99
+ "id": 1,
100
+ "method": "tools/call",
101
+ "params": {
102
+ "name": "handoff_init",
103
+ "arguments": {
104
+ "project_dir": project_dir
105
+ }
106
+ }
107
+ });
108
+
109
+ let resp = send(&req.to_string()).unwrap();
110
+ assert_eq!(resp["result"]["isError"], true);
111
+ }
112
+
113
+ #[test]
114
+ fn init_config_has_defaults() {
115
+ let dir = setup();
116
+ let project_dir = dir.path().to_string_lossy();
117
+
118
+ let req = json!({
119
+ "jsonrpc": "2.0",
120
+ "id": 1,
121
+ "method": "tools/call",
122
+ "params": {
123
+ "name": "handoff_init",
124
+ "arguments": {
125
+ "project_dir": project_dir,
126
+ "project_name": "defaults-test"
127
+ }
128
+ }
129
+ });
130
+
131
+ send(&req.to_string()).unwrap();
132
+
133
+ let config =
134
+ handoff_mcp::storage::config::read_config(&dir.path().join(".handoff/config.toml"))
135
+ .unwrap();
136
+
137
+ assert_eq!(config.project.name, "defaults-test");
138
+ assert_eq!(config.settings.history_limit, 20);
139
+ assert_eq!(config.settings.done_task_limit, 10);
140
+ assert!(config.settings.auto_git_summary);
141
+ }
142
+
143
+ #[test]
144
+ fn tools_call_unknown_tool_returns_error() {
145
+ let req = json!({
146
+ "jsonrpc": "2.0",
147
+ "id": 1,
148
+ "method": "tools/call",
149
+ "params": {
150
+ "name": "nonexistent_tool",
151
+ "arguments": {}
152
+ }
153
+ });
154
+
155
+ let resp = send(&req.to_string()).unwrap();
156
+ assert_eq!(resp["result"]["isError"], true);
157
+ assert!(resp["result"]["content"][0]["text"]
158
+ .as_str()
159
+ .unwrap()
160
+ .contains("not implemented"));
161
+ }
162
+
163
+ #[test]
164
+ fn tools_call_missing_name_returns_error() {
165
+ let req = json!({
166
+ "jsonrpc": "2.0",
167
+ "id": 1,
168
+ "method": "tools/call",
169
+ "params": {
170
+ "arguments": {}
171
+ }
172
+ });
173
+
174
+ let resp = send(&req.to_string()).unwrap();
175
+ assert!(resp["error"].is_object());
176
+ }