Skip to main content

bvr/
model.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::{BvrError, Result};
5
6const KNOWN_STATUSES: &[&str] = &[
7    "open",
8    "in_progress",
9    "blocked",
10    "deferred",
11    "pinned",
12    "hooked",
13    "review",
14    "closed",
15    "tombstone",
16];
17
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct Issue {
20    #[serde(default)]
21    pub id: String,
22    #[serde(default)]
23    pub title: String,
24    #[serde(default)]
25    pub description: String,
26    #[serde(default)]
27    pub design: String,
28    #[serde(default)]
29    pub acceptance_criteria: String,
30    #[serde(default)]
31    pub notes: String,
32    #[serde(default)]
33    pub status: String,
34    #[serde(default = "default_priority")]
35    pub priority: i32,
36    #[serde(default)]
37    pub issue_type: String,
38    #[serde(default)]
39    pub assignee: String,
40    #[serde(default)]
41    pub estimated_minutes: Option<i32>,
42    #[serde(default)]
43    pub created_at: Option<DateTime<Utc>>,
44    #[serde(default)]
45    pub updated_at: Option<DateTime<Utc>>,
46    #[serde(default)]
47    pub due_date: Option<DateTime<Utc>>,
48    #[serde(default)]
49    pub closed_at: Option<DateTime<Utc>>,
50    #[serde(default)]
51    pub labels: Vec<String>,
52    #[serde(default)]
53    pub comments: Vec<Comment>,
54    #[serde(default)]
55    pub dependencies: Vec<Dependency>,
56    #[serde(default)]
57    pub source_repo: String,
58    /// Internal workspace prefix used to recover raw IDs from namespaced
59    /// workspace issues. Computed during workspace loading and never emitted.
60    #[serde(skip)]
61    pub workspace_prefix: Option<String>,
62    /// Internal content hash for dedup — computed, not serialized to JSON output.
63    #[serde(default, skip_serializing)]
64    pub content_hash: Option<String>,
65    /// Optional link to external issue tracker (e.g., GitHub issue URL).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub external_ref: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct Dependency {
72    #[serde(default)]
73    pub issue_id: String,
74    #[serde(default)]
75    pub depends_on_id: String,
76    #[serde(default, rename = "type")]
77    pub dep_type: String,
78    #[serde(default)]
79    pub created_by: String,
80    #[serde(default)]
81    pub created_at: Option<DateTime<Utc>>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct Comment {
86    #[serde(default)]
87    pub id: i64,
88    #[serde(default)]
89    pub issue_id: String,
90    #[serde(default)]
91    pub author: String,
92    #[serde(default)]
93    pub text: String,
94    #[serde(default)]
95    pub created_at: Option<DateTime<Utc>>,
96}
97
98impl Dependency {
99    #[must_use]
100    pub fn is_blocking(&self) -> bool {
101        // Mirrors beads_rust::model::DependencyType::is_blocking: the four
102        // edge kinds that gate readiness. `parent-child` is listed on the
103        // authoritative side for propagation-only purposes and is handled
104        // separately in `actionable_ids`; it must NOT be treated as a direct
105        // blocker here or blocker-graph edges would double up.
106        let t = self.dep_type.trim().to_ascii_lowercase();
107        matches!(
108            t.as_str(),
109            "" | "blocks" | "waits-for" | "conditional-blocks"
110        )
111    }
112
113    #[must_use]
114    pub fn is_parent_child(&self) -> bool {
115        let t = self.dep_type.trim().to_ascii_lowercase();
116        t == "parent-child"
117    }
118}
119
120/// Parse an RFC 3339 timestamp string into `DateTime<Utc>`.
121///
122/// Accepts both `"2025-01-10T10:00:00Z"` and `"2025-01-10T10:00:00+00:00"`.
123pub fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
124    DateTime::parse_from_rfc3339(s)
125        .ok()
126        .map(|dt| dt.with_timezone(&Utc))
127}
128
129/// Shorthand to create `Some(DateTime<Utc>)` from an RFC 3339 string.
130/// Panics if the string is invalid — intended for test fixtures.
131pub fn ts(s: &str) -> Option<DateTime<Utc>> {
132    Some(
133        DateTime::parse_from_rfc3339(s)
134            .unwrap_or_else(|e| panic!("invalid timestamp {s:?}: {e}"))
135            .with_timezone(&Utc),
136    )
137}
138
139/// Create a timestamp N days before now. Useful for test fixtures that need
140/// staleness-relative dates instead of hard-coded absolute timestamps.
141pub fn days_ago(n: i64) -> Option<DateTime<Utc>> {
142    Some(Utc::now() - chrono::Duration::days(n))
143}
144
145impl Issue {
146    #[must_use]
147    pub fn normalized_status(&self) -> String {
148        self.status.trim().to_ascii_lowercase()
149    }
150
151    /// Returns true for any terminal status (closed or tombstone).
152    #[must_use]
153    pub fn is_closed_like(&self) -> bool {
154        matches!(self.normalized_status().as_str(), "closed" | "tombstone")
155    }
156
157    /// Returns true only for the "closed" status (not tombstone).
158    #[must_use]
159    pub fn is_closed(&self) -> bool {
160        self.normalized_status() == "closed"
161    }
162
163    /// Returns true only for the "tombstone" status (permanently removed).
164    #[must_use]
165    pub fn is_tombstone(&self) -> bool {
166        self.normalized_status() == "tombstone"
167    }
168
169    /// Returns true when the issue is already being worked on.
170    #[must_use]
171    pub fn is_in_progress(&self) -> bool {
172        self.normalized_status() == "in_progress"
173    }
174
175    #[must_use]
176    pub fn is_open_like(&self) -> bool {
177        !self.is_closed_like()
178    }
179
180    #[must_use]
181    pub fn priority_normalized(&self) -> f64 {
182        let p = self.priority.clamp(0, 4);
183        // Priority 0 => 1.0, Priority 4 => 0.2
184        (5_i32.saturating_sub(p)) as f64 / 5.0
185    }
186
187    pub fn validate(&self) -> Result<()> {
188        if self.id.trim().is_empty() {
189            return Err(BvrError::InvalidIssue(
190                "issue id cannot be empty".to_string(),
191            ));
192        }
193        if self.title.trim().is_empty() {
194            return Err(BvrError::InvalidIssue(format!(
195                "issue {} title cannot be empty",
196                self.id
197            )));
198        }
199        if self.issue_type.trim().is_empty() {
200            return Err(BvrError::InvalidIssue(format!(
201                "issue {} issue_type cannot be empty",
202                self.id
203            )));
204        }
205
206        let status = self.normalized_status();
207        if status.is_empty() {
208            return Err(BvrError::InvalidIssue(format!(
209                "issue {} status cannot be empty",
210                self.id
211            )));
212        }
213        if !KNOWN_STATUSES.contains(&status.as_str()) {
214            return Err(BvrError::InvalidIssue(format!(
215                "issue {} has unknown status: {}",
216                self.id, self.status
217            )));
218        }
219
220        if let (Some(created_at), Some(updated_at)) = (self.created_at, self.updated_at)
221            && updated_at < created_at
222        {
223            return Err(BvrError::InvalidIssue(format!(
224                "issue {} updated_at cannot be earlier than created_at",
225                self.id
226            )));
227        }
228
229        if let (Some(created_at), Some(closed_at)) = (self.created_at, self.closed_at)
230            && closed_at < created_at
231        {
232            return Err(BvrError::InvalidIssue(format!(
233                "issue {} closed_at cannot be earlier than created_at",
234                self.id
235            )));
236        }
237
238        Ok(())
239    }
240}
241
242const fn default_priority() -> i32 {
243    3
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, Default)]
247pub struct Sprint {
248    #[serde(default)]
249    pub id: String,
250    #[serde(default)]
251    pub name: String,
252    #[serde(default)]
253    pub start_date: Option<DateTime<Utc>>,
254    #[serde(default)]
255    pub end_date: Option<DateTime<Utc>>,
256    #[serde(default)]
257    pub bead_ids: Vec<String>,
258}
259
260impl Sprint {
261    #[must_use]
262    pub fn is_active_at(&self, now: DateTime<Utc>) -> bool {
263        let Some(start_date) = self.start_date else {
264            return false;
265        };
266        let Some(end_date) = self.end_date else {
267            return false;
268        };
269
270        now >= start_date && now <= end_date
271    }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct BurndownPoint {
276    pub date: DateTime<Utc>,
277    pub remaining: i32,
278    pub completed: i32,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    // -- Dependency tests --
286
287    #[test]
288    fn dependency_is_blocking_for_empty_type() {
289        let dep = Dependency {
290            dep_type: String::new(),
291            ..Default::default()
292        };
293        assert!(dep.is_blocking());
294    }
295
296    #[test]
297    fn dependency_is_blocking_for_blocks_type() {
298        let dep = Dependency {
299            dep_type: "blocks".to_string(),
300            ..Default::default()
301        };
302        assert!(dep.is_blocking());
303        // case insensitive + trim
304        let dep2 = Dependency {
305            dep_type: "  Blocks  ".to_string(),
306            ..Default::default()
307        };
308        assert!(dep2.is_blocking());
309    }
310
311    #[test]
312    fn dependency_is_blocking_for_waits_for_and_conditional_blocks() {
313        // Regression for bvr #14: waits-for and conditional-blocks were silently
314        // ignored, letting dependent issues surface as actionable/top-picks while
315        // `br ready` (the authoritative view) correctly excluded them.
316        for t in ["waits-for", "conditional-blocks"] {
317            let dep = Dependency {
318                dep_type: t.to_string(),
319                ..Default::default()
320            };
321            assert!(dep.is_blocking(), "{t} should be blocking");
322        }
323        // Case-insensitive + trim applies uniformly
324        let dep = Dependency {
325            dep_type: "  Waits-For  ".to_string(),
326            ..Default::default()
327        };
328        assert!(dep.is_blocking());
329        let dep = Dependency {
330            dep_type: "Conditional-Blocks".to_string(),
331            ..Default::default()
332        };
333        assert!(dep.is_blocking());
334    }
335
336    #[test]
337    fn dependency_not_blocking_for_other_types() {
338        // `parent-child` is excluded deliberately: it's a propagation edge in
339        // beads_rust's blocked-cache rebuild (src/storage/sqlite.rs:3109-3115),
340        // not a direct blocker. `actionable_ids` handles parent-child
341        // transitively via epic-blocked propagation.
342        for t in [
343            "parent-child",
344            "related",
345            "mentions",
346            "discovered-from",
347            "unknown",
348        ] {
349            let dep = Dependency {
350                dep_type: t.to_string(),
351                ..Default::default()
352            };
353            assert!(!dep.is_blocking(), "{t} should not be blocking");
354        }
355    }
356
357    #[test]
358    fn dependency_is_parent_child() {
359        let dep = Dependency {
360            dep_type: "parent-child".to_string(),
361            ..Default::default()
362        };
363        assert!(dep.is_parent_child());
364        // case insensitive
365        let dep2 = Dependency {
366            dep_type: " Parent-Child ".to_string(),
367            ..Default::default()
368        };
369        assert!(dep2.is_parent_child());
370        // Not parent-child
371        let dep3 = Dependency {
372            dep_type: "blocks".to_string(),
373            ..Default::default()
374        };
375        assert!(!dep3.is_parent_child());
376    }
377
378    // -- Issue status tests --
379
380    #[test]
381    fn normalized_status_lowercases_and_trims() {
382        let issue = Issue {
383            status: "  OPEN  ".to_string(),
384            ..Default::default()
385        };
386        assert_eq!(issue.normalized_status(), "open");
387    }
388
389    #[test]
390    fn is_closed_like_detects_closed_and_tombstone() {
391        for status in ["closed", "Closed", "CLOSED", "tombstone", "Tombstone"] {
392            let issue = Issue {
393                status: status.to_string(),
394                ..Default::default()
395            };
396            assert!(issue.is_closed_like(), "{status} should be closed-like");
397            assert!(!issue.is_open_like(), "{status} should not be open-like");
398        }
399    }
400
401    #[test]
402    fn is_closed_vs_tombstone_distinction() {
403        let closed = Issue {
404            status: "closed".to_string(),
405            ..Default::default()
406        };
407        assert!(closed.is_closed());
408        assert!(!closed.is_tombstone());
409        assert!(closed.is_closed_like());
410
411        let tombstone = Issue {
412            status: "tombstone".to_string(),
413            ..Default::default()
414        };
415        assert!(!tombstone.is_closed());
416        assert!(tombstone.is_tombstone());
417        assert!(tombstone.is_closed_like());
418
419        let open = Issue {
420            status: "open".to_string(),
421            ..Default::default()
422        };
423        assert!(!open.is_closed());
424        assert!(!open.is_tombstone());
425        assert!(!open.is_closed_like());
426    }
427
428    #[test]
429    fn is_open_like_for_all_open_statuses() {
430        for status in [
431            "open",
432            "in_progress",
433            "blocked",
434            "deferred",
435            "pinned",
436            "hooked",
437            "review",
438        ] {
439            let issue = Issue {
440                status: status.to_string(),
441                ..Default::default()
442            };
443            assert!(issue.is_open_like(), "{status} should be open-like");
444            assert!(
445                !issue.is_closed_like(),
446                "{status} should not be closed-like"
447            );
448        }
449    }
450
451    #[test]
452    fn content_hash_and_external_ref_defaults() {
453        let issue = Issue::default();
454        assert!(issue.workspace_prefix.is_none());
455        assert!(issue.content_hash.is_none());
456        assert!(issue.external_ref.is_none());
457
458        let issue_with_ref = Issue {
459            external_ref: Some("https://github.com/org/repo/issues/42".to_string()),
460            ..Default::default()
461        };
462        assert_eq!(
463            issue_with_ref.external_ref.as_deref(),
464            Some("https://github.com/org/repo/issues/42")
465        );
466    }
467
468    #[test]
469    fn workspace_prefix_is_never_serialized_or_deserialized() {
470        let issue = Issue {
471            id: "api-1".to_string(),
472            workspace_prefix: Some("api-".to_string()),
473            ..Default::default()
474        };
475        let json = serde_json::to_value(&issue).unwrap();
476        assert!(
477            json.get("workspace_prefix").is_none(),
478            "internal workspace_prefix must not be serialized"
479        );
480
481        let parsed: Issue = serde_json::from_str(
482            r#"{"id":"api-1","title":"T","status":"open","issue_type":"task","workspace_prefix":"evil-"}"#,
483        )
484        .unwrap();
485        assert!(
486            parsed.workspace_prefix.is_none(),
487            "internal workspace_prefix must not be accepted from external JSON"
488        );
489    }
490
491    // -- Priority normalization --
492
493    #[test]
494    fn priority_normalized_maps_p0_to_highest_and_p4_to_lowest() {
495        let p0 = Issue {
496            priority: 0,
497            ..Default::default()
498        };
499        assert!((p0.priority_normalized() - 1.0).abs() < f64::EPSILON);
500
501        let p4 = Issue {
502            priority: 4,
503            ..Default::default()
504        };
505        assert!((p4.priority_normalized() - 0.2).abs() < f64::EPSILON);
506    }
507
508    #[test]
509    fn priority_normalized_distinguishes_p0_from_p1() {
510        let p0 = Issue {
511            priority: 0,
512            ..Default::default()
513        };
514        let p1 = Issue {
515            priority: 1,
516            ..Default::default()
517        };
518
519        assert!(p0.priority_normalized() > p1.priority_normalized());
520        assert!((p1.priority_normalized() - 0.8).abs() < f64::EPSILON);
521    }
522
523    #[test]
524    fn priority_normalized_clamps_out_of_range() {
525        let too_low = Issue {
526            priority: -10,
527            ..Default::default()
528        };
529        // clamp(0, 4) => 0 => (5-0)/5 = 1.0
530        assert!((too_low.priority_normalized() - 1.0).abs() < f64::EPSILON);
531
532        let too_high = Issue {
533            priority: 100,
534            ..Default::default()
535        };
536        // clamp(0, 4) => 4 => (5-4)/5 = 0.2
537        assert!((too_high.priority_normalized() - 0.2).abs() < f64::EPSILON);
538    }
539
540    #[test]
541    fn priority_normalized_default_treats_zero_as_p0() {
542        // Issue::default() has priority=0 (Rust default), which is also the valid P0 value.
543        let issue = Issue::default();
544        assert_eq!(issue.priority, 0);
545        assert!((issue.priority_normalized() - 1.0).abs() < f64::EPSILON);
546    }
547
548    #[test]
549    fn priority_normalized_serde_default_is_3() {
550        let json = r#"{"id":"X","title":"T"}"#;
551        let issue: Issue = serde_json::from_str(json).unwrap();
552        assert_eq!(issue.priority, 3);
553        // (5-3)/5 = 0.4
554        assert!((issue.priority_normalized() - 0.4).abs() < f64::EPSILON);
555    }
556
557    // -- Validation --
558
559    #[test]
560    fn validate_rejects_empty_id() {
561        let issue = Issue {
562            id: "  ".to_string(),
563            title: "T".to_string(),
564            issue_type: "task".to_string(),
565            status: "open".to_string(),
566            ..Default::default()
567        };
568        let err = issue.validate().unwrap_err();
569        assert!(err.to_string().contains("id cannot be empty"));
570    }
571
572    #[test]
573    fn validate_rejects_empty_title() {
574        let issue = Issue {
575            id: "X-1".to_string(),
576            title: String::new(),
577            issue_type: "task".to_string(),
578            status: "open".to_string(),
579            ..Default::default()
580        };
581        let err = issue.validate().unwrap_err();
582        assert!(err.to_string().contains("title cannot be empty"));
583    }
584
585    #[test]
586    fn validate_rejects_empty_type() {
587        let issue = Issue {
588            id: "X-1".to_string(),
589            title: "Test".to_string(),
590            issue_type: String::new(),
591            status: "open".to_string(),
592            ..Default::default()
593        };
594        let err = issue.validate().unwrap_err();
595        assert!(err.to_string().contains("issue_type cannot be empty"));
596    }
597
598    #[test]
599    fn validate_rejects_empty_status() {
600        let issue = Issue {
601            id: "X-1".to_string(),
602            title: "Test".to_string(),
603            issue_type: "task".to_string(),
604            status: String::new(),
605            ..Default::default()
606        };
607        let err = issue.validate().unwrap_err();
608        assert!(err.to_string().contains("status cannot be empty"));
609    }
610
611    #[test]
612    fn validate_rejects_unknown_status() {
613        let issue = Issue {
614            id: "X-1".to_string(),
615            title: "Test".to_string(),
616            issue_type: "task".to_string(),
617            status: "banana".to_string(),
618            ..Default::default()
619        };
620        let err = issue.validate().unwrap_err();
621        assert!(err.to_string().contains("unknown status"));
622    }
623
624    #[test]
625    fn validate_accepts_all_known_statuses() {
626        for status in KNOWN_STATUSES {
627            let issue = Issue {
628                id: "X-1".to_string(),
629                title: "Test".to_string(),
630                issue_type: "task".to_string(),
631                status: status.to_string(),
632                ..Default::default()
633            };
634            assert!(issue.validate().is_ok(), "status {status} should be valid");
635        }
636    }
637
638    #[test]
639    fn validate_rejects_updated_at_before_created_at() {
640        let issue = Issue {
641            id: "X-1".to_string(),
642            title: "Test".to_string(),
643            issue_type: "task".to_string(),
644            status: "open".to_string(),
645            created_at: ts("2025-01-02T00:00:00Z"),
646            updated_at: ts("2025-01-01T00:00:00Z"),
647            ..Default::default()
648        };
649
650        let err = issue.validate().unwrap_err();
651        assert!(
652            err.to_string()
653                .contains("updated_at cannot be earlier than created_at")
654        );
655    }
656
657    #[test]
658    fn validate_accepts_equal_created_and_updated_timestamps() {
659        let issue = Issue {
660            id: "X-1".to_string(),
661            title: "Test".to_string(),
662            issue_type: "task".to_string(),
663            status: "open".to_string(),
664            created_at: ts("2025-01-01T00:00:00Z"),
665            updated_at: ts("2025-01-01T00:00:00Z"),
666            ..Default::default()
667        };
668
669        assert!(issue.validate().is_ok());
670    }
671
672    #[test]
673    fn validate_rejects_closed_at_before_created_at() {
674        let issue = Issue {
675            id: "X-1".to_string(),
676            title: "Test".to_string(),
677            issue_type: "task".to_string(),
678            status: "closed".to_string(),
679            created_at: ts("2025-01-02T00:00:00Z"),
680            closed_at: ts("2025-01-01T00:00:00Z"),
681            ..Default::default()
682        };
683
684        let err = issue.validate().unwrap_err();
685        assert!(
686            err.to_string()
687                .contains("closed_at cannot be earlier than created_at")
688        );
689    }
690
691    #[test]
692    fn validate_accepts_equal_created_and_closed_timestamps() {
693        let issue = Issue {
694            id: "X-1".to_string(),
695            title: "Test".to_string(),
696            issue_type: "task".to_string(),
697            status: "closed".to_string(),
698            created_at: ts("2025-01-01T00:00:00Z"),
699            closed_at: ts("2025-01-01T00:00:00Z"),
700            ..Default::default()
701        };
702
703        assert!(issue.validate().is_ok());
704    }
705
706    // -- Sprint tests --
707
708    #[test]
709    fn sprint_is_active_at_within_range() {
710        let sprint = Sprint {
711            id: "s1".to_string(),
712            name: "Sprint 1".to_string(),
713            start_date: Some("2026-01-01T00:00:00Z".parse().unwrap()),
714            end_date: Some("2026-01-14T00:00:00Z".parse().unwrap()),
715            bead_ids: Vec::new(),
716        };
717        let mid: DateTime<Utc> = "2026-01-07T12:00:00Z".parse().unwrap();
718        assert!(sprint.is_active_at(mid));
719    }
720
721    #[test]
722    fn sprint_not_active_outside_range() {
723        let sprint = Sprint {
724            id: "s1".to_string(),
725            name: "Sprint 1".to_string(),
726            start_date: Some("2026-01-01T00:00:00Z".parse().unwrap()),
727            end_date: Some("2026-01-14T00:00:00Z".parse().unwrap()),
728            bead_ids: Vec::new(),
729        };
730        let before: DateTime<Utc> = "2025-12-31T00:00:00Z".parse().unwrap();
731        let after: DateTime<Utc> = "2026-01-15T00:00:00Z".parse().unwrap();
732        assert!(!sprint.is_active_at(before));
733        assert!(!sprint.is_active_at(after));
734    }
735
736    #[test]
737    fn sprint_not_active_without_dates() {
738        let sprint = Sprint {
739            start_date: None,
740            end_date: None,
741            ..Default::default()
742        };
743        let now: DateTime<Utc> = "2026-01-07T00:00:00Z".parse().unwrap();
744        assert!(!sprint.is_active_at(now));
745    }
746
747    #[test]
748    fn sprint_active_at_boundary() {
749        let sprint = Sprint {
750            start_date: Some("2026-01-01T00:00:00Z".parse().unwrap()),
751            end_date: Some("2026-01-14T00:00:00Z".parse().unwrap()),
752            ..Default::default()
753        };
754        let at_start: DateTime<Utc> = "2026-01-01T00:00:00Z".parse().unwrap();
755        let at_end: DateTime<Utc> = "2026-01-14T00:00:00Z".parse().unwrap();
756        assert!(sprint.is_active_at(at_start), "active at start boundary");
757        assert!(sprint.is_active_at(at_end), "active at end boundary");
758    }
759
760    // -- Serde round-trip --
761
762    #[test]
763    fn issue_deserializes_with_defaults() {
764        let json = r#"{"id":"X-1","title":"Test"}"#;
765        let issue: Issue = serde_json::from_str(json).unwrap();
766        assert_eq!(issue.id, "X-1");
767        assert_eq!(issue.priority, 3); // default
768        assert_eq!(issue.status, "");
769        assert!(issue.labels.is_empty());
770        assert!(issue.dependencies.is_empty());
771    }
772
773    #[test]
774    fn dependency_deserializes_type_field() {
775        let json = r#"{"issue_id":"A","depends_on_id":"B","type":"blocks"}"#;
776        let dep: Dependency = serde_json::from_str(json).unwrap();
777        assert_eq!(dep.dep_type, "blocks");
778        assert!(dep.is_blocking());
779    }
780}