conpub 0.1.0

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use super::models::{Document, HierarchyEntry, ResolvedConfig};
use crate::support::*;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

pub(crate) fn validate_directory_index_conflicts(
    resolved: &ResolvedConfig,
    documents: &[Document],
) -> AppResult<()> {
    build_directory_index_map(resolved, documents).map(|_| ())
}

pub(crate) fn build_hierarchy(
    resolved: &ResolvedConfig,
    selected_documents: &[Document],
    all_documents: &[Document],
) -> AppResult<Vec<HierarchyEntry>> {
    let index_by_dir = build_directory_index_map(resolved, all_documents)?;
    let all_by_path = all_documents
        .iter()
        .map(|document| (document.path.as_str(), document))
        .collect::<HashMap<_, _>>();
    let mut entries = HashMap::new();

    for document in selected_documents {
        add_hierarchy_entry(
            resolved,
            &document.path,
            &all_by_path,
            &index_by_dir,
            &mut entries,
        )?;
    }

    let mut entries = entries.into_values().collect::<Vec<_>>();
    entries.sort_by(|a, b| {
        a.order
            .cmp(&b.order)
            .then(a.document.path.cmp(&b.document.path))
    });
    Ok(entries)
}

pub(crate) fn add_hierarchy_entry(
    resolved: &ResolvedConfig,
    path: &str,
    all_by_path: &HashMap<&str, &Document>,
    index_by_dir: &HashMap<String, String>,
    entries: &mut HashMap<String, HierarchyEntry>,
) -> AppResult<()> {
    if entries.contains_key(path) {
        return Ok(());
    }

    let document = all_by_path.get(path).ok_or_else(|| {
        AppError::new(
            "DOCUMENT_NOT_FOUND",
            format!("document is not part of the bound source: {path}"),
        )
    })?;
    let (parent_path, order) = hierarchy_parent_and_order(resolved, document, index_by_dir)?;

    if let Some(parent_path) = &parent_path {
        add_hierarchy_entry(resolved, parent_path, all_by_path, index_by_dir, entries)?;
    }

    entries.insert(
        path.to_string(),
        HierarchyEntry {
            document: (*document).clone(),
            parent_path,
            order,
        },
    );
    Ok(())
}

pub(crate) fn build_directory_index_map(
    resolved: &ResolvedConfig,
    documents: &[Document],
) -> AppResult<HashMap<String, String>> {
    let mut index_by_dir = HashMap::new();

    for document in documents {
        if !is_index_document(&document.path) {
            continue;
        }

        let source_rel = source_relative_path(resolved, document)?;
        let dir = source_relative_parent_dir(&source_rel);
        if let Some(previous) = index_by_dir.insert(dir.clone(), document.path.clone()) {
            return Err(AppError::new(
                "HIERARCHY_INDEX_CONFLICT",
                format!(
                    "multiple directory index documents for source-relative directory `{}`: {} and {}",
                    display_source_dir(&dir),
                    previous,
                    document.path
                ),
            ));
        }
    }

    Ok(index_by_dir)
}

pub(crate) fn hierarchy_parent_and_order(
    resolved: &ResolvedConfig,
    document: &Document,
    index_by_dir: &HashMap<String, String>,
) -> AppResult<(Option<String>, usize)> {
    let source_rel = source_relative_path(resolved, document)?;
    let dir = source_relative_parent_dir(&source_rel);
    let depth = directory_depth(&dir);

    if is_index_document(&document.path) {
        if dir.is_empty() {
            return Ok((None, 0));
        }

        let parent_dir = parent_dir_string(&dir);
        let parent_path = if parent_dir.is_empty() {
            None
        } else {
            Some(required_directory_index(
                &parent_dir,
                &document.path,
                index_by_dir,
            )?)
        };
        return Ok((parent_path, depth));
    }

    if dir.is_empty() {
        return Ok((None, 1));
    }

    Ok((
        Some(required_directory_index(
            &dir,
            &document.path,
            index_by_dir,
        )?),
        depth + 1,
    ))
}

pub(crate) fn required_directory_index(
    dir: &str,
    child_path: &str,
    index_by_dir: &HashMap<String, String>,
) -> AppResult<String> {
    index_by_dir.get(dir).cloned().ok_or_else(|| {
        AppError::new(
            "HIERARCHY_INDEX_MISSING",
            format!(
                "missing _index.md or index.md for source-relative directory `{}` required by {}",
                display_source_dir(dir),
                child_path
            ),
        )
    })
}

pub(crate) fn source_relative_path(
    resolved: &ResolvedConfig,
    document: &Document,
) -> AppResult<PathBuf> {
    let document_path = PathBuf::from(&document.path);
    let source = PathBuf::from(&resolved.source);

    if resolved.source.is_empty() || resolved.source == "." {
        return Ok(document_path);
    }

    document_path
        .strip_prefix(&source)
        .map(Path::to_path_buf)
        .map_err(|_| {
            AppError::new(
                "PATH_OUTSIDE_SOURCE",
                format!(
                    "document is outside bound source {}: {}",
                    resolved.source, document.path
                ),
            )
        })
}

pub(crate) fn source_relative_parent_dir(path: &Path) -> String {
    path.parent()
        .map(path_to_slash)
        .filter(|dir| !dir.is_empty())
        .unwrap_or_default()
}

pub(crate) fn parent_dir_string(dir: &str) -> String {
    Path::new(dir)
        .parent()
        .map(path_to_slash)
        .filter(|parent| !parent.is_empty())
        .unwrap_or_default()
}

pub(crate) fn directory_depth(dir: &str) -> usize {
    dir.split('/')
        .filter(|component| !component.is_empty())
        .count()
}

pub(crate) fn display_source_dir(dir: &str) -> &str {
    if dir.is_empty() { "." } else { dir }
}

pub(crate) fn is_index_document(path: &str) -> bool {
    Path::new(path)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .map(|stem| stem == "_index" || stem == "index")
        .unwrap_or(false)
}

pub(crate) fn validate_unique_slugs(documents: &[Document]) -> AppResult<()> {
    let mut slugs = HashMap::new();
    for document in documents {
        let slug = slug_for_path(&document.path);
        if let Some(previous) = slugs.insert(slug.clone(), document.path.clone()) {
            return Err(AppError::new(
                "SLUG_COLLISION",
                format!(
                    "documents produce the same slug `{slug}`: {previous} and {}",
                    document.path
                ),
            ));
        }
    }

    Ok(())
}