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.
package/Cargo.lock CHANGED
@@ -144,7 +144,7 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.2.0"
147
+ version = "0.4.0"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -0,0 +1,68 @@
1
+ use anyhow::Result;
2
+ use chrono::Utc;
3
+ use serde_json::Value;
4
+
5
+ use super::resolve_project_dir;
6
+ use crate::storage::ensure_handoff_exists;
7
+ use crate::storage::tasks::{find_task_dir_by_id, find_task_file, read_task, write_task};
8
+
9
+ pub fn handle(arguments: &Value) -> Result<String> {
10
+ let project_dir = resolve_project_dir(arguments)?;
11
+
12
+ let handoff = ensure_handoff_exists(&project_dir)?;
13
+ let tasks_dir = handoff.join("tasks");
14
+
15
+ let task_id = arguments
16
+ .get("task_id")
17
+ .and_then(|v| v.as_str())
18
+ .ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
19
+
20
+ let criterion_index = arguments
21
+ .get("criterion_index")
22
+ .and_then(|v| v.as_u64())
23
+ .ok_or_else(|| anyhow::anyhow!("'criterion_index' parameter is required"))?
24
+ as usize;
25
+
26
+ let checked = arguments
27
+ .get("checked")
28
+ .and_then(|v| v.as_bool())
29
+ .ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
30
+
31
+ let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
32
+ .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
33
+
34
+ let (mut data, status) = read_task(&task_dir)?
35
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
36
+
37
+ if criterion_index >= data.done_criteria.len() {
38
+ anyhow::bail!(
39
+ "criterion_index {criterion_index} is out of range (task has {} criteria)",
40
+ data.done_criteria.len()
41
+ );
42
+ }
43
+
44
+ data.done_criteria[criterion_index].checked = checked;
45
+ data.updated_at = Some(Utc::now().to_rfc3339());
46
+
47
+ if let Some((old_path, _)) = find_task_file(&task_dir)? {
48
+ std::fs::remove_file(&old_path)?;
49
+ }
50
+
51
+ write_task(&task_dir, &status, &data)?;
52
+
53
+ let checked_count = data.done_criteria.iter().filter(|c| c.checked).count();
54
+ let total = data.done_criteria.len();
55
+
56
+ let result = serde_json::json!({
57
+ "task_id": data.id,
58
+ "criterion_index": criterion_index,
59
+ "item": data.done_criteria[criterion_index].item,
60
+ "checked": checked,
61
+ "done_criteria_summary": {
62
+ "total": total,
63
+ "checked": checked_count,
64
+ }
65
+ });
66
+
67
+ serde_json::to_string_pretty(&result).map_err(Into::into)
68
+ }
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
4
4
  use serde_json::Value;
5
5
 
6
6
  use crate::storage::config::read_config;
7
+ use crate::storage::expand_tilde;
8
+ use crate::storage::referrals::read_referral_summaries;
7
9
  use crate::storage::sessions::read_active_sessions;
8
10
  use crate::storage::tasks::build_task_index;
9
11
 
@@ -88,6 +90,10 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
88
90
  .flat_map(|s| s.blockers.iter().cloned())
89
91
  .collect();
90
92
 
93
+ let unread_referrals = read_referral_summaries(&handoff_dir.join("referrals"), Some("open"))
94
+ .map(|r| r.len() as u32)
95
+ .unwrap_or(0);
96
+
91
97
  Ok(serde_json::json!({
92
98
  "name": config.project.name,
93
99
  "path": project_path.to_string_lossy(),
@@ -96,14 +102,6 @@ fn collect_project_info(project_path: &Path) -> Result<Value> {
96
102
  "active_tasks": active_tasks,
97
103
  "blocked_tasks": blocked_tasks,
98
104
  "blockers": blockers,
105
+ "unread_referrals": unread_referrals,
99
106
  }))
100
107
  }
