handoff-mcp-server 0.7.0 → 0.7.2

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.
@@ -1,339 +0,0 @@
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
- schedule: None,
28
- dependencies: Vec::new(),
29
- order: None,
30
- }
31
- }
32
-
33
- #[test]
34
- fn write_and_read_task() {
35
- let dir = setup();
36
- let task_dir = dir.path().join("t1-test");
37
- fs::create_dir_all(&task_dir).unwrap();
38
-
39
- let data = make_task("t1", "Test task");
40
- write_task(&task_dir, "todo", &data).unwrap();
41
-
42
- let (read_data, status) = read_task(&task_dir).unwrap().unwrap();
43
- assert_eq!(read_data.id, "t1");
44
- assert_eq!(read_data.title, "Test task");
45
- assert_eq!(status, "todo");
46
- }
47
-
48
- #[test]
49
- fn change_task_status() {
50
- let dir = setup();
51
- let task_dir = dir.path().join("t1-test");
52
- fs::create_dir_all(&task_dir).unwrap();
53
-
54
- let data = make_task("t1", "Test");
55
- write_task(&task_dir, "todo", &data).unwrap();
56
-
57
- change_status(&task_dir, "in_progress").unwrap();
58
-
59
- let (_, status) = read_task(&task_dir).unwrap().unwrap();
60
- assert_eq!(status, "in_progress");
61
-
62
- assert!(!task_dir.join("_task.todo.json").exists());
63
- assert!(task_dir.join("_task.in_progress.json").exists());
64
- }
65
-
66
- #[test]
67
- fn change_to_same_status_is_noop() {
68
- let dir = setup();
69
- let task_dir = dir.path().join("t1-test");
70
- fs::create_dir_all(&task_dir).unwrap();
71
-
72
- let data = make_task("t1", "Test");
73
- write_task(&task_dir, "todo", &data).unwrap();
74
-
75
- change_status(&task_dir, "todo").unwrap();
76
- let (_, status) = read_task(&task_dir).unwrap().unwrap();
77
- assert_eq!(status, "todo");
78
- }
79
-
80
- #[test]
81
- fn invalid_status_returns_error() {
82
- let dir = setup();
83
- let task_dir = dir.path().join("t1-test");
84
- fs::create_dir_all(&task_dir).unwrap();
85
-
86
- let data = make_task("t1", "Test");
87
- write_task(&task_dir, "todo", &data).unwrap();
88
-
89
- assert!(change_status(&task_dir, "invalid_status").is_err());
90
- }
91
-
92
- #[test]
93
- fn next_top_level_id_empty() {
94
- let dir = setup();
95
- let tasks_dir = dir.path().join("tasks");
96
- fs::create_dir_all(&tasks_dir).unwrap();
97
-
98
- assert_eq!(next_top_level_id(&tasks_dir).unwrap(), "t1");
99
- }
100
-
101
- #[test]
102
- fn next_top_level_id_with_existing() {
103
- let dir = setup();
104
- let tasks_dir = dir.path().join("tasks");
105
- fs::create_dir_all(tasks_dir.join("t1-first")).unwrap();
106
- fs::create_dir_all(tasks_dir.join("t3-third")).unwrap();
107
-
108
- assert_eq!(next_top_level_id(&tasks_dir).unwrap(), "t4");
109
- }
110
-
111
- #[test]
112
- fn next_child_id_empty_parent() {
113
- let dir = setup();
114
- let parent_dir = dir.path().join("tasks/t1-parent");
115
- fs::create_dir_all(&parent_dir).unwrap();
116
-
117
- assert_eq!(next_child_id(&parent_dir, "t1").unwrap(), "t1.1");
118
- }
119
-
120
- #[test]
121
- fn next_child_id_with_existing_children() {
122
- let dir = setup();
123
- let parent_dir = dir.path().join("tasks/t1-parent");
124
- fs::create_dir_all(parent_dir.join("t1.1-first")).unwrap();
125
- fs::create_dir_all(parent_dir.join("t1.2-second")).unwrap();
126
-
127
- assert_eq!(next_child_id(&parent_dir, "t1").unwrap(), "t1.3");
128
- }
129
-
130
- #[test]
131
- fn title_to_slug_basic() {
132
- assert_eq!(title_to_slug("Hello World"), "hello-world");
133
- assert_eq!(title_to_slug("P0: SM_RESTART"), "p0-sm-restart");
134
- assert_eq!(title_to_slug(" Multiple Spaces "), "multiple-spaces");
135
- }
136
-
137
- #[test]
138
- fn title_to_slug_empty() {
139
- assert_eq!(title_to_slug(""), "task");
140
- }
141
-
142
- #[test]
143
- fn find_task_dir_by_id_finds_nested() {
144
- let dir = setup();
145
- let tasks_dir = dir.path().join("tasks");
146
- let nested = tasks_dir.join("t1-parent/t1.1-child");
147
- fs::create_dir_all(&nested).unwrap();
148
- write_task(&nested, "todo", &make_task("t1.1", "child")).unwrap();
149
-
150
- let found = find_task_dir_by_id(&tasks_dir, "t1.1").unwrap();
151
- assert!(found.is_some());
152
- assert!(found.unwrap().ends_with("t1.1-child"));
153
- }
154
-
155
- #[test]
156
- fn find_task_dir_by_id_not_found() {
157
- let dir = setup();
158
- let tasks_dir = dir.path().join("tasks");
159
- fs::create_dir_all(&tasks_dir).unwrap();
160
-
161
- assert!(find_task_dir_by_id(&tasks_dir, "t99").unwrap().is_none());
162
- }
163
-
164
- #[test]
165
- fn build_task_index_basic() {
166
- let dir = setup();
167
- let tasks_dir = dir.path().join("tasks");
168
-
169
- create_task_dir(
170
- &tasks_dir,
171
- "t1-parent",
172
- "in_progress",
173
- &make_task("t1", "Parent"),
174
- );
175
- create_task_dir(
176
- &tasks_dir.join("t1-parent"),
177
- "t1.1-child",
178
- "done",
179
- &make_task("t1.1", "Child Done"),
180
- );
181
- create_task_dir(&tasks_dir, "t2-other", "todo", &make_task("t2", "Other"));
182
-
183
- let (tree, summary) = build_task_index(&tasks_dir, 10).unwrap();
184
- assert_eq!(summary.total, 3);
185
- assert_eq!(*summary.by_status.get("in_progress").unwrap_or(&0), 1);
186
- assert_eq!(*summary.by_status.get("done").unwrap_or(&0), 1);
187
- assert_eq!(*summary.by_status.get("todo").unwrap_or(&0), 1);
188
-
189
- assert_eq!(tree.len(), 2);
190
- let t1 = tree.iter().find(|t| t.id == "t1").unwrap();
191
- assert_eq!(t1.children.len(), 1);
192
- assert_eq!(t1.children[0].id, "t1.1");
193
- }
194
-
195
- #[test]
196
- fn build_task_index_done_limit() {
197
- let dir = setup();
198
- let tasks_dir = dir.path().join("tasks");
199
-
200
- for i in 1..=5 {
201
- create_task_dir(
202
- &tasks_dir,
203
- &format!("t{i}-task-{i}"),
204
- "done",
205
- &make_task(&format!("t{i}"), &format!("Task {i}")),
206
- );
207
- }
208
-
209
- let (tree, summary) = build_task_index(&tasks_dir, 3).unwrap();
210
- assert_eq!(summary.total, 5);
211
- assert_eq!(*summary.by_status.get("done").unwrap(), 5);
212
- assert_eq!(tree.len(), 3);
213
- }
214
-
215
- #[test]
216
- fn validate_done_unchecked_criteria_fails() {
217
- let dir = setup();
218
- let task_dir = dir.path().join("t1-test");
219
- fs::create_dir_all(&task_dir).unwrap();
220
-
221
- let data = TaskData {
222
- done_criteria: vec![
223
- DoneCriterion {
224
- item: "test passes".to_string(),
225
- checked: true,
226
- },
227
- DoneCriterion {
228
- item: "review done".to_string(),
229
- checked: false,
230
- },
231
- ],
232
- ..make_task("t1", "Test")
233
- };
234
-
235
- assert!(validate_done_transition(&task_dir, &data).is_err());
236
- }
237
-
238
- #[test]
239
- fn validate_done_all_checked_passes() {
240
- let dir = setup();
241
- let task_dir = dir.path().join("t1-test");
242
- fs::create_dir_all(&task_dir).unwrap();
243
-
244
- let data = TaskData {
245
- done_criteria: vec![DoneCriterion {
246
- item: "test passes".to_string(),
247
- checked: true,
248
- }],
249
- ..make_task("t1", "Test")
250
- };
251
-
252
- assert!(validate_done_transition(&task_dir, &data).is_ok());
253
- }
254
-
255
- #[test]
256
- fn validate_done_child_not_terminal_fails() {
257
- let dir = setup();
258
- let task_dir = dir.path().join("t1-test");
259
- fs::create_dir_all(&task_dir).unwrap();
260
-
261
- create_task_dir(
262
- &task_dir,
263
- "t1.1-child",
264
- "in_progress",
265
- &make_task("t1.1", "Child"),
266
- );
267
-
268
- let data = make_task("t1", "Parent");
269
- assert!(validate_done_transition(&task_dir, &data).is_err());
270
- }
271
-
272
- #[test]
273
- fn validate_done_child_all_terminal_passes() {
274
- let dir = setup();
275
- let task_dir = dir.path().join("t1-test");
276
- fs::create_dir_all(&task_dir).unwrap();
277
-
278
- create_task_dir(
279
- &task_dir,
280
- "t1.1-child-a",
281
- "done",
282
- &make_task("t1.1", "Child A"),
283
- );
284
- create_task_dir(
285
- &task_dir,
286
- "t1.2-child-b",
287
- "skipped",
288
- &make_task("t1.2", "Child B"),
289
- );
290
-
291
- let data = make_task("t1", "Parent");
292
- assert!(validate_done_transition(&task_dir, &data).is_ok());
293
- }
294
-
295
- #[test]
296
- fn validate_skipped_child_not_terminal_fails() {
297
- let dir = setup();
298
- let task_dir = dir.path().join("t1-test");
299
- fs::create_dir_all(&task_dir).unwrap();
300
-
301
- create_task_dir(&task_dir, "t1.1-child", "todo", &make_task("t1.1", "Child"));
302
-
303
- let data = make_task("t1", "Parent");
304
- assert!(validate_skipped_transition(&task_dir, &data).is_err());
305
- }
306
-
307
- #[test]
308
- fn is_valid_priority_accepts_valid() {
309
- assert!(is_valid_priority("low"));
310
- assert!(is_valid_priority("medium"));
311
- assert!(is_valid_priority("high"));
312
- }
313
-
314
- #[test]
315
- fn is_valid_priority_rejects_invalid() {
316
- assert!(!is_valid_priority("critical"));
317
- assert!(!is_valid_priority("urgent"));
318
- assert!(!is_valid_priority(""));
319
- assert!(!is_valid_priority("HIGH"));
320
- }
321
-
322
- #[test]
323
- fn validate_priority_none_is_ok() {
324
- assert!(validate_priority(None).is_ok());
325
- }
326
-
327
- #[test]
328
- fn validate_priority_valid_is_ok() {
329
- assert!(validate_priority(Some("low")).is_ok());
330
- assert!(validate_priority(Some("medium")).is_ok());
331
- assert!(validate_priority(Some("high")).is_ok());
332
- }
333
-
334
- #[test]
335
- fn validate_priority_invalid_is_err() {
336
- let err = validate_priority(Some("critical")).unwrap_err();
337
- assert!(err.to_string().contains("Invalid priority"));
338
- assert!(err.to_string().contains("critical"));
339
- }
@@ -1,165 +0,0 @@
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
- }