conpub 0.1.1

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use super::title::title_from_file;
use crate::domain::Document;
use crate::support::*;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::{DirEntry, WalkDir};

pub(crate) fn list_documents(root: &Path, scope: &Path) -> AppResult<Vec<Document>> {
    let mut docs = Vec::new();

    if !scope.exists() {
        return Err(AppError::new(
            "SOURCE_NOT_FOUND",
            format!("source does not exist: {}", scope.display()),
        ));
    }

    for entry in WalkDir::new(scope)
        .into_iter()
        .filter_entry(|entry| !is_ignored(entry))
    {
        let entry = entry.map_err(|err| AppError::new("WALK_ERROR", err.to_string()))?;
        if !entry.file_type().is_file() || !is_supported_doc(entry.path()) {
            continue;
        }

        let rel_path = relative_to(entry.path(), root)?;
        let extension = entry
            .path()
            .extension()
            .and_then(|ext| ext.to_str())
            .unwrap_or("")
            .to_string();

        docs.push(Document {
            title: title_from_file(root, entry.path()),
            path: rel_path,
            extension,
        });
    }

    docs.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(docs)
}

pub(crate) fn is_ignored(entry: &DirEntry) -> bool {
    entry
        .file_name()
        .to_str()
        .map(|name| {
            name == ".git"
                || name == "node_modules"
                || name == ".cache"
                || (name.starts_with('.') && entry.file_type().is_dir())
        })
        .unwrap_or(false)
}

pub(crate) fn is_supported_doc(path: &Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| SUPPORTED_EXTENSIONS.contains(&ext))
        .unwrap_or(false)
}

pub(crate) fn resolve_document_subset(
    root: &Path,
    source: &Path,
    references: &[String],
) -> AppResult<Vec<Document>> {
    let mut by_path = HashMap::new();

    for reference in references {
        let (path_ref, _) = split_read_ref(reference);
        let path = resolve_read_path(root, source, &path_ref)?;
        let path = canonical_or_original(path);

        if !path.starts_with(source) {
            return Err(AppError::new(
                "PATH_OUTSIDE_SOURCE",
                format!("sync path is outside bound source: {path_ref}"),
            ));
        }

        if path.is_dir() {
            for document in list_documents(root, &path)? {
                by_path.insert(document.path.clone(), document);
            }
        } else if path.is_file() && is_supported_doc(&path) {
            let extension = path
                .extension()
                .and_then(|ext| ext.to_str())
                .unwrap_or("")
                .to_string();
            let document = Document {
                path: relative_to(&path, root)?,
                title: title_from_file(root, &path),
                extension,
            };
            by_path.insert(document.path.clone(), document);
        } else {
            return Err(AppError::new(
                "UNSUPPORTED_DOCUMENT",
                format!("sync path is not a supported Markdown or Typst document: {path_ref}"),
            ));
        }
    }

    let mut documents = by_path.into_values().collect::<Vec<_>>();
    documents.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(documents)
}

pub(crate) fn read_text_file(path: &Path) -> AppResult<String> {
    fs::read_to_string(path).map_err(|err| {
        AppError::new(
            "FILE_READ_ERROR",
            format!("failed to read {}: {err}", path.display()),
        )
    })
}

pub(crate) fn split_read_ref(reference: &str) -> (String, Option<usize>) {
    if let Some((path, line)) = reference.rsplit_once(':')
        && let Ok(line_number) = line.parse::<usize>()
    {
        return (path.to_string(), Some(line_number));
    }

    (reference.to_string(), None)
}

pub(crate) fn resolve_read_path(root: &Path, source: &Path, path_ref: &str) -> AppResult<PathBuf> {
    let root_relative = root.join(path_ref);
    if root_relative.exists() {
        return Ok(root_relative);
    }

    let source_relative = source.join(path_ref);
    if source_relative.exists() {
        return Ok(source_relative);
    }

    Err(AppError::new(
        "FILE_NOT_FOUND",
        format!("file not found under root or source: {path_ref}"),
    ))
}

pub(crate) fn relative_to(path: &Path, root: &Path) -> AppResult<String> {
    let path_abs = canonical_or_original(path.to_path_buf());
    let root_abs = canonical_or_original(root.to_path_buf());
    let rel = path_abs.strip_prefix(&root_abs).map_err(|_| {
        AppError::new(
            "PATH_OUTSIDE_ROOT",
            format!("path is outside configured root: {}", path.display()),
        )
    })?;
    Ok(path_to_slash(rel))
}