handoff-mcp-server 0.10.0 → 0.12.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.
@@ -11,6 +11,27 @@ pub struct Config {
11
11
  pub settings: SettingsConfig,
12
12
  #[serde(default)]
13
13
  pub dashboard: DashboardConfig,
14
+ /// Project-level start timestamp (pre-start mode). RFC3339 string.
15
+ #[serde(default, skip_serializing_if = "Option::is_none")]
16
+ pub started_at: Option<String>,
17
+ /// Scheduling mode: "manual" or "auto".
18
+ #[serde(default, skip_serializing_if = "Option::is_none")]
19
+ pub schedule_mode: Option<String>,
20
+ /// Project-level label vocabulary.
21
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
22
+ pub labels: Vec<String>,
23
+ #[serde(default, skip_serializing_if = "CalendarConfig::is_empty")]
24
+ pub calendar: CalendarConfig,
25
+ /// Team members keyed by stable assignee key.
26
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
27
+ pub assignees: HashMap<String, AssigneeConfig>,
28
+ /// Milestones keyed by milestone name.
29
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
30
+ pub milestones: HashMap<String, MilestoneConfig>,
31
+ #[serde(default, skip_serializing_if = "GanttViewConfig::is_empty")]
32
+ pub gantt_view: GanttViewConfig,
33
+ #[serde(default, skip_serializing_if = "EffortBudgetConfig::is_empty")]
34
+ pub effort_budget: EffortBudgetConfig,
14
35
  }
15
36
 
