glacier-web-cli 0.5.0

Command-line tool for creating and running Glacier applications.
//! Glacier's guides, built into the binary, for tools to search and read
//! (the MCP's `search_docs` and `get_syntax_reference`). They come from
//! `guides/expanded/`, which the guides' generator writes with each
//! example's code in place of its `include=` line.

use serde_json::{Value, json};

/// Every guide: its name and Markdown.
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")),
];

/// A guide by name (`glacier_language`, `models`, …).
pub fn guide(name: &str) -> Option<&'static str> {
    GUIDES
        .iter()
        .find(|(n, _)| *n == name)
        .map(|(_, text)| *text)
}

/// A guide's title, from its front matter, else its name.
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)
}

/// The guide sections that best match `query`: every word of the query
/// scored by how often it appears, a heading match counting extra. Each
/// result names its guide and section and carries the section's text.
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;
            }
            // Sections matching every word come first.
            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()
}

/// A guide's `##` sections (the text before the first one under its title).
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")
        );
    }
}