handoff-mcp-server 0.2.0 → 0.4.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.
@@ -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] = &[
@@ -56,10 +92,28 @@ const VALID_STATUSES: &[&str] = &[
56
92
  "skipped",
57
93
  ];
58
94
 
95
+ const VALID_PRIORITIES: &[&str] = &["low", "medium", "high"];
96
+
59
97
  pub fn is_valid_status(status: &str) -> bool {
60
98
  VALID_STATUSES.contains(&status)
61
99
  }
62
100
 
101
+ pub fn is_valid_priority(priority: &str) -> bool {
102
+ VALID_PRIORITIES.contains(&priority)
103
+ }
104
+
105
+ pub fn validate_priority(priority: Option<&str>) -> Result<()> {
106
+ if let Some(p) = priority {
107
+ if !is_valid_priority(p) {
108
+ anyhow::bail!(
109
+ "Invalid priority: '{p}'. Must be one of: {}",
110
+ VALID_PRIORITIES.join(", ")
111
+ );
112
+ }
113
+ }
114
+ Ok(())
115
+ }
116
+
63
117
  pub fn is_terminal_status(status: &str) -> bool {
64
118
  status == "done" || status == "skipped"
65
119
  }
@@ -251,9 +305,17 @@ pub fn build_task_index(
251
305
  let mut tree = Vec::new();
252
306
  let mut summary = TaskSummary {
253
307
  total: 0,
254
- by_status: std::collections::HashMap::new(),
308
+ by_status: HashMap::new(),
309
+ total_estimate_hours: None,
310
+ total_actual_hours: None,
311
+ completion_rate: None,
312
+ overdue_count: 0,
255
313
  };
256
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();
257
319
 
258
320
  build_index_recursive(
259
321
  tasks_dir,
@@ -261,17 +323,37 @@ pub fn build_task_index(
261
323
  &mut summary,
262
324
  &mut done_count,
263
325
  done_task_limit,
326
+ &mut estimate_sum,
327
+ &mut actual_sum,
328
+ &mut has_hours,
329
+ &today,
264
330
  )?;
265
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
+
266
343
  Ok((tree, summary))
267
344
  }
268
345
 
346
+ #[allow(clippy::too_many_arguments)]
269
347
  fn build_index_recursive(
270
348
  dir: &Path,
271
349
  tree: &mut Vec<TaskIndex>,
272
350
  summary: &mut TaskSummary,
273
351
  done_count: &mut u32,
274
352
  done_task_limit: u32,
353
+ estimate_sum: &mut f64,
354
+ actual_sum: &mut f64,
355
+ has_hours: &mut bool,
356
+ today: &str,
275
357
  ) -> Result<()> {
276
358
  if !dir.exists() {
277
359
  return Ok(());
@@ -298,6 +380,22 @@ fn build_index_recursive(
298
380
  summary.total += 1;
299
381
  *summary.by_status.entry(status.clone()).or_insert(0) += 1;
300
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
+
301
399
  if is_terminal_status(&status) {
302
400
  *done_count += 1;
303
401
  if *done_count > done_task_limit {
@@ -312,12 +410,19 @@ fn build_index_recursive(
312
410
  summary,
313
411
  done_count,
314
412
  done_task_limit,
413
+ estimate_sum,
414
+ actual_sum,
415
+ has_hours,
416
+ today,
315
417
  )?;
316
418
 
317
419
  tree.push(TaskIndex {
318
420
  id: data.id,
319
421
  title: data.title,
320
422
  status,
423
+ schedule: data.schedule,
424
+ dependencies: data.dependencies,
425
+ order: data.order,
321
426
  children,
322
427
  });
323
428
  }
@@ -325,6 +430,80 @@ fn build_index_recursive(
325
430
  Ok(())
326
431
  }
327
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
+
328
507
  pub fn validate_done_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
329
508
  for criterion in &data.done_criteria {
330
509
  if !criterion.checked {
@@ -55,6 +55,10 @@ fn tools_list_returns_all_tools() {
55
55
  "handoff_get_config",
56
56
  "handoff_update_config",
57
57
  "handoff_dashboard",
58
+ "handoff_import_context",
59
+ "handoff_refer",
60
+ "handoff_list_referrals",
61
+ "handoff_update_referral",
58
62
  ];
59
63
 
60
64
  let tool_names: Vec<&str> = tools
@@ -24,6 +24,9 @@ fn make_task(id: &str, title: &str) -> TaskData {
24
24
  labels: Vec::new(),
25
25
  links: Vec::new(),
26
26
  done_criteria: Vec::new(),
27
+ schedule: None,
28
+ dependencies: Vec::new(),
29
+ order: None,
27
30
  }
28
31
  }
29
32
 
@@ -300,3 +303,37 @@ fn validate_skipped_child_not_terminal_fails() {
300
303
  let data = make_task("t1", "Parent");
301
304
  assert!(validate_skipped_transition(&task_dir, &data).is_err());
302
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
+ }
@@ -375,3 +375,69 @@ fn import_source_recorded_in_environment() {
375
375
  "markdown"
376
376
  );
377
377
  }
378
+
379
+ fn is_error(resp: &Value) -> bool {
380
+ resp["result"]["isError"].as_bool().unwrap_or(false)
381
+ }
382
+
383
+ #[test]
384
+ fn import_invalid_priority_rejected() {
385
+ let dir = setup_project();
386
+ let resp = call_tool(
387
+ "handoff_import_context",
388
+ json!({
389
+ "project_dir": dir.path().to_string_lossy(),
390
+ "source": { "description": "bad priority import" },
391
+ "tasks": [
392
+ { "title": "Task", "priority": "urgent" }
393
+ ]
394
+ }),
395
+ );
396
+ assert!(is_error(&resp));
397
+ let text = get_text(&resp);
398
+ assert!(text.contains("Invalid priority"));
399
+ }
400
+
401
+ #[test]
402
+ fn import_valid_priorities_accepted() {
403
+ let dir = setup_project();
404
+ let resp = call_tool(
405
+ "handoff_import_context",
406
+ json!({
407
+ "project_dir": dir.path().to_string_lossy(),
408
+ "source": { "description": "valid priorities" },
409
+ "tasks": [
410
+ { "title": "Low", "priority": "low" },
411
+ { "title": "Medium", "priority": "medium" },
412
+ { "title": "High", "priority": "high" }
413
+ ]
414
+ }),
415
+ );
416
+ assert!(!is_error(&resp), "error: {}", get_text(&resp));
417
+ let text = get_text(&resp);
418
+ assert!(text.contains("Tasks created: 3"));
419
+ }
420
+
421
+ #[test]
422
+ fn import_invalid_priority_in_child_rejected() {
423
+ let dir = setup_project();
424
+ let resp = call_tool(
425
+ "handoff_import_context",
426
+ json!({
427
+ "project_dir": dir.path().to_string_lossy(),
428
+ "source": { "description": "bad child priority" },
429
+ "tasks": [
430
+ {
431
+ "title": "Parent",
432
+ "priority": "high",
433
+ "children": [
434
+ { "title": "Child", "priority": "ASAP" }
435
+ ]
436
+ }
437
+ ]
438
+ }),
439
+ );
440
+ assert!(is_error(&resp));
441
+ let text = get_text(&resp);
442
+ assert!(text.contains("Invalid priority"));
443
+ }