conpub 0.1.1

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use crate::domain::SyncItemResult;
use crate::support::*;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use typub_config::Config as TypubConfig;
use typub_engine::PublishContext;
use typub_storage::StatusTracker;

const CONFLUENCE_PLATFORM: &str = "confluence";

static TYPUB_STATUS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

pub(crate) fn typub_status_db_path(stage_root: &Path) -> PathBuf {
    stage_root.join(".typub").join("status.db")
}

pub(crate) fn new_publish_context_with_stage_status(
    config: &TypubConfig,
    stage_root: &Path,
) -> AppResult<PublishContext> {
    with_typub_status_lock(|| new_publish_context_with_stage_status_locked(config, stage_root))
}

pub(crate) fn join_sync_items_with_typub_status(
    stage_root: &Path,
    items: &mut [SyncItemResult],
) -> AppResult<()> {
    let Some(status) = load_existing_typub_status(stage_root)? else {
        return Ok(());
    };

    for item in items {
        let mut has_remote_status = false;
        if let Some(platform_id) = get_platform_id(&status, &item.slug)? {
            item.platform_id = Some(platform_id);
            has_remote_status = true;
        }
        if let Some(url) = get_published_url(&status, &item.slug)? {
            item.url = Some(url);
            has_remote_status = true;
        }
        if item.action == "create" && has_remote_status {
            item.action = "update".to_string();
            item.reason =
                Some("present in typub status but missing from local publish state".to_string());
        }
    }

    Ok(())
}

pub(crate) fn get_platform_id(status: &StatusTracker, slug: &str) -> AppResult<Option<String>> {
    status
        .get_platform_id(slug, CONFLUENCE_PLATFORM)
        .map_err(|err| AppError::new("PUBLISH_STATE_ERROR", err.to_string()))
}

fn get_published_url(status: &StatusTracker, slug: &str) -> AppResult<Option<String>> {
    status
        .get_published_url(slug, CONFLUENCE_PLATFORM)
        .map_err(|err| AppError::new("PUBLISH_STATE_ERROR", err.to_string()))
}

fn load_existing_typub_status(stage_root: &Path) -> AppResult<Option<StatusTracker>> {
    if !typub_status_db_path(stage_root).exists() {
        return Ok(None);
    }

    with_typub_status_lock(|| {
        with_typub_stage_workdir(stage_root, || {
            StatusTracker::load(stage_root)
                .map(Some)
                .map_err(|err| AppError::new("PUBLISH_STATE_ERROR", err.to_string()))
        })
    })
}

fn new_publish_context_with_stage_status_locked(
    config: &TypubConfig,
    stage_root: &Path,
) -> AppResult<PublishContext> {
    with_typub_stage_workdir(stage_root, || {
        PublishContext::new_with_root(config, stage_root)
            .map_err(|err| AppError::new("PUBLISH_STATE_ERROR", err.to_string()))
    })
}

fn with_typub_status_lock<T>(op: impl FnOnce() -> AppResult<T>) -> AppResult<T> {
    let lock = TYPUB_STATUS_LOCK.get_or_init(|| Mutex::new(()));
    let _guard = lock.lock().map_err(|_| {
        AppError::new(
            "PUBLISH_STATE_ERROR",
            "typub status working-directory lock is poisoned",
        )
    })?;
    op()
}

fn with_typub_stage_workdir<T>(
    stage_root: &Path,
    op: impl FnOnce() -> AppResult<T>,
) -> AppResult<T> {
    let _workdir = CurrentDirGuard::enter(stage_root)?;
    op()
}

struct CurrentDirGuard {
    previous: PathBuf,
}

impl CurrentDirGuard {
    fn enter(path: &Path) -> AppResult<Self> {
        fs::create_dir_all(path).map_err(|err| {
            AppError::new(
                "PUBLISH_STATE_ERROR",
                format!("failed to create {}: {err}", path.display()),
            )
        })?;
        let previous = env::current_dir().map_err(|err| {
            AppError::new(
                "PUBLISH_STATE_ERROR",
                format!("failed to read current directory: {err}"),
            )
        })?;
        env::set_current_dir(path).map_err(|err| {
            AppError::new(
                "PUBLISH_STATE_ERROR",
                format!("failed to enter {}: {err}", path.display()),
            )
        })?;

        Ok(Self { previous })
    }
}

