spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use crate::domain::Section;

pub fn extract_title(relative_path: &str, body: &str) -> String {
    body.lines()
        .find_map(|line| {
            line.strip_prefix("# ")
                .map(|value| value.trim().to_string())
        })
        .or_else(|| {
            relative_path
                .rsplit('/')
                .next()
                .map(|name| name.trim_end_matches(".md").to_string())
        })
        .unwrap_or_else(|| "Untitled".to_string())
}

pub fn extract_sections(body: &str) -> Vec<Section> {
    let mut sections = Vec::new();
    let mut current_heading: Option<String> = None;
    let mut current_level = 0usize;
    let mut buffer: Vec<String> = Vec::new();
    let mut active_fence: Option<&str> = None;

    for line in body.lines() {
        let trimmed = line.trim_start();
        let fence = if trimmed.starts_with("```") {
            Some("```")
        } else if trimmed.starts_with("~~~") {
            Some("~~~")
        } else {
            None
        };
        if let Some(fence) = fence {
            active_fence = match active_fence {
                Some(current) if current == fence => None,
                None => Some(fence),
                Some(current) => Some(current),
            };
            buffer.push(line.to_string());
            continue;
        }
        if active_fence.is_none() && trimmed.starts_with('#') {
            let level = trimmed.chars().take_while(|ch| *ch == '#').count();
            if level > 0 && trimmed.chars().nth(level) == Some(' ') {
                if !buffer.is_empty() || current_heading.is_some() {
                    sections.push(Section {
                        heading: current_heading.clone(),
                        level: current_level,
                        content: buffer.join("\n").trim().to_string(),
                    });
                }
                current_heading = Some(trimmed[level + 1..].trim().to_string());
                current_level = level;
                buffer.clear();
                continue;
            }
        }
        buffer.push(line.to_string());
    }

    if !buffer.is_empty() || current_heading.is_some() {
        sections.push(Section {
            heading: current_heading,
            level: current_level,
            content: buffer.join("\n").trim().to_string(),
        });
    }

    if sections.is_empty() {
        sections.push(Section {
            heading: None,
            level: 0,
            content: body.trim().to_string(),
        });
    }

    sections
}