16
37
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -28,6 +49,13 @@ pub struct SettingsConfig {
28
49
  pub done_task_limit: u32,
29
50
  #[serde(default = "default_auto_git_summary")]
30
51
  pub auto_git_summary: bool,
52
+ /// Require `estimate_hours` when creating/updating leaf tasks. Default true.
53
+ #[serde(default = "default_require_estimate_hours")]
54
+ pub require_estimate_hours: bool,
55
+ /// Multiplier applied to AI-entered `estimate_hours` to derive the
56
+ /// adjusted (AI-effort) estimate at aggregation time. Default 0.2.
57
+ #[serde(default = "default_ai_estimate_multiplier")]
58
+ pub ai_estimate_multiplier: f64,
31
59
  #[serde(default)]
32
60
  pub context_files: Vec<String>,
33
61
  #[serde(default)]
@@ -42,6 +70,121 @@ pub struct DashboardConfig {
42
70
  pub exclude_patterns: Vec<String>,
43
71
  }
44
72
 
73
+ /// Project-wide working calendar. Mirrors VSCode `CalendarConfig`.
74
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
75
+ pub struct CalendarConfig {
76
+ #[serde(default, skip_serializing_if = "Option::is_none")]
77
+ pub work_hours_per_day: Option<f64>,
78
+ /// Weekday numbers (0=Sun..6=Sat) that are non-working.
79
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
80
+ pub closed_weekdays: Vec<u32>,
81
+ /// Specific YYYY-MM-DD dates that are non-working (override weekdays).
82
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
83
+ pub closed_dates: Vec<String>,
84
+ /// Specific YYYY-MM-DD dates that are working even if normally closed.
85
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
86
+ pub open_dates: Vec<String>,
87
+ /// Per-weekday / per-date working-hour overrides. Key = weekday name or YYYY-MM-DD.
88
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
89
+ pub day_hours: HashMap<String, f64>,
90
+ #[serde(default, skip_serializing_if = "Option::is_none")]
91
+ pub schedule_mode: Option<String>,
92
+ #[serde(default, skip_serializing_if = "Option::is_none")]
93
+ pub overwork_limit_percent: Option<f64>,
94
+ #[serde(default, skip_serializing_if = "Option::is_none")]
95
+ pub max_utilization: Option<f64>,
96
+ }
97
+
98
+ impl CalendarConfig {
99
+ pub fn is_empty(&self) -> bool {
100
+ self.work_hours_per_day.is_none()
101
+ && self.closed_weekdays.is_empty()
102
+ && self.closed_dates.is_empty()
103
+ && self.open_dates.is_empty()
104
+ && self.day_hours.is_empty()
105
+ && self.schedule_mode.is_none()
106
+ && self.overwork_limit_percent.is_none()
107
+ && self.max_utilization.is_none()
108
+ }
109
+ }
110
+
111
+ /// A single team member's configuration. Mirrors VSCode `AssigneeConfig`.
112
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
113
+ pub struct AssigneeConfig {
114
+ #[serde(default, skip_serializing_if = "Option::is_none")]
115
+ pub display_name: Option<String>,
116
+ #[serde(default, skip_serializing_if = "Option::is_none")]
117
+ pub color: Option<String>,
118
+ #[serde(default, skip_serializing_if = "Option::is_none")]
119
+ pub work_hours_per_day: Option<f64>,
120
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
121
+ pub closed_weekdays: Vec<u32>,
122
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
123
+ pub closed_dates: Vec<String>,
124
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
125
+ pub open_dates: Vec<String>,
126
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
127
+ pub day_hours: HashMap<String, f64>,
128
+ }
129
+
130
+ /// A milestone definition. Mirrors VSCode `MilestoneConfig`.
131
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
132
+ pub struct MilestoneConfig {
133
+ #[serde(default, skip_serializing_if = "Option::is_none")]
134
+ pub date: Option<String>,
135
+ #[serde(default, skip_serializing_if = "Option::is_none")]
136
+ pub color: Option<String>,
137
+ #[serde(default, skip_serializing_if = "Option::is_none")]
138
+ pub description: Option<String>,
139
+ }
140
+
141
+ /// Gantt view UI settings. Mirrors VSCode `GanttViewSettings`.
142
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
143
+ pub struct GanttViewConfig {
144
+ #[serde(default, skip_serializing_if = "Option::is_none")]
145
+ pub sort: Option<String>,
146
+ #[serde(default, skip_serializing_if = "Option::is_none")]
147
+ pub zoom: Option<String>,
148
+ #[serde(default, skip_serializing_if = "Option::is_none")]
149
+ pub mode: Option<String>,
150
+ #[serde(default, skip_serializing_if = "Option::is_none")]
151
+ pub group_by_milestone: Option<bool>,
152
+ #[serde(default, skip_serializing_if = "Option::is_none")]
153
+ pub group_by_assignee: Option<bool>,
154
+ #[serde(default, skip_serializing_if = "Option::is_none")]
155
+ pub show_workload: Option<bool>,
156
+ #[serde(default, skip_serializing_if = "Option::is_none")]
157
+ pub filter_assignee: Option<String>,
158
+ #[serde(default, skip_serializing_if = "Option::is_none")]
159
+ pub workload_view: Option<String>,
160
+ }
161
+
162
+ impl GanttViewConfig {
163
+ pub fn is_empty(&self) -> bool {
164
+ self.sort.is_none()
165
+ && self.zoom.is_none()
166
+ && self.mode.is_none()
167
+ && self.group_by_milestone.is_none()
168
+ && self.group_by_assignee.is_none()
169
+ && self.show_workload.is_none()
170
+ && self.filter_assignee.is_none()
171
+ && self.workload_view.is_none()
172
+ }
173
+ }
174
+
175
+ /// Effort budget. Mirrors VSCode `budgetTotalHours`.
176
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
177
+ pub struct EffortBudgetConfig {
178
+ #[serde(default, skip_serializing_if = "Option::is_none")]
179
+ pub total_hours: Option<f64>,
180
+ }
181
+
182
+ impl EffortBudgetConfig {
183
+ pub fn is_empty(&self) -> bool {
184
+ self.total_hours.is_none()
185
+ }
186
+ }
187
+
45
188
  fn default_history_limit() -> u32 {
46
189
  20
47
190
  }
@@ -54,6 +197,14 @@ fn default_auto_git_summary() -> bool {
54
197
  true
55
198
  }
56
199
 
200
+ fn default_require_estimate_hours() -> bool {
201
+ true
202
+ }
203
+
204
+ fn default_ai_estimate_multiplier() -> f64 {
205
+ 0.2
206
+ }
207
+
57
208
  fn default_scan_dirs() -> Vec<String> {
58
209
  vec!["~/pro/".to_string()]
59
210
  }
@@ -64,6 +215,8 @@ impl Default for SettingsConfig {
64
215
  history_limit: default_history_limit(),
65
216
  done_task_limit: default_done_task_limit(),
66
217
  auto_git_summary: default_auto_git_summary(),
218
+ require_estimate_hours: default_require_estimate_hours(),
219
+ ai_estimate_multiplier: default_ai_estimate_multiplier(),
67
220
  context_files: Vec::new(),
68
221
  custom_fields: HashMap::new(),
69
222
  }
@@ -92,6 +245,14 @@ impl Config {
92
245
  },
93
246
  settings: SettingsConfig::default(),
94
247
  dashboard: DashboardConfig::default(),
248
+ started_at: None,
249
+ schedule_mode: None,
250
+ labels: Vec::new(),
251
+ calendar: CalendarConfig::default(),
252
+ assignees: HashMap::new(),
253
+ milestones: HashMap::new(),
254
+ gantt_view: GanttViewConfig::default(),
255
+ effort_budget: EffortBudgetConfig::default(),
95
256
  }
