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 — never required for a healthy brain):
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 when the files exist. Absence is fine:
12//! doctor tips are info-level only. Rustbrain never invents changelog entries or
13//! plan tasks — it only densifies what is already on disk after `sync`.
14
15use std::path::{Component, Path};
16
17/// Stable node id for the root README hub.
18pub const HUB_README: &str = "readme";
19/// Stable node id for the root CHANGELOG hub (Keep a Changelog).
20pub const HUB_CHANGELOG: &str = "changelog";
21/// Stable node id for an optional root ROADMAP.
22pub const HUB_ROADMAP: &str = "roadmap";
23/// Stable node id for an optional root BACKLOG.
24pub const HUB_BACKLOG: &str = "backlog";
25
26/// Which hub (if any) a workspace-relative path maps to.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ProjectHub {
29    /// Root README.md
30    Readme,
31    /// Root CHANGELOG.md / CHANGES.md / HISTORY.md
32    Changelog,
33    /// Root ROADMAP.md
34    Roadmap,
35    /// Root BACKLOG.md
36    Backlog,
37}
38
39impl ProjectHub {
40    /// Stable graph / FTS node id.
41    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    /// Default display title when the file has no H1.
51    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    /// Extra resolution aliases (lowercased by alias storage).
61    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
82/// Detect a root-level project hub from a workspace-relative path.
83pub 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
102/// True when `id` is a recognized project hub node.
103pub fn is_hub_node_id(id: &str) -> bool {
104    matches!(
105        id,
106        HUB_README | HUB_CHANGELOG | HUB_ROADMAP | HUB_BACKLOG
107    )
108}
109
110/// True for release / history / version-oriented agent prompts.
111pub 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
141/// True for planning / prioritization agent prompts (roadmap, backlog, kanban-ish).
142pub 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
169/// Keep a Changelog: first `## […]` heading as a short summary (latest section).
170pub 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        // Also accept ## Unreleased
180        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
187/// Version labels from Keep a Changelog headings, newest first, capped.
188pub 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        // Skip dates accidentally captured
204        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}