use serde_json::{Value, json};
pub const GUIDES: &[(&str, &str)] = &[
("ai_agents", include_str!("../guides/ai_agents.md")),
("associations", include_str!("../guides/associations.md")),
(
"authentication",
include_str!("../guides/authentication.md"),
),
(
"background_jobs",
include_str!("../guides/background_jobs.md"),
),
("billing", include_str!("../guides/billing.md")),
("caching", include_str!("../guides/caching.md")),
(
"calling_other_services",
include_str!("../guides/calling_other_services.md"),
),
("controllers", include_str!("../guides/controllers.md")),
("file_uploads", include_str!("../guides/file_uploads.md")),
(
"getting_started",
include_str!("../guides/getting_started.md"),
),
(
"glacier_language",
include_str!("../guides/glacier_language.md"),
),
("json_apis", include_str!("../guides/json_apis.md")),
("mail", include_str!("../guides/mail.md")),
("migrations", include_str!("../guides/migrations.md")),
("models", include_str!("../guides/models.md")),
("querying", include_str!("../guides/querying.md")),
("routing", include_str!("../guides/routing.md")),
("testing", include_str!("../guides/testing.md")),
("views", include_str!("../guides/views.md")),
];
pub fn guide(name: &str) -> Option<&'static str> {
GUIDES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, text)| *text)
}
pub fn title(text: &str) -> Option<&str> {
text.lines().find_map(|l| l.strip_prefix("title: "))
}
fn guide_title(name: &str) -> &str {
guide(name).and_then(title).unwrap_or(name)
}
pub fn search(query: &str, limit: usize) -> Vec<Value> {
let words: Vec<String> = query
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|w| w.len() > 1)
.map(str::to_lowercase)
.collect();
if words.is_empty() {
return Vec::new();
}
let mut scored = Vec::new();
for (name, text) in GUIDES {
for (heading, body) in sections(text) {
let lower_body = body.to_lowercase();
let lower_heading = heading.to_lowercase();
let mut score = 0;
let mut matched = 0;
for word in &words {
let hits = lower_body.matches(word.as_str()).count();
let in_heading = lower_heading.contains(word.as_str());
if hits > 0 || in_heading {
matched += 1;
}
score += hits.min(10) + if in_heading { 15 } else { 0 };
}
if matched == 0 {
continue;
}
scored.push((matched, score, *name, heading, body));
}
}
scored.sort_by_key(|a| std::cmp::Reverse((a.0, a.1)));
scored
.into_iter()
.take(limit)
.map(|(_, _, guide, heading, body)| {
json!({
"guide": guide,
"title": guide_title(guide),
"section": heading,
"text": body.trim(),
})
})
.collect()
}
fn sections(text: &str) -> Vec<(String, String)> {
let body = text
.strip_prefix("---")
.and_then(|rest| rest.split_once("\n---").map(|(_, after)| after))
.unwrap_or(text);
let mut out = Vec::new();
let mut heading = String::from("Introduction");
let mut current = String::new();
for line in body.lines() {
if let Some(h) = line.strip_prefix("## ") {
if !current.trim().is_empty() {
out.push((heading.clone(), std::mem::take(&mut current)));
}
heading = h.trim().to_owned();
current.clear();
} else {
current.push_str(line);
current.push('\n');
}
}
if !current.trim().is_empty() {
out.push((heading, current));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guides_carry_their_examples() {
for (name, text) in GUIDES {
assert!(
!text.contains("include="),
"{name}: run the guides' generator to write its examples in"
);
}
assert!(guide("ai_agents").is_some());
assert!(guide("models").unwrap().contains("changeset signup do"));
}
#[test]
fn finds_sections_by_their_words() {
let hits = search("nested attributes", 3);
assert!(!hits.is_empty());
assert!(
hits.iter()
.any(|h| h["text"].as_str().unwrap().contains("nested")),
"{hits:#?}"
);
assert!(search("", 3).is_empty());
assert!(guide("glacier_language").is_some());
assert_eq!(
title(guide("glacier_language").unwrap()),
Some("The .glacier Language")
);
}
}