impl Drop for CurrentDirGuard {
    fn drop(&mut self) {
        let _ = env::set_current_dir(&self.previous);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{NaiveDate, Utc};
    use std::collections::HashMap;
    use typub_core::{Content, ContentFormat, ContentMeta};
    use typub_storage::PublishResult;

    #[test]
    fn publish_context_uses_stage_root_typub_status_db() {
        let temp = tempfile::tempdir().expect("temp dir");
        let stage_root = temp.path().join("stage");

        with_typub_status_lock(|| {
            let before = env::current_dir().expect("current dir");

            let _ctx = new_publish_context_with_stage_status_locked(
                &minimal_typub_config(&stage_root),
                &stage_root,
            )?;

            assert_eq!(env::current_dir().expect("current dir"), before);
            Ok(())
        })
        .expect("publish context");
        assert!(typub_status_db_path(&stage_root).exists());
    }

    #[test]
    fn join_sync_items_reads_remote_fields_from_typub_status() {
        let temp = tempfile::tempdir().expect("temp dir");
        let stage_root = temp.path().join("stage");
        let slug = "projects-cuda-agent-notes";
        seed_typub_status(
            &stage_root,
            slug,
            "https://example.atlassian.net/wiki/spaces/GPU/pages/42/Notes",
            "42",
        );

        let mut items = vec![SyncItemResult {
            path: "projects/cuda-agent/notes.typ".to_string(),
            title: "Notes".to_string(),
            slug: slug.to_string(),
            parent_path: None,
            parent_id: None,
            action: "deleted".to_string(),
            status: "skipped".to_string(),
            fingerprint: None,
            previous_fingerprint: Some("old".to_string()),
            url: None,
            platform_id: None,
            archive_task_id: None,
            error: None,
            reason: None,
        }];

        join_sync_items_with_typub_status(&stage_root, &mut items).expect("join status");

        assert_eq!(items[0].platform_id.as_deref(), Some("42"));
        assert_eq!(
            items[0].url.as_deref(),
            Some("https://example.atlassian.net/wiki/spaces/GPU/pages/42/Notes")
        );
    }

    #[test]
    fn join_sync_items_reclassifies_known_remote_create_as_update() {
        let temp = tempfile::tempdir().expect("temp dir");
        let stage_root = temp.path().join("stage");
        let slug = "projects-cuda-agent-notes";
        seed_typub_status(
            &stage_root,
            slug,
            "https://example.atlassian.net/wiki/spaces/GPU/pages/42/Notes",
            "42",
        );

        let mut items = vec![SyncItemResult {
            path: "projects/cuda-agent/notes.typ".to_string(),
            title: "Notes".to_string(),
            slug: slug.to_string(),
            parent_path: None,
            parent_id: None,
            action: "create".to_string(),
            status: "pending".to_string(),
            fingerprint: Some("new".to_string()),
            previous_fingerprint: None,
            url: None,
            platform_id: None,
            archive_task_id: None,
            error: None,
            reason: Some("not present in local publish state".to_string()),
        }];

        join_sync_items_with_typub_status(&stage_root, &mut items).expect("join status");

        assert_eq!(items[0].action, "update");
        assert_eq!(items[0].platform_id.as_deref(), Some("42"));
        assert_eq!(
            items[0].reason.as_deref(),
            Some("present in typub status but missing from local publish state")
        );
    }

    fn seed_typub_status(stage_root: &Path, slug: &str, url: &str, platform_id: &str) {
        let config = minimal_typub_config(stage_root);
        let mut ctx =
            new_publish_context_with_stage_status(&config, stage_root).expect("publish context");
        let content = staged_content(stage_root, slug);
        let result = PublishResult {
            url: Some(url.to_string()),
            platform_id: Some(platform_id.to_string()),
            published_at: Utc::now(),
        };

        ctx.status
            .mark_published(&content, CONFLUENCE_PLATFORM, &result, Some("published"))
            .expect("mark published");
    }

    fn staged_content(stage_root: &Path, slug: &str) -> Content {
        let post_dir = stage_root.join("posts").join(slug);
        fs::create_dir_all(&post_dir).expect("create post dir");
        let content_file = post_dir.join("content.md");
        fs::write(&content_file, "# Notes\n").expect("write content");

        Content {
            path: post_dir,
            meta: ContentMeta {
                title: "Notes".to_string(),
                created: NaiveDate::from_ymd_opt(2026, 6, 18).expect("date"),
                updated: None,
                tags: Vec::new(),
                categories: Vec::new(),
                published: Some(true),
                theme: None,
                internal_link_target: None,
                preamble: None,
                platforms: HashMap::new(),
            },
            content_file,
            source_format: ContentFormat::Markdown,
            slides_file: None,
            assets: Vec::new(),
        }
    }

    fn minimal_typub_config(stage_root: &Path) -> TypubConfig {
        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: HashMap::new(),
        }
    }
}