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