101
-
102
- fn expand_tilde(path: &str) -> String {
103
- if let Some(rest) = path.strip_prefix("~/") {
104
- if let Ok(home) = std::env::var("HOME") {
105
- return format!("{home}/{rest}");
106
- }
107
- }
108
- path.to_string()
109
- }
@@ -0,0 +1,43 @@
1
+ use anyhow::Result;
2
+ use serde_json::Value;
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+ use crate::storage::tasks::{find_task_dir_by_id, read_task};
7
+
8
+ pub fn handle(arguments: &Value) -> Result<String> {
9
+ let project_dir = resolve_project_dir(arguments)?;
10
+
11
+ let handoff = ensure_handoff_exists(&project_dir)?;
12
+ let tasks_dir = handoff.join("tasks");
13
+
14
+ let task_id = arguments
15
+ .get("task_id")
16
+ .and_then(|v| v.as_str())
17
+ .ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
18
+
19
+ let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
20
+ .ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
21
+
22
+ let (data, status) = read_task(&task_dir)?
23
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
24
+
25
+ let result = serde_json::json!({
26
+ "id": data.id,
27
+ "title": data.title,
28
+ "status": status,
29
+ "notes": data.notes,
30
+ "priority": data.priority,
31
+ "created_at": data.created_at,
32
+ "updated_at": data.updated_at,
33
+ "completed_at": data.completed_at,
34
+ "labels": data.labels,
35
+ "links": data.links,
36
+ "done_criteria": data.done_criteria,
37
+ "schedule": data.schedule,
38
+ "dependencies": data.dependencies,
39
+ "order": data.order,
40
+ });
41
+
42
+ serde_json::to_string_pretty(&result).map_err(Into::into)
43
+ }
@@ -212,6 +212,9 @@ fn create_task_recursive(
212
212
  anyhow::bail!("Invalid status: {status}");
213
213
  }
214
214
 
215
+ let priority = task_val.get("priority").and_then(|v| v.as_str());
216
+ validate_priority(priority)?;
217
+
215
218
  let completed_at = if is_terminal_status(status) {
216
219
  Some(now.clone())
217
220
  } else {
@@ -225,16 +228,19 @@ fn create_task_recursive(
225
228
  .get("notes")
226
229
  .and_then(|v| v.as_str())
227
230
  .map(String::from),
228
- priority: task_val
229
- .get("priority")
230
- .and_then(|v| v.as_str())
231
- .map(String::from),
231
+ priority: priority.map(String::from),
232
232
  created_at: Some(now.clone()),
233
233
  updated_at: Some(now),
234
234
  completed_at,
235
235
  labels: extract_string_array_from(task_val, "labels"),
236
236
  links: extract_string_array_from(task_val, "links"),
237
237
  done_criteria: extract_done_criteria(task_val),
238
+ schedule: extract_schedule(task_val),
239
+ dependencies: extract_string_array_from(task_val, "dependencies"),
240
+ order: task_val
241
+ .get("order")
242
+ .and_then(|v| v.as_u64())
243
+ .map(|v| v as u32),
238
244
  };
239
245
 
240
246
  write_task(&task_dir, status, &data)?;
@@ -308,3 +314,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
308
314
  })
309
315
  .unwrap_or_default()
310
316
  }
317
+
318
+ fn extract_schedule(val: &Value) -> Option<Schedule> {
319
+ let sched = val.get("schedule")?;
320
+ if sched.is_null() {
321
+ return None;
322
+ }
323
+ Some(Schedule {
324
+ start_date: sched
325
+ .get("start_date")
326
+ .and_then(|v| v.as_str())
327
+ .map(String::from),
328
+ due_date: sched
329
+ .get("due_date")
330
+ .and_then(|v| v.as_str())
331
+ .map(String::from),
332
+ estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
333
+ actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
334
+ milestone: sched
335
+ .get("milestone")
336
+ .and_then(|v| v.as_str())
337
+ .map(String::from),
338
+ })
339
+ }
@@ -51,6 +51,9 @@ fn filter_tree(
51
51
  id: node.id.clone(),
52
52
  title: node.title.clone(),
53
53
  status: node.status.clone(),
54
+ schedule: node.schedule.clone(),
55
+ dependencies: node.dependencies.clone(),
56
+ order: node.order,
54
57
  children,
55
58
  })
56
59
  } else {
@@ -3,6 +3,7 @@ use serde_json::Value;
3
3
 
4
4
  use super::resolve_project_dir;
5
5
  use crate::storage::config::read_config;
6
+ use crate::storage::referrals::read_referral_summaries;
6
7
  use crate::storage::sessions::read_active_sessions;
7
8
  use crate::storage::tasks::build_task_index;
8
9
  use crate::storage::{ensure_handoff_exists, handoff_dir};
@@ -90,5 +91,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
90
91
  result["suggested_reads"] = serde_json::to_value(&config.settings.context_files)?;
91
92
  }
92
93
 
94
+ let referrals_dir = handoff.join("referrals");
95
+ let open_referrals = read_referral_summaries(&referrals_dir, Some("open"))?;
96
+ if !open_referrals.is_empty() {
97
+ result["referrals"] = serde_json::to_value(&open_referrals)?;
98
+ }
99
+
93
100
  serde_json::to_string_pretty(&result).context("Failed to serialize context")
