conpub 0.1.1

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use crate::domain::*;
use crate::infrastructure::assets::stage_shared_assets;
use crate::infrastructure::config::{conpub_home, resolve_confluence_credentials};
use crate::infrastructure::{get_platform_id, new_publish_context_with_stage_status};
use crate::support::*;
use chrono::NaiveDate;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use typub_config::{Config as TypubConfig, PlatformConfig};
use typub_core::{Content, ContentFormat, ContentMeta, PostPlatformConfig};
use typub_engine::{AdapterRegistry, Renderer, publish_single_platform};
use typub_storage::StatusTracker;

pub(crate) async fn publish_snapshots_with_hierarchy(
    config: &TypubConfig,
    resolved: &ResolvedConfig,
    stage_root: &Path,
    snapshots: &[DocumentSnapshot],
    state: &SyncState,
    delay_ms: u64,
) -> AppResult<Vec<PublishItemResult>> {
    let registry = AdapterRegistry::new(config)
        .map_err(|err| AppError::new("PUBLISH_BACKEND_ERROR", err.to_string()))?;
    let adapter = registry
        .get("confluence")
        .map_err(|err| AppError::new("PUBLISH_BACKEND_ERROR", err.to_string()))?;

    if let Some(platform_config) = config.get_platform("confluence") {
        adapter
            .validate_config(platform_config)
            .map_err(|err| AppError::new("PUBLISH_CONFIG_ERROR", err.to_string()))?;
    }

    let renderer = Renderer::new_with_root(config, stage_root.to_path_buf());
    let mut ctx = new_publish_context_with_stage_status(config, stage_root)?;
    let mut published_ids = HashMap::new();
    let mut results = Vec::new();

    let mut ordered = snapshots.to_vec();
    ordered.sort_by(|a, b| {
        a.hierarchy_order
            .cmp(&b.hierarchy_order)
            .then(a.document.path.cmp(&b.document.path))
    });

    for (idx, snapshot) in ordered.into_iter().enumerate() {
        if idx > 0 && delay_ms > 0 {
            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
        }

        let parent_id = match resolve_snapshot_parent_id(
            &snapshot,
            state,
            &published_ids,
            resolved,
            &ctx.status,
        ) {
            Ok(parent_id) => parent_id,
            Err(err) => {
                results.push(PublishItemResult {
                    path: snapshot.document.path,
                    title: snapshot.document.title,
                    slug: snapshot.slug,
                    parent_path: snapshot.parent_path,
                    parent_id: None,
                    status: "failed".to_string(),
                    url: None,
                    platform_id: None,
                    error: Some(err.to_string()),
                });
                continue;
            }
        };
        let content = stage_document(resolved, &snapshot.document, stage_root, &parent_id)?;
        let result = publish_single_platform(
            adapter,
            "confluence",
            &content,
            &renderer,
            &mut ctx,
            config,
            None,
        )
        .await;

        match result {
            Ok(published) => {
                if let Some(platform_id) = &published.platform_id {
                    published_ids.insert(snapshot.document.path.clone(), platform_id.clone());
                }
                results.push(PublishItemResult {
                    path: snapshot.document.path,
                    title: snapshot.document.title,
                    slug: snapshot.slug,
                    parent_path: snapshot.parent_path,
                    parent_id: Some(parent_id),
                    status: "published".to_string(),
                    url: published.url,
                    platform_id: published.platform_id,
                    error: None,
                });
            }
            // Alternate anyhow rendering keeps the whole context chain; the
            // outermost message alone has already misattributed a local
            // staged-asset read failure as a remote upload error.
            Err(err) => results.push(PublishItemResult {
                path: snapshot.document.path,
                title: snapshot.document.title,
                slug: snapshot.slug,
                parent_path: snapshot.parent_path,
                parent_id: Some(parent_id),
                status: "failed".to_string(),
                url: None,
                platform_id: None,
                error: Some(format!("{err:#}")),
            }),
        }
    }

    ctx.status
        .save()
        .map_err(|err| AppError::new("PUBLISH_STATE_ERROR", err.to_string()))?;

    Ok(results)
}

pub(crate) fn resolve_snapshot_parent_id(
    snapshot: &DocumentSnapshot,
    state: &SyncState,
    published_ids: &HashMap<String, String>,
    resolved: &ResolvedConfig,
    typub_status: &StatusTracker,
) -> AppResult<String> {
    let Some(parent_path) = &snapshot.parent_path else {
        return Ok(resolved.target.parent_id.clone());
    };

    if let Some(parent_id) = published_ids.get(parent_path) {
        return Ok(parent_id.clone());
    }

    if let Some(entry) = state.documents.get(parent_path)
        && let Some(parent_id) = get_platform_id(typub_status, &entry.slug)?
    {
        return Ok(parent_id);
    }

    Err(AppError::new(
        "HIERARCHY_PARENT_NOT_PUBLISHED",
        format!(
            "parent page for {} is not published yet: {parent_path}",
            snapshot.document.path
        ),
    ))
}

