Skip to main content

rustbrain_core/
hubs.rs

1//! Recognized **project hub** Markdown files at the workspace root.
2//!
3//! Rust / open-source norms:
4//! - [`README.md`](https://doc.rust-lang.org/cargo/guide/project-layout.html) — project front door
5//! - [`CHANGELOG.md`](https://keepachangelog.com/) — Keep a Changelog + SemVer ship history
6//!
7//! Optional planning surfaces (agent HITL, not required):
8//! - `ROADMAP.md`, `BACKLOG.md` — priorities / unshipped work
9//!
10//! These are indexed under **stable node ids** (`readme`, `changelog`, …) so agents
11//! can query and context-pack them reliably. Rustbrain never invents changelog
12//! entries — it only surfaces what is already on disk after `sync`.
13
14use std::path::{Component, Path};
15
16/// Stable node id for the root README hub.
17pub const HUB_README: &str = "readme";
18/// Stable node id for the root CHANGELOG hub (Keep a Changelog).
19pub const HUB_CHANGELOG: &str = "changelog";
20/// Stable node id for an optional root ROADMAP.
21pub const HUB_ROADMAP: &str = "roadmap";
22/// Stable node id for an optional root BACKLOG.
23pub const HUB_BACKLOG: &str = "backlog";
24
25/// Which hub (if any) a workspace-relative path maps to.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ProjectHub {
28    /// Root README.md
29    Readme,
30    /// Root CHANGELOG.md / CHANGES.md / HISTORY.md
31    Changelog,
32    /// Root ROADMAP.md
33    Roadmap,
34    /// Root BACKLOG.md
35    Backlog,
36}
37
38impl ProjectHub {
39    /// Stable graph / FTS node id.
40    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    /// Default display title when the file has no H1.
50    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    /// Extra resolution aliases (lowercased by alias storage).
60    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
81/// Detect a root-level project hub from a workspace-relative path.
82pub 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
101/// True when `id` is a recognized project hub node.
102pub fn is_hub_node_id(id: &str) -> bool {
103    matches!(
104        id,
105        HUB_README | HUB_CHANGELOG | HUB_ROADMAP | HUB_BACKLOG
106    )
107}
108
109/// True for release / history / version-oriented agent prompts.
110pub 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
140/// True for planning / prioritization agent prompts (roadmap, backlog, kanban-ish).
141pub 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
168/// Keep a Changelog: first `## […]` heading as a short summary (latest section).
169pub 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        // Also accept ## Unreleased
179        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
186/// Version labels from Keep a Changelog headings, newest first, capped.
187pub 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        // Skip dates accidentally captured
203        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}