handoff-mcp-server 0.22.0 → 0.23.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.
@@ -207,6 +207,34 @@ pub fn find_doc_by_id(handoff_dir: &Path, doc_id: &str) -> Result<Option<DocMeta
207
207
  Ok(docs.into_iter().find(|d| d.id == doc_id))
208
208
  }
209
209
 
210
+ /// Resolve every `link_type == "doc"` entry in `task_links` to its
211
+ /// [`DocMetadata`], in one pass over `docs/` (via [`read_all_docs`]) rather
212
+ /// than one [`find_doc_by_id`] scan per link. `task_links[].target` holds the
213
+ /// document's stable `id` (see `crate::storage::tasks::sync_doc_task_links`),
214
+ /// so lookup is by `id`, not `slug`. Links whose target doesn't resolve to an
215
+ /// existing document (stale/dangling link) are silently skipped — callers
216
+ /// that need to detect that should compare the input link count against the
217
+ /// output length themselves.
218
+ pub fn batch_resolve_docs(
219
+ handoff_dir: &Path,
220
+ task_links: &[crate::storage::tasks::TaskLink],
221
+ ) -> Result<Vec<DocMetadata>> {
222
+ let doc_ids: Vec<&str> = task_links
223
+ .iter()
224
+ .filter(|l| l.link_type == "doc")
225
+ .map(|l| l.target.as_str())
226
+ .collect();
227
+ if doc_ids.is_empty() {
228
+ return Ok(Vec::new());
229
+ }
230
+
231
+ let all_docs = read_all_docs(handoff_dir)?;
232
+ Ok(doc_ids
233
+ .iter()
234
+ .filter_map(|id| all_docs.iter().find(|d| &d.id == id).cloned())
235
+ .collect())
236
+ }
237
+
210
238
  /// Delete a document's metadata file by exact slug. Returns `Ok(false)` when
211
239
  /// the file does not exist. Does not touch the document's body file —
212
240
  /// callers that want a full delete should also call [`delete_doc_body`].
@@ -475,4 +503,95 @@ mod tests {
475
503
  write_doc_body(&h, "my-slug", "body").unwrap();
476
504
  assert!(docs_dir(&h).exists());
477
505
  }
506
+
507
+ #[test]
508
+ fn batch_resolve_docs_resolves_doc_links_by_id() {
509
+ use crate::storage::tasks::TaskLink;
510
+
511
+ let tmp = TempDir::new().unwrap();
512
+ let h = handoff(&tmp);
513
+ write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
514
+ write_doc(&h, &sample_doc("doc-2", "doc-two")).unwrap();
515
+
516
+ let links = vec![
517
+ TaskLink {
518
+ target: "doc-1".to_string(),
519
+ link_type: "doc".to_string(),
520
+ label: None,
521
+ },
522
+ TaskLink {
523
+ target: "doc-2".to_string(),
524
+ link_type: "doc".to_string(),
525
+ label: None,
526
+ },
527
+ ];
528
+
529
+ let resolved = batch_resolve_docs(&h, &links).unwrap();
530
+ assert_eq!(resolved.len(), 2);
531
+ let ids: Vec<&str> = resolved.iter().map(|d| d.id.as_str()).collect();
532
+ assert!(ids.contains(&"doc-1"));
533
+ assert!(ids.contains(&"doc-2"));
534
+ }
535
+
536
+ #[test]
537
+ fn batch_resolve_docs_ignores_non_doc_link_types() {
538
+ use crate::storage::tasks::TaskLink;
539
+
540
+ let tmp = TempDir::new().unwrap();
541
+ let h = handoff(&tmp);
542
+ write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
543
+
544
+ let links = vec![
545
+ TaskLink {
546
+ target: "doc-1".to_string(),
547
+ link_type: "doc".to_string(),
548
+ label: None,
549
+ },
550
+ TaskLink {
551
+ target: "https://example.com".to_string(),
552
+ link_type: "url".to_string(),
553
+ label: None,
554
+ },
555
+ ];
556
+
557
+ let resolved = batch_resolve_docs(&h, &links).unwrap();
558
+ assert_eq!(resolved.len(), 1);
559
+ assert_eq!(resolved[0].id, "doc-1");
560
+ }
561
+
562
+ #[test]
563
+ fn batch_resolve_docs_skips_dangling_links() {
564
+ use crate::storage::tasks::TaskLink;
565
+
566
+ let tmp = TempDir::new().unwrap();
567
+ let h = handoff(&tmp);
568
+ write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
569
+
570
+ let links = vec![
571
+ TaskLink {
572
+ target: "doc-1".to_string(),
573
+ link_type: "doc".to_string(),
574
+ label: None,
575
+ },
576
+ TaskLink {
577
+ target: "doc-missing".to_string(),
578
+ link_type: "doc".to_string(),
579
+ label: None,
580
+ },
581
+ ];
582
+
583
+ let resolved = batch_resolve_docs(&h, &links).unwrap();
584
+ assert_eq!(resolved.len(), 1);
585
+ assert_eq!(resolved[0].id, "doc-1");
586
+ }
587
+
588
+ #[test]
589
+ fn batch_resolve_docs_empty_links_is_empty_without_reading_docs_dir() {
590
+ let tmp = TempDir::new().unwrap();
591
+ let h = tmp.path().join(".handoff");
592
+ std::fs::create_dir_all(&h).unwrap();
593
+ // docs/ dir does not exist at all — must not error.
594
+ assert!(!docs_dir(&h).exists());
595
+ assert!(batch_resolve_docs(&h, &[]).unwrap().is_empty());
596
+ }
478
597
  }
@@ -786,10 +786,13 @@ pub fn task_has_children(task_dir: &Path) -> Result<bool> {
786
786
  }
787
787
 
788
788
  /// Whether a task in the given status requires an effort estimate.
789
- /// Parent tasks (with children) and blocked/skipped tasks are exempt;
790
- /// this only covers the status dimension.
789
+ /// Parent tasks (with children) and blocked/skipped/todo tasks are exempt;
790
+ /// this only covers the status dimension. `todo` is exempt so that parent
791
+ /// tasks can be created before their children exist (the children turn the
792
+ /// parent from a leaf into a parent, removing the estimate requirement).
793
+ /// The estimate is enforced when the task moves to `in_progress` or later.
791
794
  pub fn status_requires_estimate(status: &str) -> bool {
792
- matches!(status, "todo" | "in_progress" | "review" | "done")
795
+ matches!(status, "in_progress" | "review" | "done")
793
796
  }
794
797
 
795
798
  /// Validate that a leaf task carries an `estimate_hours` when the project
@@ -839,7 +842,7 @@ pub fn validate_estimate_required(
839
842
  Resend with, for example:\n \
840
843
  {example}\n\
841
844
  Exempt from this rule: parent tasks (any task with children), and \
842
- tasks in status 'blocked' or 'skipped'.\n\
845
+ tasks in status 'todo', 'blocked', or 'skipped'.\n\
843
846
  To disable this requirement project-wide, set \
844
847
  settings.require_estimate_hours = false."
845
848
  );