conpub 0.1.4

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use crate::domain::canonical_tags;
use crate::support::*;
use std::fs;
use std::path::Path;
use typub_core::ContentFormat;

pub(crate) struct InspectedDocument {
    pub(crate) title: String,
    pub(crate) tags: Vec<String>,
}

pub(crate) fn inspect_document_source(root: &Path, path: &Path) -> AppResult<InspectedDocument> {
    let text = fs::read_to_string(path).map_err(|err| {
        AppError::new(
            "FILE_READ_ERROR",
            format!("failed to read {}: {err}", path.display()),
        )
    })?;
    let format = match path.extension().and_then(|extension| extension.to_str()) {
        Some("md") => ContentFormat::Markdown,
        Some("typ") => ContentFormat::Typst,
        _ => {
            return Err(AppError::new(
                "UNSUPPORTED_DOCUMENT",
                format!("unsupported document source: {}", path.display()),
            ));
        }
    };

    match typub_engine::inspect_source(root, path, format) {
        Ok(inspection) => Ok(InspectedDocument {
            title: inspection
                .title
                .unwrap_or_else(|| fallback_title_from_path(path)),
            tags: canonical_tags(
                inspection.metadata.tags.unwrap_or_default(),
                &format!("source {}", path.display()),
            )?,
        }),
        Err(err) if declares_source_metadata(&text, format) => Err(AppError::new(
            "SOURCE_METADATA_ERROR",
            format!(
                "failed to inspect source metadata in {}: {err:#}",
                path.display()
            ),
        )),
        Err(_) => Ok(InspectedDocument {
            title: fallback_title_from_path(path),
            tags: Vec::new(),
        }),
    }
}

fn declares_source_metadata(text: &str, format: ContentFormat) -> bool {
    match format {
        ContentFormat::Markdown => has_markdown_frontmatter(text),
        ContentFormat::Typst => text.contains("<typub-meta>"),
    }
}

fn has_markdown_frontmatter(text: &str) -> bool {
    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
    let mut lines = text.lines();
    if lines.next().map(trim_frontmatter_line) != Some("---") {
        return false;
    }
    if lines
        .next()
        .is_none_or(|line| trim_frontmatter_line(line).is_empty())
    {
        return false;
    }
    lines.any(|line| matches!(trim_frontmatter_line(line), "---" | "..."))
}

fn trim_frontmatter_line(line: &str) -> &str {
    line.trim_end_matches([' ', '\t', '\r'])
}

fn fallback_title_from_path(path: &Path) -> String {
    path.file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("untitled")
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn markdown_frontmatter_requires_a_complete_nonempty_block() {
        assert!(has_markdown_frontmatter(
            "---\ntags: [rust]\n---\n# Title\n"
        ));
        assert!(has_markdown_frontmatter(
            "---\ntags: [rust]\n...\n# Title\n"
        ));
        assert!(!has_markdown_frontmatter("---\n\nParagraph\n"));
        assert!(!has_markdown_frontmatter("---\nHorizontal rule only\n"));
    }
}