1use std::path::{Component, Path};
15
16pub const HUB_README: &str = "readme";
18pub const HUB_CHANGELOG: &str = "changelog";
20pub const HUB_ROADMAP: &str = "roadmap";
22pub const HUB_BACKLOG: &str = "backlog";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ProjectHub {
28 Readme,
30 Changelog,
32 Roadmap,
34 Backlog,
36}
37
38impl ProjectHub {
39 pub fn node_id(self) -> &'static str {
41 match self {
42 Self::Readme => HUB_README,
43 Self::Changelog => HUB_CHANGELOG,
44 Self::Roadmap => HUB_ROADMAP,
45 Self::Backlog => HUB_BACKLOG,
46 }
47 }
48
49 pub fn default_title(self) -> &'static str {
51 match self {
52 Self::Readme => "README",
53 Self::Changelog => "Changelog",
54 Self::Roadmap => "Roadmap",
55 Self::Backlog => "Backlog",
56 }
57 }
58
59 pub fn aliases(self) -> &'static [&'static str] {
61 match self {
62 Self::Readme => &["readme", "hub", "home"],
63 Self::Changelog => &[
64 "changelog",
65 "change-log",
66 "changes",
67 "history",
68 "releases",
69 "release-notes",
70 "keepachangelog",
71 "semver",
72 "versions",
73 "unreleased",
74 ],
75 Self::Roadmap => &["roadmap", "milestones", "plan", "future"],
76 Self::Backlog => &["backlog", "todo", "work-queue", "kanban"],
77 }
78 }
79}
80
81pub fn detect_project_hub(rel: &Path) -> Option<ProjectHub> {
83 let comps: Vec<_> = rel
84 .components()
85 .filter(|c| matches!(c, Component::Normal(_)))
86 .collect();
87 if comps.len() != 1 {
88 return None;
89 }
90 let name = rel.file_name()?.to_str()?;
91 let lower = name.to_ascii_lowercase();
92 match lower.as_str() {
93 "readme.md" => Some(ProjectHub::Readme),
94 "changelog.md" | "changes.md" | "history.md" => Some(ProjectHub::Changelog),
95 "roadmap.md" => Some(ProjectHub::Roadmap),
96 "backlog.md" => Some(ProjectHub::Backlog),
97 _ => None,
98 }
99}
100
101pub fn is_hub_node_id(id: &str) -> bool {
103 matches!(
104 id,
105 HUB_README | HUB_CHANGELOG | HUB_ROADMAP | HUB_BACKLOG
106 )
107}
108
109pub fn is_release_intent(tokens: &[String]) -> bool {
111 tokens.iter().any(|t| {
112 matches!(
113 t.as_str(),
114 "changelog"
115 | "changelogs"
116 | "release"
117 | "releases"
118 | "released"
119 | "version"
120 | "versions"
121 | "semver"
122 | "shipped"
123 | "unreleased"
124 | "history"
125 | "breaking"
126 | "migration"
127 | "upgrade"
128 | "v1"
129 | "v2"
130 | "v3"
131 ) || (t.starts_with('v')
132 && t.len() >= 2
133 && t[1..].chars().next().is_some_and(|c| c.is_ascii_digit()))
134 || (t.chars().all(|c| c.is_ascii_digit() || c == '.')
135 && t.contains('.')
136 && t.len() >= 3)
137 })
138}
139
140pub fn is_planning_intent(tokens: &[String]) -> bool {
142 tokens.iter().any(|t| {
143 matches!(
144 t.as_str(),
145 "roadmap"
146 | "backlog"
147 | "priority"
148 | "priorities"
149 | "prioritize"
150 | "epic"
151 | "epics"
152 | "story"
153 | "stories"
154 | "sprint"
155 | "kanban"
156 | "milestone"
157 | "milestones"
158 | "todo"
159 | "todos"
160 | "status"
161 | "plan"
162 | "planning"
163 | "next"
164 )
165 })
166}
167
168pub fn changelog_latest_heading(body: &str) -> Option<String> {
170 for line in body.lines() {
171 let t = line.trim();
172 if t.starts_with("## [") || t.starts_with("##[") {
173 let s = t.trim_start_matches('#').trim();
174 if !s.is_empty() {
175 return Some(s.to_string());
176 }
177 }
178 if t.eq_ignore_ascii_case("## unreleased") || t.eq_ignore_ascii_case("## [unreleased]") {
180 return Some(t.trim_start_matches('#').trim().to_string());
181 }
182 }
183 None
184}
185
186pub fn changelog_version_aliases(body: &str, max: usize) -> Vec<String> {
188 let mut out = Vec::new();
189 for line in body.lines() {
190 let t = line.trim();
191 let rest = if let Some(r) = t.strip_prefix("## [") {
192 r
193 } else if let Some(r) = t.strip_prefix("##[") {
194 r
195 } else {
196 continue;
197 };
198 let ver = rest.split(']').next().unwrap_or("").trim();
199 if ver.is_empty() || ver.eq_ignore_ascii_case("unreleased") {
200 continue;
201 }
202 if ver.len() >= 3 && !out.iter().any(|x: &String| x.eq_ignore_ascii_case(ver)) {
204 out.push(ver.to_string());
205 }
206 if out.len() >= max {
207 break;
208 }
209 }
210 out
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::path::Path;
217
218 #[test]
219 fn detects_root_hubs() {
220 assert_eq!(
221 detect_project_hub(Path::new("CHANGELOG.md")),
222 Some(ProjectHub::Changelog)
223 );
224 assert_eq!(
225 detect_project_hub(Path::new("changelog.md")),
226 Some(ProjectHub::Changelog)
227 );
228 assert_eq!(
229 detect_project_hub(Path::new("HISTORY.md")),
230 Some(ProjectHub::Changelog)
231 );
232 assert_eq!(
233 detect_project_hub(Path::new("README.md")),
234 Some(ProjectHub::Readme)
235 );
236 assert_eq!(
237 detect_project_hub(Path::new("ROADMAP.md")),
238 Some(ProjectHub::Roadmap)
239 );
240 assert_eq!(detect_project_hub(Path::new("docs/CHANGELOG.md")), None);
241 }
242
243 #[test]
244 fn parses_keep_a_changelog_heading() {
245 let body = "# Changelog\n\n## [0.3.14] - 2026-07-31\n\n### Added\n- foo\n";
246 assert_eq!(
247 changelog_latest_heading(body).as_deref(),
248 Some("[0.3.14] - 2026-07-31")
249 );
250 assert_eq!(
251 changelog_version_aliases(body, 3),
252 vec!["0.3.14".to_string()]
253 );
254 }
255
256 #[test]
257 fn release_intent_tokens() {
258 assert!(is_release_intent(&[
259 "what".into(),
260 "shipped".into(),
261 "0.3.14".into()
262 ]));
263 assert!(is_release_intent(&["changelog".into()]));
264 assert!(!is_release_intent(&["raft".into(), "consensus".into()]));
265 }
266}