94
101
  }
@@ -1,9 +1,13 @@
1
+ pub mod check_criterion;
1
2
  pub mod config;
2
3
  pub mod dashboard;
4
+ pub mod get_task;
3
5
  pub mod import_context;
4
6
  pub mod init;
5
7
  pub mod list_tasks;
6
8
  pub mod load_context;
9
+ pub mod refer;
10
+ pub mod referrals;
7
11
  pub mod save_context;
8
12
  pub mod update_task;
9
13
 
@@ -32,7 +36,12 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
32
36
  "handoff_dashboard" => dashboard::handle(arguments),
33
37
  "handoff_get_config" => config::handle_get(arguments),
34
38
  "handoff_update_config" => config::handle_update(arguments),
39
+ "handoff_get_task" => get_task::handle(arguments),
40
+ "handoff_check_criterion" => check_criterion::handle(arguments),
35
41
  "handoff_import_context" => import_context::handle(arguments),
42
+ "handoff_refer" => refer::handle(arguments),
43
+ "handoff_list_referrals" => referrals::handle_list(arguments),
44
+ "handoff_update_referral" => referrals::handle_update(arguments),
36
45
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
37
46
  };
38
47
 
@@ -0,0 +1,152 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use anyhow::{Context, Result};
4
+ use chrono::Utc;
5
+ use serde_json::Value;
6
+
7
+ use super::resolve_project_dir;
8
+ use crate::storage::config::read_config;
9
+ use crate::storage::expand_tilde;
10
+ use crate::storage::referrals::{is_valid_referral_type, write_referral, ReferralData};
11
+ use crate::storage::tasks::validate_priority;
12
+
13
+ pub fn handle(arguments: &Value) -> Result<String> {
14
+ let source_project_dir = resolve_project_dir(arguments)?;
15
+
16
+ let source_handoff = source_project_dir.join(".handoff");
17
+ if !source_handoff.join("config.toml").exists() {
18
+ anyhow::bail!(
19
+ "Source project is not initialized: {}",
20
+ source_project_dir.display()
21
+ );
22
+ }
23
+
24
+ let source_config = read_config(&source_handoff.join("config.toml"))?;
25
+
26
+ let target_dir = resolve_target(arguments, &source_config.dashboard.scan_dirs)?;
27
+
28
+ let target_handoff = target_dir.join(".handoff");
29
+ if !target_handoff.exists() {
30
+ anyhow::bail!(
31
+ "Target project is not initialized (no .handoff/): {}",
32
+ target_dir.display()
33
+ );
34
+ }
35
+
36
+ let summary = arguments
37
+ .get("summary")
38
+ .and_then(|v| v.as_str())
39
+ .ok_or_else(|| anyhow::anyhow!("'summary' is required"))?;
40
+
41
+ let referral_type = arguments
42
+ .get("referral_type")
43
+ .and_then(|v| v.as_str())
44
+ .unwrap_or("request");
45
+
46
+ if !is_valid_referral_type(referral_type) {
47
+ anyhow::bail!(
48
+ "Invalid referral_type: '{referral_type}'. Must be one of: improvement, bug, request, info"
49
+ );
50
+ }
51
+
52
+ let priority = arguments.get("priority").and_then(|v| v.as_str());
53
+ validate_priority(priority)?;
54
+
55
+ let details = arguments
56
+ .get("details")
57
+ .and_then(|v| v.as_str())
58
+ .map(String::from);
59
+
60
+ let tasks: Vec<Value> = arguments
61
+ .get("tasks")
62
+ .and_then(|v| v.as_array())
63
+ .cloned()
64
+ .unwrap_or_default();
65
+
66
+ let context = arguments.get("context").cloned();
67
+
68
+ let now_dt = Utc::now();
69
+ let now = now_dt.to_rfc3339();
70
+ let id = format!("ref-{}", now_dt.format("%Y%m%d-%H%M%S-%f"));
71
+
72
+ let data = ReferralData {
73
+ id: id.clone(),
74
+ source_project: source_config.project.name.clone(),
75
+ source_project_dir: source_project_dir.to_string_lossy().to_string(),
76
+ created_at: now,
77
+ referral_type: referral_type.to_string(),
78
+ summary: summary.to_string(),
79
+ details,
80
+ priority: priority.map(String::from),
81
+ tasks,
82
+ context,
83
+ };
84
+
85
+ let referrals_dir = target_handoff.join("referrals");
86
+ write_referral(&referrals_dir, &data)?;
87
+
88
+ let target_name = if target_handoff.join("config.toml").exists() {
89
+ read_config(&target_handoff.join("config.toml"))
90
+ .map(|c| c.project.name)
91
+ .unwrap_or_else(|_| target_dir.to_string_lossy().to_string())
92
+ } else {
93
+ target_dir.to_string_lossy().to_string()
94
+ };
95
+
96
+ Ok(format!(
97
+ "Referral sent: {id}\n From: {}\n To: {target_name}\n Type: {referral_type}\n Summary: {summary}",
98
+ source_config.project.name
99
+ ))
100
+ }
101
+
102
+ fn resolve_target(arguments: &Value, scan_dirs: &[String]) -> Result<PathBuf> {
103
+ if let Some(dir) = arguments.get("target_project_dir").and_then(|v| v.as_str()) {
104
+ let path = PathBuf::from(dir);
105
+ return std::fs::canonicalize(&path)
106
+ .with_context(|| format!("Invalid target project path: {}", path.display()));
107
+ }
108
+
109
+ if let Some(name) = arguments.get("target_project").and_then(|v| v.as_str()) {
110
+ return resolve_by_name(name, scan_dirs);
111
+ }
112
+
113
+ anyhow::bail!("Either 'target_project' or 'target_project_dir' is required")
114
+ }
115
+
116
+ fn resolve_by_name(name: &str, scan_dirs: &[String]) -> Result<PathBuf> {
117
+ for scan_dir in scan_dirs {
118
+ let expanded = expand_tilde(scan_dir);
119
+ let expanded_path = Path::new(&expanded);
120
+
121
+ if !expanded_path.exists() {
122
+ continue;
123
+ }
124
+
125
+ let entries = match std::fs::read_dir(expanded_path) {
126
+ Ok(e) => e,
127
+ Err(_) => continue,
128
+ };
129
+
130
+ for entry in entries.flatten() {
131
+ if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
132
+ continue;
133
+ }
134
+
135
+ let config_path = entry.path().join(".handoff/config.toml");
136
+ if !config_path.exists() {
137
+ continue;
138
+ }
139
+
140
+ if let Ok(config) = read_config(&config_path) {
141
+ if config.project.name == name {
142
+ return Ok(entry.path());
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ anyhow::bail!(
149
+ "Target project '{name}' not found in scan_dirs. \
150
+ Use 'target_project_dir' with an absolute path instead."
151
+ )
152
+ }
@@ -0,0 +1,55 @@
1
+ use anyhow::{Context, Result};
2
+ use serde_json::Value;
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+ use crate::storage::referrals::{
7
+ change_referral_status, is_valid_referral_status, read_referral_summaries,
8
+ };
9
+
10
+ pub fn handle_list(arguments: &Value) -> Result<String> {
11
+ let project_dir = resolve_project_dir(arguments)?;
12
+ let handoff = ensure_handoff_exists(&project_dir)?;
13
+ let referrals_dir = handoff.join("referrals");
14
+
15
+ let status_filter = arguments.get("status_filter").and_then(|v| v.as_str());
16
+
17
+ if let Some(filter) = status_filter {
18
+ if !is_valid_referral_status(filter) {
19
+ anyhow::bail!(
20
+ "Invalid status_filter: '{filter}'. Must be one of: open, acknowledged, resolved"
21
+ );
22
+ }
23
+ }
24
+
25
+ let summaries = read_referral_summaries(&referrals_dir, status_filter)?;
26
+
27
+ let result = serde_json::json!({
28
+ "referrals": summaries,
29
+ "total": summaries.len(),
30
+ });
31
+
32
+ serde_json::to_string_pretty(&result).context("Failed to serialize referrals")
33
+ }
34
+
35
+ pub fn handle_update(arguments: &Value) -> Result<String> {
36
+ let project_dir = resolve_project_dir(arguments)?;
37
+ let handoff = ensure_handoff_exists(&project_dir)?;
38
+ let referrals_dir = handoff.join("referrals");
39
+
40
+ let referral_id = arguments
41
+ .get("referral_id")
42
+ .and_then(|v| v.as_str())
43
+ .ok_or_else(|| anyhow::anyhow!("'referral_id' is required"))?;
44
+
45
+ let status = arguments
46
+ .get("status")
47
+ .and_then(|v| v.as_str())
48
+ .ok_or_else(|| anyhow::anyhow!("'status' is required"))?;
49
+
50
+ change_referral_status(&referrals_dir, referral_id, status)?;
51
+
52
+ Ok(format!(
53
+ "Updated referral {referral_id}: status -> {status}"
54
+ ))
55
+ }
@@ -16,11 +16,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
16
16
  .get("task")
17
17
  .ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
18
18
 
19
- let title = task_val
20
- .get("title")
21
- .and_then(|v| v.as_str())
22
- .ok_or_else(|| anyhow::anyhow!("'task.title' is required"))?;
23
-
24
19
  let task_id = task_val.get("id").and_then(|v| v.as_str());
25
20
  let move_to = arguments.get("move_to").and_then(|v| v.as_str());
26
21
 
@@ -31,6 +26,11 @@ pub fn handle(arguments: &Value) -> Result<String> {
31
26
  return handle_update(&tasks_dir, existing_id, task_val);
32
27
  }
33
28
 
29
+ let title = task_val
30
+ .get("title")
31
+ .and_then(|v| v.as_str())
32
+ .ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
33
+
34
34
  handle_create(&tasks_dir, title, task_val, arguments)
35
35
  }
36
36
 
@@ -71,6 +71,14 @@ fn handle_create(
71
71
  anyhow::bail!("Invalid status: {status}");
72
72
  }
73
73
 
74
+ let priority = task_val.get("priority").and_then(|v| v.as_str());
75
+ validate_priority(priority)?;
76
+
77
+ let dependencies = extract_string_array(task_val, "dependencies");
78
+ if !dependencies.is_empty() {
79
+ validate_dependencies(tasks_dir, &new_id, &dependencies)?;
80
+ }
81
+
74
82
  let data = TaskData {
75
83
  id: new_id.clone(),
76
84
  title: title.to_string(),
@@ -78,16 +86,19 @@ fn handle_create(
78
86
  .get("notes")
79
87
  .and_then(|v| v.as_str())
80
88
  .map(String::from),
81
- priority: task_val
82
- .get("priority")
83
- .and_then(|v| v.as_str())
84
- .map(String::from),
89
+ priority: priority.map(String::from),
85
90
  created_at: Some(now.clone()),
86
91
  updated_at: Some(now),
87
92
  completed_at: None,
88
93
  labels: extract_string_array(task_val, "labels"),
89
94
  links: extract_string_array(task_val, "links"),
90
95
  done_criteria: extract_done_criteria(task_val),
96
+ schedule: extract_schedule(task_val),
97
+ dependencies,
98
+ order: task_val
99
+ .get("order")
100
+ .and_then(|v| v.as_u64())
101
+ .map(|v| v as u32),
91
102
  };
92
103
 
93
104
  write_task(&task_dir, status, &data)?;
@@ -109,6 +120,7 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
109
120
  data.notes = Some(notes.to_string());
110
121
  }
111
122
  if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
123
+ validate_priority(Some(priority))?;
112
124
  data.priority = Some(priority.to_string());
113
125
  }
114
126
  if task_val.get("labels").is_some() {
@@ -120,6 +132,19 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
120
132
  if task_val.get("done_criteria").is_some() {
121
133
  data.done_criteria = extract_done_criteria(task_val);
122
134
  }
135
+ if task_val.get("schedule").is_some() {
136
+ data.schedule = extract_schedule(task_val);
137
+ }
138
+ if task_val.get("dependencies").is_some() {
139
+ let new_deps = extract_string_array(task_val, "dependencies");
140
+ if !new_deps.is_empty() {
141
+ validate_dependencies(tasks_dir, task_id, &new_deps)?;
142
+ }
143
+ data.dependencies = new_deps;
144
+ }
145
+ if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
146
+ data.order = Some(order as u32);
147
+ }
123
148
 
124
149
  let new_status = task_val
125
150
  .get("status")
@@ -205,3 +230,26 @@ fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
205
230
  })
206
231
  .unwrap_or_default()
207
232
  }
233
+
234
+ fn extract_schedule(val: &Value) -> Option<Schedule> {
235
+ let sched = val.get("schedule")?;
236
+ if sched.is_null() {
237
+ return None;
238
+ }
239
+ Some(Schedule {
240
+ start_date: sched
241
+ .get("start_date")
242
+ .and_then(|v| v.as_str())
243
+ .map(String::from),
244
+ due_date: sched
245
+ .get("due_date")
246
+ .and_then(|v| v.as_str())
247
+ .map(String::from),
248
+ estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
249
+ actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
250
+ milestone: sched
251
+ .get("milestone")
252
+ .and_then(|v| v.as_str())
253
+ .map(String::from),
254
+ })
255
+ }