handoff-mcp-server 0.13.0 → 0.15.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,3 +1,4 @@
1
+ use std::cmp::Reverse;
1
2
  use std::collections::{HashMap, HashSet};
2
3
  use std::path::{Path, PathBuf};
3
4
 
@@ -341,27 +342,117 @@ pub fn find_task_dir_by_id(tasks_dir: &Path, task_id: &str) -> Result<Option<Pat
341
342
  find_task_dir_recursive(tasks_dir, task_id)
342
343
  }
343
344
 
345
+ fn dir_name_could_match(dir_name: &str, task_id: &str) -> bool {
346
+ dir_name == task_id
347
+ || (dir_name.starts_with(task_id) && dir_name.as_bytes().get(task_id.len()) == Some(&b'-'))
348
+ }
349
+
344
350
  fn find_task_dir_recursive(dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
345
351
  if !dir.exists() {
346
352
  return Ok(None);
347
353
  }
354
+ let mut candidates = Vec::new();
355
+ let mut other_subdirs = Vec::new();
348
356
  for entry in std::fs::read_dir(dir)? {
349
357
  let entry = entry?;
350
358
  if !entry.file_type()?.is_dir() {
351
359
  continue;
352
360
  }
353
361
  let name = entry.file_name().to_string_lossy().to_string();
354
- let entry_id = name.split('-').next().unwrap_or("");
355
- if entry_id == task_id {
356
- return Ok(Some(entry.path()));
362
+ if dir_name_could_match(&name, task_id) {
363
+ candidates.push(entry.path());
364
+ } else {
365
+ other_subdirs.push(entry.path());
357
366
  }
358
- if let Some(found) = find_task_dir_recursive(&entry.path(), task_id)? {
367
+ }
368
+ // Verify candidates by reading the JSON id field.
369
+ for candidate in &candidates {
370
+ if let Some((data, _)) = read_task(candidate)? {
371
+ if data.id == task_id {
372
+ return Ok(Some(candidate.clone()));
373
+ }
374
+ }
375
+ }
376
+ // Recurse into all subdirectories (candidates that didn't match + others).
377
+ for subdir in candidates.into_iter().chain(other_subdirs) {
378
+ if let Some(found) = find_task_dir_recursive(&subdir, task_id)? {
359
379
  return Ok(Some(found));
360
380
  }
361
381
  }
362
382
  Ok(None)
363
383
  }
364
384
 
385
+ fn collect_all_ids_recursive(dir: &Path, ids: &mut Vec<String>) -> Result<()> {
386
+ if !dir.exists() {
387
+ return Ok(());
388
+ }
389
+ for entry in std::fs::read_dir(dir)? {
390
+ let entry = entry?;
391
+ if !entry.file_type()?.is_dir() {
392
+ continue;
393
+ }
394
+ if let Some((data, _)) = read_task(&entry.path())? {
395
+ ids.push(data.id);
396
+ }
397
+ collect_all_ids_recursive(&entry.path(), ids)?;
398
+ }
399
+ Ok(())
400
+ }
401
+
402
+ pub fn suggest_task_id(tasks_dir: &Path, requested_id: &str) -> String {
403
+ let mut all_ids = Vec::new();
404
+ let _ = collect_all_ids_recursive(tasks_dir, &mut all_ids);
405
+ if all_ids.is_empty() {
406
+ return format!("Task not found: '{requested_id}'. No tasks exist yet.");
407
+ }
408
+ let mut scored: Vec<(&str, usize)> = all_ids
409
+ .iter()
410
+ .filter_map(|id| {
411
+ let score = fuzzy_score(requested_id, id);
412
+ if score > 0 {
413
+ Some((id.as_str(), score))
414
+ } else {
415
+ None
416
+ }
417
+ })
418
+ .collect();
419
+ scored.sort_by_key(|&(_, s)| Reverse(s));
420
+ scored.truncate(5);
421
+
422
+ if scored.is_empty() {
423
+ return format!(
424
+ "Task not found: '{requested_id}'. Use handoff_list_tasks to see available task IDs."
425
+ );
426
+ }
427
+ let suggestions: Vec<String> = scored.iter().map(|(id, _)| format!(" - {id}")).collect();
428
+ format!(
429
+ "Task not found: '{requested_id}'. Did you mean one of these?\n{}\n\
430
+ Use handoff_list_tasks to see all task IDs.",
431
+ suggestions.join("\n")
432
+ )
433
+ }
434
+
435
+ fn fuzzy_score(query: &str, candidate: &str) -> usize {
436
+ let q = query.to_lowercase();
437
+ let c = candidate.to_lowercase();
438
+ if c == q {
439
+ return 100;
440
+ }
441
+ if c.starts_with(&q) || q.starts_with(&c) {
442
+ return 80;
443
+ }
444
+ if c.contains(&q) || q.contains(&c) {
445
+ return 60;
446
+ }
447
+ let q_parts: Vec<&str> = q.split(['-', '.', '_']).collect();
448
+ let c_parts: Vec<&str> = c.split(['-', '.', '_']).collect();
449
+ let matching = q_parts.iter().filter(|p| c_parts.contains(p)).count();
450
+ if matching > 0 {
451
+ return 20 * matching;
452
+ }
453
+ 0
454
+ }
455
+
365
456
  pub fn build_task_index(
366
457
  tasks_dir: &Path,
367
458
  done_task_limit: u32,