96
257
  }
97
258
  }
@@ -106,7 +267,7 @@ pub fn read_config(path: &Path) -> Result<Config> {
106
267
 
107
268
  pub fn write_config(path: &Path, config: &Config) -> Result<()> {
108
269
  let content = toml::to_string_pretty(config).context("Failed to serialize config")?;
109
- std::fs::write(path, content)
270
+ crate::storage::atomic_write(path, content.as_bytes())
110
271
  .with_context(|| format!("Failed to write config: {}", path.display()))?;
111
272
  Ok(())
112
273
  }
@@ -8,6 +8,48 @@ use std::path::{Path, PathBuf};
8
8
 
9
9
  use anyhow::{Context, Result};
10
10
 
11
+ /// Write `content` to `path` atomically: write to a sibling temp file, fsync,
12
+ /// then rename over the target. A rename within the same directory is atomic on
13
+ /// POSIX, so a concurrent reader never observes a partially-written file.
14
+ ///
15
+ /// Used by every handoff write path (tasks, config, sessions, referrals) so that
16
+ /// the VSCode extension — which writes the same files — never reads torn data.
17
+ pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> Result<()> {
18
+ use std::io::Write;
19
+
20
+ let path = path.as_ref();
21
+ let dir = path.parent().ok_or_else(|| {
22
+ anyhow::anyhow!("Cannot determine parent directory for {}", path.display())
23
+ })?;
24
+ let file_name = path
25
+ .file_name()
26
+ .and_then(|n| n.to_str())
27
+ .ok_or_else(|| anyhow::anyhow!("Invalid file name for {}", path.display()))?;
28
+
29
+ // Unique-per-process temp name in the same directory (so rename is atomic).
30
+ let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
31
+ let tmp_path = dir.join(tmp_name);
32
+
33
+ let mut f = std::fs::File::create(&tmp_path)
34
+ .with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
35
+ f.write_all(content)
36
+ .with_context(|| format!("Failed to write temp file {}", tmp_path.display()))?;
37
+ f.sync_all()
38
+ .with_context(|| format!("Failed to sync temp file {}", tmp_path.display()))?;
39
+ drop(f);
40
+
41
+ std::fs::rename(&tmp_path, path).with_context(|| {
42
+ // Best-effort cleanup so a failed rename doesn't leave a stray temp file.
43
+ let _ = std::fs::remove_file(&tmp_path);
44
+ format!(
45
+ "Failed to rename {} -> {}",
46
+ tmp_path.display(),
47
+ path.display()
48
+ )
49
+ })?;
50
+ Ok(())
51
+ }
52
+
11
53
  pub fn expand_tilde(path: &str) -> String {
12
54
  if let Some(rest) = path.strip_prefix("~/") {
13
55
  if let Ok(home) = std::env::var("HOME") {
@@ -59,7 +59,7 @@ pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathB
59
59
  let file_path = referrals_dir.join(&filename);
60
60
 
61
61
  let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
62
- std::fs::write(&file_path, content)
62
+ crate::storage::atomic_write(&file_path, content.as_bytes())
63
63
  .with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
64
64
 
65
65
  Ok(file_path)
@@ -160,6 +160,44 @@ pub fn change_referral_status(
160
160
  Ok(())
161
161
  }
162
162
 
163
+ /// Read a single referral's full data by ID, returning the data and its status.
164
+ /// Searches every status (open / acknowledged / resolved). Matches by exact ID
165
+ /// first, then falls back to a unique prefix match for convenience.
166
+ pub fn read_referral_by_id(
167
+ referrals_dir: &Path,
168
+ referral_id: &str,
169
+ ) -> Result<Option<(ReferralData, String)>> {
170
+ if !referrals_dir.exists() {
171
+ return Ok(None);
172
+ }
173
+
174
+ let mut prefix_matches: Vec<(ReferralData, String)> = Vec::new();
175
+
176
+ for entry in std::fs::read_dir(referrals_dir)? {
177
+ let entry = entry?;
178
+ let name = entry.file_name().to_string_lossy().to_string();
179
+ if let Some(status) = parse_referral_status(&name) {
180
+ let content = std::fs::read_to_string(entry.path())?;
181
+ if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
182
+ if data.id == referral_id {
183
+ return Ok(Some((data, status)));
184
+ }
185
+ if data.id.starts_with(referral_id) {
186
+ prefix_matches.push((data, status));
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ match prefix_matches.len() {
193
+ 0 => Ok(None),
194
+ 1 => Ok(Some(prefix_matches.into_iter().next().unwrap())),
195
+ n => anyhow::bail!(
196
+ "Ambiguous referral id prefix '{referral_id}' matches {n} referrals; use the full id"
197
+ ),
198
+ }
199
+ }
200
+
163
201
  pub fn find_referral_file(
164
202
  referrals_dir: &Path,
165
203
  referral_id: &str,
@@ -109,6 +109,10 @@ fn synthesize_id_from_filename(filename: &str) -> String {
109
109
  }
110
110
  }
111
111
 
112
+ fn ids_match(candidate: &str, query: &str) -> bool {
113
+ candidate == query || candidate.starts_with(query) || query.starts_with(candidate)
114
+ }
115
+
112
116
  pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
113
117
  let mut data = data.clone();
114
118
  if data.id.is_none() {
@@ -121,7 +125,7 @@ pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<Pat
121
125
  let path = sessions_dir.join(&filename);
122
126
 
123
127
  let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
124
- std::fs::write(&path, content)
128
+ crate::storage::atomic_write(&path, content.as_bytes())
125
129
  .with_context(|| format!("Failed to write session: {}", path.display()))?;
126
130
 
127
131
  Ok(path)
@@ -165,6 +169,40 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
165
169
  read_sessions_by_status(sessions_dir, "active")
166
170
  }
167
171
 
172
+ /// Append `decision` (a decision object: {decision, reason?, confidence?}) to the
173
+ /// `decisions` list of every active session file. Returns the number of sessions
174
+ /// updated. Used by tools like auto_schedule to record applied changes so the
175
+ /// audit trail lives with the session, not just the tool response.
176
+ pub fn append_decision_to_active_sessions(
177
+ sessions_dir: &Path,
178
+ decision: serde_json::Value,
179
+ ) -> Result<usize> {
180
+ let suffix = ".active.json";
181
+ if !sessions_dir.exists() {
182
+ return Ok(0);
183
+ }
184
+
185
+ let mut count = 0;
186
+ for entry in std::fs::read_dir(sessions_dir)? {
187
+ let entry = entry?;
188
+ let name = entry.file_name().to_string_lossy().to_string();
189
+ if !name.ends_with(suffix) {
190
+ continue;
191
+ }
192
+ let path = entry.path();
193
+ let content = std::fs::read_to_string(&path)
194
+ .with_context(|| format!("Failed to read session: {}", path.display()))?;
195
+ let mut data: SessionData = serde_json::from_str(&content)
196
+ .with_context(|| format!("Failed to parse session: {}", path.display()))?;
197
+ data.decisions.push(decision.clone());
198
+ let updated = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
199
+ crate::storage::atomic_write(&path, updated.as_bytes())
200
+ .with_context(|| format!("Failed to write session: {}", path.display()))?;
201
+ count += 1;
202
+ }
203
+ Ok(count)
204
+ }
205
+
168
206
  fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
169
207
  let mut transitioned = Vec::new();
170
208
  let from_suffix = format!(".{from}.json");
@@ -206,6 +244,8 @@ fn transition_session_by_id(
206
244
  return Ok(None);
207
245
  }
208
246
 
247
+ let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
248
+
209
249
  for entry in std::fs::read_dir(sessions_dir)? {
210
250
  let entry = entry?;
211
251
  let name = entry.file_name().to_string_lossy().to_string();
@@ -219,25 +259,39 @@ fn transition_session_by_id(
219
259
  .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
220
260
 
221
261
  let file_id = data.id.as_deref().unwrap_or("").to_string();
222
- let synthesized = if file_id.is_empty() {
262
+ let resolved_id = if file_id.is_empty() {
223
263
  synthesize_id_from_filename(&name)
224
264
  } else {
225
265
  file_id
226
266
  };
227
267
 
228
- if synthesized == session_id {
229
- let new_name = name.replace(&from_suffix, &to_suffix);
230
- let new_path = sessions_dir.join(&new_name);
231
- std::fs::rename(entry.path(), &new_path).with_context(|| {
232
- format!(
233
- "Failed to transition session {from}->{to}: {}",
234
- entry.path().display()
235
- )
236
- })?;
237
- return Ok(Some(new_path));
268
+ if ids_match(&resolved_id, session_id) {
269
+ matches.push((entry.path(), name, resolved_id));
238
270
  }
239
271
  }
240
272
 
273
+ if matches.len() > 1 {
274
+ let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
275
+ anyhow::bail!(
276
+ "Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
277
+ session_id,
278
+ matches.len(),
279
+ candidates.join(", ")
280
+ );
281
+ }
282
+
283
+ if let Some((path, name, _)) = matches.into_iter().next() {
284
+ let new_name = name.replace(&from_suffix, &to_suffix);
285
+ let new_path = sessions_dir.join(&new_name);
286
+ std::fs::rename(&path, &new_path).with_context(|| {
287
+ format!(
288
+ "Failed to transition session {from}->{to}: {}",
289
+ path.display()
290
+ )
291
+ })?;
292
+ return Ok(Some(new_path));
293
+ }
294
+
241
295
  Ok(None)
242
296
  }
243
297
 
@@ -329,6 +383,8 @@ fn find_and_update_active_session(
329
383
  return Ok(None);
330
384
  }
331
385
 
386
+ let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
387
+
332
388
  for entry in std::fs::read_dir(sessions_dir)? {
333
389
  let entry = entry?;
334
390
  let name = entry.file_name().to_string_lossy().to_string();
@@ -338,41 +394,58 @@ fn find_and_update_active_session(
338
394
 
339
395
  let content = std::fs::read_to_string(entry.path())
340
396
  .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
341
- let mut data: SessionData = serde_json::from_str(&content)
397
+ let data: SessionData = serde_json::from_str(&content)
342
398
  .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
343
399
 
344
400
  let file_id = data.id.as_deref().unwrap_or("").to_string();
345
- let synthesized = if file_id.is_empty() {
401
+ let resolved_id = if file_id.is_empty() {
346
402
  synthesize_id_from_filename(&name)
347
403
  } else {
348
404
  file_id
349
405
  };
350
406
 
351
- if synthesized != session_id {
352
- continue;
407
+ if ids_match(&resolved_id, session_id) {
408
+ matches.push((entry.path(), name, resolved_id));
353
409
  }
410
+ }
411
+
412
+ if matches.len() > 1 {
413
+ let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
414
+ anyhow::bail!(
415
+ "Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
416
+ session_id,
417
+ matches.len(),
418
+ candidates.join(", ")
419
+ );
420
+ }
421
+
422
+ if let Some((path, name, _)) = matches.into_iter().next() {
423
+ let content = std::fs::read_to_string(&path)
424
+ .with_context(|| format!("Failed to read session: {}", path.display()))?;
425
+ let mut data: SessionData = serde_json::from_str(&content)
426
+ .with_context(|| format!("Failed to parse session: {}", path.display()))?;
354
427
 
355
428
  apply_session_updates(&mut data, updates);
356
429
 
357
430
  let updated_content =
358
431
  serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
359
- std::fs::write(entry.path(), &updated_content)
360
- .with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
432
+ crate::storage::atomic_write(&path, updated_content.as_bytes())
433
+ .with_context(|| format!("Failed to write session: {}", path.display()))?;
361
434
 
362
435
  if let Some(target_status) = transition_to {
363
436
  let target_suffix = format!(".{target_status}.json");
364
437
  let new_name = name.replace(suffix, &target_suffix);
365
438
  let new_path = sessions_dir.join(&new_name);
366
- std::fs::rename(entry.path(), &new_path).with_context(|| {
439
+ std::fs::rename(&path, &new_path).with_context(|| {
367
440
  format!(
368
441
  "Failed to transition session active->{target_status}: {}",
369
- entry.path().display()
442
+ path.display()
370
443
  )
371
444
  })?;
372
445
  return Ok(Some(new_path));
373
446
  }
374
447
 
375
- return Ok(Some(entry.path()));
448
+ return Ok(Some(path));
376
449
  }
377
450
 
378
451
  Ok(None)
@@ -440,7 +513,7 @@ pub fn write_session_with_status(
440
513
  let path = sessions_dir.join(&filename);
441
514
 
442
515
  let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
443
- std::fs::write(&path, content)
516
+ crate::storage::atomic_write(&path, content.as_bytes())
444
517
  .with_context(|| format!("Failed to write session: {}", path.display()))?;
445
518
 
446
519
  Ok(path)