Skip to main content

formal_ai/seed/
projects.rs

1//! Curated GitHub-project registry loaded from `data/seed/projects.lino`.
2//!
3//! Each `project_*` entry encodes one open-source project from the promoted
4//! repository organizations. The entry carries
5//! everything that project lookup call sites need to
6//! describe the project at any summarization length:
7//!
8//! - identity metadata (`org`, `name`, `display_name`, `url`),
9//! - the primary programming `language`,
10//! - a coarse `category` and a list of `aliases` (pipe-delimited),
11//! - a 1–5 word `topic` used by the topic-mode summarizer,
12//! - any number of `statement "..."` blocks, each tagged with a `kind`
13//!   (purpose, feature, language, identity, install, example, misc) and a
14//!   numeric `weight` (0–100) so the summarizer can keep the highest-weighted
15//!   statements when the caller asks for a tighter response.
16//!
17//! Optional `localized "<lang>"` blocks override `display_name` and the
18//! statement list per language. Empty fields fall back to the outer English
19//! values so a localization only needs to differ where it actually does.
20//!
21//! `project_lookup` treats this registry as the authoritative source for
22//! promoted project answers: when the prompt asks about `Hive Mind` /
23//! `hive-mind` (or any other registered project), the answer starts from the
24//! curated statements here before any fallback web search.
25
26use super::parser::{parse_lino, split_pipe_list, LinoNode};
27use super::PROJECTS_LINO;
28
29/// A localized variant of a single project statement.
30#[derive(Debug, Clone, Default)]
31pub struct ProjectStatement {
32    pub text: String,
33    pub kind: String,
34    pub weight: u8,
35}
36
37impl ProjectStatement {
38    fn parse(node: &LinoNode) -> Option<Self> {
39        if node.name != "statement" {
40            return None;
41        }
42        let text = node.id.trim().to_string();
43        if text.is_empty() {
44            return None;
45        }
46        let kind = node.find_child_value("kind").trim().to_string();
47        let weight = node
48            .find_child_value("weight")
49            .trim()
50            .parse::<u8>()
51            .unwrap_or(50);
52        Some(Self { text, kind, weight })
53    }
54}
55
56/// A per-language override (display name + replacement statements).
57#[derive(Debug, Clone, Default)]
58pub struct LocalizedProject {
59    pub language: String,
60    pub display_name: String,
61    pub statements: Vec<ProjectStatement>,
62}
63
64/// A single curated project entry from `data/seed/projects.lino`.
65#[derive(Debug, Clone)]
66pub struct ProjectRecord {
67    pub slug: String,
68    pub org: String,
69    pub name: String,
70    pub display_name: String,
71    pub url: String,
72    pub language: String,
73    pub category: String,
74    pub aliases: Vec<String>,
75    pub topic: String,
76    pub statements: Vec<ProjectStatement>,
77    pub localized: Vec<LocalizedProject>,
78}
79
80impl ProjectRecord {
81    /// `org/name` repository slug as displayed in URLs and prompts.
82    #[must_use]
83    pub fn repo_slug(&self) -> String {
84        format!("{}/{}", self.org, self.name)
85    }
86
87    /// Pick the localized variant matching `language`, falling back to the
88    /// explicit English variant when one is defined, otherwise `None`.
89    #[must_use]
90    pub fn localized_for(&self, language: &str) -> Option<&LocalizedProject> {
91        self.localized
92            .iter()
93            .find(|loc| loc.language == language)
94            .or_else(|| self.localized.iter().find(|loc| loc.language == "en"))
95    }
96
97    /// Statements for `language` — either the localized override (when
98    /// present and non-empty) or the default English statements.
99    #[must_use]
100    pub fn statements_for(&self, language: &str) -> &[ProjectStatement] {
101        self.localized_for(language)
102            .map(|loc| loc.statements.as_slice())
103            .filter(|stmts| !stmts.is_empty())
104            .unwrap_or(self.statements.as_slice())
105    }
106
107    /// Localized display name when defined, otherwise the default.
108    #[must_use]
109    pub fn display_name_for(&self, language: &str) -> &str {
110        self.localized_for(language)
111            .map(|loc| loc.display_name.as_str())
112            .filter(|name| !name.is_empty())
113            .unwrap_or(self.display_name.as_str())
114    }
115
116    /// Localized topic (1–5 word) label when defined, otherwise the default.
117    #[must_use]
118    pub const fn topic_for(&self, language: &str) -> &str {
119        // Statements may move, but the topic stays per-record — localized
120        // overrides only differ for prose, not for the bare topic label.
121        let _ = language;
122        if self.topic.is_empty() {
123            self.display_name.as_str()
124        } else {
125            self.topic.as_str()
126        }
127    }
128
129    /// Case-insensitive alias match against `term`. Aliases are normalized
130    /// (lowercased, hyphens/underscores -> spaces, whitespace collapsed)
131    /// at load time so substring comparison is straightforward.
132    #[must_use]
133    pub fn matches_alias(&self, term: &str) -> bool {
134        let normalized = normalize_alias(term);
135        if normalized.is_empty() {
136            return false;
137        }
138        self.aliases.iter().any(|alias| alias == &normalized)
139    }
140}
141
142/// Convenience wrapper around the loaded registry.
143#[derive(Debug, Clone, Default)]
144pub struct ProjectsRegistry {
145    pub projects: Vec<ProjectRecord>,
146}
147
148impl ProjectsRegistry {
149    /// Look up a project whose `slug` (`project_*` identifier) matches `slug`.
150    #[must_use]
151    pub fn by_slug(&self, slug: &str) -> Option<&ProjectRecord> {
152        self.projects.iter().find(|p| p.slug == slug)
153    }
154
155    /// Look up a project by alias match (term in `aliases` or display name).
156    #[must_use]
157    pub fn by_alias(&self, term: &str) -> Option<&ProjectRecord> {
158        if term.is_empty() {
159            return None;
160        }
161        let normalized = normalize_alias(term);
162        if normalized.is_empty() {
163            return None;
164        }
165        self.projects.iter().find(|p| {
166            p.aliases.iter().any(|alias| alias == &normalized)
167                || normalize_alias(&p.display_name) == normalized
168                || normalize_alias(&p.name) == normalized
169        })
170    }
171
172    /// Filter projects by organization (`link-assistant`, `link-foundation`, ...).
173    #[must_use]
174    pub fn by_org<'a>(&'a self, org: &str) -> Vec<&'a ProjectRecord> {
175        self.projects.iter().filter(|p| p.org == org).collect()
176    }
177}
178
179/// Load and parse `data/seed/projects.lino` into the in-memory registry.
180#[must_use]
181pub fn projects_registry() -> ProjectsRegistry {
182    let tree = parse_lino(PROJECTS_LINO);
183    let entries: &[LinoNode] = if tree.name.is_empty() {
184        tree.children.as_slice()
185    } else {
186        std::slice::from_ref(&tree)
187    };
188    let mut out = Vec::new();
189    for entry in entries {
190        if !entry.name.starts_with("project_") {
191            continue;
192        }
193        let aliases = split_pipe_list(entry.find_child_value("aliases"))
194            .into_iter()
195            .map(|s| normalize_alias(&s))
196            .filter(|s| !s.is_empty())
197            .collect();
198        let statements: Vec<ProjectStatement> = entry
199            .children
200            .iter()
201            .filter_map(ProjectStatement::parse)
202            .collect();
203        let localized = entry
204            .children
205            .iter()
206            .filter(|c| c.name == "localized")
207            .map(|child| {
208                let language = child.id.clone();
209                let display_name = child.find_child_value("display_name").to_string();
210                let stmts: Vec<ProjectStatement> = child
211                    .children
212                    .iter()
213                    .filter_map(ProjectStatement::parse)
214                    .collect();
215                LocalizedProject {
216                    language,
217                    display_name,
218                    statements: stmts,
219                }
220            })
221            .filter(|loc| !loc.language.is_empty())
222            .collect();
223        out.push(ProjectRecord {
224            slug: entry.name.clone(),
225            org: entry.find_child_value("org").to_string(),
226            name: entry.find_child_value("name").to_string(),
227            display_name: entry.find_child_value("display_name").to_string(),
228            url: entry.find_child_value("url").to_string(),
229            language: entry.find_child_value("language").to_string(),
230            category: entry.find_child_value("category").to_string(),
231            aliases,
232            topic: entry.find_child_value("topic").to_string(),
233            statements,
234            localized,
235        });
236    }
237    ProjectsRegistry { projects: out }
238}
239
240/// Normalize an alias for case-insensitive matching: lowercased, hyphens and
241/// underscores replaced with spaces, collapse whitespace.
242fn normalize_alias(value: &str) -> String {
243    value
244        .to_lowercase()
245        .replace(['-', '_'], " ")
246        .split_whitespace()
247        .collect::<Vec<_>>()
248        .join(" ")
249}