pub(crate) fn build_typub_config(
    resolved: &ResolvedConfig,
    stage_root: &Path,
) -> AppResult<TypubConfig> {
    let base_url = resolved.target.base_url.as_deref().ok_or_else(|| {
        AppError::new(
            "CONFIG_MISSING_BASE_URL",
            format!(
                "run `conpub root <dir> --base-url <url>`, bind with --base-url, or set {ENV_BASE_URL}"
            ),
        )
    })?;

    let mut extra = HashMap::new();
    extra.insert(
        "base_url".to_string(),
        toml::Value::String(normalize_confluence_base_url(base_url)),
    );
    extra.insert(
        "space".to_string(),
        toml::Value::String(resolved.target.space.clone()),
    );
    extra.insert(
        "parent_id".to_string(),
        toml::Value::String(resolved.target.parent_id.clone()),
    );

    // Credentials resolve env-over-project-over-user and travel only inside
    // this in-memory typub config; they never reach resolve/plan/status
    // output or the staged files on disk.
    let credentials = resolve_confluence_credentials()?;
    if let Some(api_key) = credentials.api_key {
        extra.insert("api_key".to_string(), toml::Value::String(api_key));
    }
    if let Some(email) = credentials.email {
        extra.insert("email".to_string(), toml::Value::String(email));
    }

    let mut platforms = HashMap::new();
    platforms.insert(
        "confluence".to_string(),
        PlatformConfig {
            enabled: true,
            asset_strategy: Some("upload".to_string()),
            published: Some(true),
            theme: None,
            internal_link_target: None,
            math_rendering: Some("latex".to_string()),
            math_delimiters: None,
            extra,
        },
    );

    Ok(TypubConfig {
        content_dir: stage_root.join("posts"),
        output_dir: stage_root.join("output"),
        storage: None,
        published: Some(true),
        theme: None,
        internal_link_target: None,
        preamble: None,
        platforms,
    })
}

pub(crate) fn stage_document(
    resolved: &ResolvedConfig,
    document: &Document,
    stage_root: &Path,
    parent_id: &str,
) -> AppResult<Content> {
    let staged = stage_document_files(resolved, document, stage_root)?;

    Ok(Content {
        path: staged.post_dir,
        meta: content_meta_for(document, &resolved.target, parent_id)?,
        content_file: staged.content_file,
        source_format: staged.source_format,
        slides_file: None,
        assets: staged.assets,
    })
}

pub(crate) fn stage_document_files(
    resolved: &ResolvedConfig,
    document: &Document,
    stage_root: &Path,
) -> AppResult<StagedDocumentFiles> {
    let root = PathBuf::from(&resolved.root);
    let posts_dir = stage_root.join("posts");
    fs::create_dir_all(&posts_dir).map_err(|err| {
        AppError::new(
            "STAGE_WRITE_ERROR",
            format!("failed to create {}: {err}", posts_dir.display()),
        )
    })?;

    let original = root.join(&document.path);
    let slug = slug_for_path(&document.path);
    let post_dir = posts_dir.join(&slug);

    if post_dir.exists() {
        fs::remove_dir_all(&post_dir).map_err(|err| {
            AppError::new(
                "STAGE_WRITE_ERROR",
                format!("failed to reset {}: {err}", post_dir.display()),
            )
        })?;
    }
    fs::create_dir_all(&post_dir).map_err(|err| {
        AppError::new(
            "STAGE_WRITE_ERROR",
            format!("failed to create {}: {err}", post_dir.display()),
        )
    })?;

    let source_format = match document.extension.as_str() {
        "typ" => ContentFormat::Typst,
        _ => ContentFormat::Markdown,
    };
    let staged_name = match source_format {
        ContentFormat::Typst => "content.typ",
        ContentFormat::Markdown => "content.md",
    };
    let assets = stage_shared_assets(&root, &post_dir)?;
    let content_file = post_dir.join(staged_name);
    fs::copy(&original, &content_file).map_err(|err| {
        AppError::new(
            "STAGE_WRITE_ERROR",
            format!(
                "failed to stage {} as {}: {err}",
                original.display(),
                content_file.display()
            ),
        )
    })?;

    Ok(StagedDocumentFiles {
        post_dir,
        content_file,
        source_format,
        assets,
    })
}

pub(crate) struct StagedDocumentFiles {
    pub(crate) post_dir: PathBuf,
    pub(crate) content_file: PathBuf,
    pub(crate) source_format: ContentFormat,
    pub(crate) assets: Vec<PathBuf>,
}

pub(crate) fn content_meta_for(
    document: &Document,
    target: &Target,
    parent_id: &str,
) -> AppResult<ContentMeta> {
    let created = NaiveDate::from_ymd_opt(1970, 1, 1)
        .ok_or_else(|| AppError::new("DATE_ERROR", "failed to construct default date"))?;
    let mut extra = HashMap::new();
    extra.insert(
        "space".to_string(),
        toml::Value::String(target.space.clone()),
    );
    extra.insert(
        "parent_id".to_string(),
        toml::Value::String(parent_id.to_string()),
    );
    let mut platforms = HashMap::new();
    platforms.insert(
        "confluence".to_string(),
        PostPlatformConfig {
            published: Some(true),
            internal_link_target: None,
            extra,
        },
    );

    Ok(ContentMeta {
        title: document.title.clone(),
        created,
        updated: None,
        tags: Vec::new(),
        categories: Vec::new(),
        published: Some(true),
        theme: None,
        internal_link_target: None,
        preamble: None,
        platforms,
    })
}

pub(crate) fn publish_stage_root(resolved: &ResolvedConfig) -> AppResult<PathBuf> {
    let key = format!("{}-{}", resolved.target.space, resolved.target.parent_id);
    Ok(conpub_home()?.join("typub-stage").join(sanitize_slug(&key)))
}