1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
// Roadmap and RoadmapItem method implementations
//
// Included by roadmap.rs — shares parent scope, no `use` imports needed.
impl Roadmap {
/// Create a new empty roadmap
pub fn new(github_repo: Option<String>) -> Self {
Self {
roadmap_version: ROADMAP_VERSION.to_string(),
github_enabled: true,
github_repo,
roadmap: Vec::new(),
}
}
/// Find item by ID with fuzzy matching
///
/// Matching strategy (in order):
/// 1. Exact match (case-sensitive)
/// 2. Case-insensitive match
/// 3. Prefix match (starts_with, case-insensitive)
/// 4. Contains match (partial, case-insensitive)
///
/// This allows users to type:
/// - Full ID: "Continue unwrap elimination: 27 more unwraps..."
/// - Partial: "unwrap elimination"
/// - Short: "unwrap"
/// - Any case: "UNWRAP"
pub fn find_item(&self, id: &str) -> Option<&RoadmapItem> {
let id_lower = id.to_lowercase();
// 1. Exact match (fastest, case-sensitive)
if let Some(item) = self.roadmap.iter().find(|item| item.id == id) {
return Some(item);
}
// 2. Case-insensitive exact match
if let Some(item) = self
.roadmap
.iter()
.find(|item| item.id.to_lowercase() == id_lower)
{
return Some(item);
}
// 3. Prefix match (starts_with)
if let Some(item) = self
.roadmap
.iter()
.find(|item| item.id.to_lowercase().starts_with(&id_lower))
{
return Some(item);
}
// 4. Contains match (last resort)
self.roadmap
.iter()
.find(|item| item.id.to_lowercase().contains(&id_lower))
}
/// Find item by GitHub issue number
pub fn find_item_by_github_issue(&self, issue: u64) -> Option<&RoadmapItem> {
self.roadmap
.iter()
.find(|item| item.github_issue == Some(issue))
}
/// Find item by ID (mutable)
pub fn find_item_mut(&mut self, id: &str) -> Option<&mut RoadmapItem> {
self.roadmap.iter_mut().find(|item| item.id == id)
}
/// Add or update item
pub fn upsert_item(&mut self, item: RoadmapItem) {
if let Some(existing) = self.find_item_mut(&item.id) {
*existing = item;
} else {
self.roadmap.push(item);
}
}
/// Remove item by ID
pub fn remove_item(&mut self, id: &str) -> Option<RoadmapItem> {
if let Some(pos) = self.roadmap.iter().position(|item| item.id == id) {
Some(self.roadmap.remove(pos))
} else {
None
}
}
/// Get items without GitHub sync
pub fn yaml_only_items(&self) -> Vec<&RoadmapItem> {
self.roadmap
.iter()
.filter(|item| item.github_issue.is_none())
.collect()
}
/// Get epic items
pub fn epic_items(&self) -> Vec<&RoadmapItem> {
self.roadmap
.iter()
.filter(|item| item.item_type == ItemType::Epic)
.collect()
}
}
impl RoadmapItem {
/// Create a new roadmap item
pub fn new(id: String, title: String) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
id,
github_issue: None,
item_type: ItemType::Task,
title,
status: ItemStatus::Planned,
priority: Priority::Medium,
assigned_to: None,
created: now.clone(),
updated: now,
spec: None,
acceptance_criteria: Vec::new(),
phases: Vec::new(),
subtasks: Vec::new(),
estimated_effort: None,
labels: Vec::new(),
notes: None,
}
}
/// Create from GitHub issue
pub fn from_github_issue(issue_number: u64, title: String) -> Self {
let id = format!("GH-{}", issue_number);
let mut item = Self::new(id, title);
item.github_issue = Some(issue_number);
item
}
/// Calculate overall completion percentage
pub fn completion_percentage(&self) -> u8 {
if !self.subtasks.is_empty() {
// Epic: weighted average of subtasks
let total: u16 = self.subtasks.iter().map(|st| st.completion as u16).sum();
(total / self.subtasks.len() as u16) as u8
} else if !self.phases.is_empty() {
// Multi-phase: weighted average of phases
let total: u16 = self.phases.iter().map(|p| p.completion as u16).sum();
(total / self.phases.len() as u16) as u8
} else if !self.acceptance_criteria.is_empty() {
// Count completed criteria (basic heuristic)
0 // TODO: Track individual criteria completion
} else {
match self.status {
ItemStatus::Planned => 0,
ItemStatus::InProgress => 50,
ItemStatus::Review => 90,
ItemStatus::Completed => 100,
ItemStatus::Cancelled => 0,
ItemStatus::Blocked => 0,
}
}
}
/// Check if item is synced with GitHub
pub fn is_github_synced(&self) -> bool {
self.github_issue.is_some()
}
}