carryctx 0.0.3

Persistent project context for coding agents
use crate::domain::ids::format_display_id;
use crate::domain::progress::{ProgressAction, ProgressType, evaluate_progress_transition};
use crate::error::CarryCtxError;
use crate::repository::{
    EventRepository, NewEvent, NewProgressItem, ProgressFilter, ProgressItemRecord,
    ProgressRepository, TaskRepository,
};

pub struct CreateProgressInput {
    pub project_id: String,
    pub task_id: String,
    pub source_session_id: Option<String>,
    pub item_type: ProgressType,
    pub content: String,
}

pub fn create_progress(
    progress_repo: &dyn ProgressRepository,
    task_repo: &dyn TaskRepository,
    event_repo: &dyn EventRepository,
    input: &CreateProgressInput,
    now: &str,
) -> Result<ProgressItemRecord, CarryCtxError> {
    if input.content.trim().is_empty() {
        return Err(CarryCtxError::validation_error(
            "Progress content cannot be empty.",
        ));
    }

    let task = task_repo
        .find_by_id(&input.project_id, &input.task_id)?
        .or_else(|| {
            task_repo
                .find_by_display_id(&input.project_id, &input.task_id)
                .ok()
                .flatten()
        })
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!("Task '{}' not found", input.task_id))
        })?;

    let seq = progress_repo.allocate_display_id(&input.project_id)?;
    let display_id = format_display_id("PX", seq);
    let position = progress_repo.get_next_position(&input.project_id, &task.id)?;

    let item = progress_repo.create(
        &NewProgressItem {
            id: ulid::Ulid::new().to_string(),
            display_id,
            project_id: input.project_id.clone(),
            task_id: task.id.clone(),
            source_session_id: input.source_session_id.clone(),
            item_type: input.item_type,
            content: input.content.trim().to_string(),
            position,
        },
        now,
    )?;

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: input.project_id.clone(),
        event_type: "progress.created".into(),
        actor_agent_id: None,
        session_id: None,
        task_id: Some(task.id.clone()),
        payload: serde_json::json!({
            "id": item.id,
            "display_id": item.display_id,
            "task_id": task.id,
            "item_type": item.item_type,
            "status": item.status,
            "content": item.content,
        }),
        occurred_at: now.to_string(),
    })?;

    Ok(item)
}

pub struct EditProgressInput {
    pub project_id: String,
    pub ref_or_id: String,
    pub content: String,
}

pub fn edit_progress(
    progress_repo: &dyn ProgressRepository,
    event_repo: &dyn EventRepository,
    input: &EditProgressInput,
    now: &str,
) -> Result<ProgressItemRecord, CarryCtxError> {
    if input.content.trim().is_empty() {
        return Err(CarryCtxError::validation_error(
            "Progress content cannot be empty.",
        ));
    }

    let existing = resolve_progress(progress_repo, &input.project_id, &input.ref_or_id)?;

    let updated = progress_repo.edit(&existing.id, &input.project_id, input.content.trim(), now)?;

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: input.project_id.clone(),
        event_type: "progress.edited".into(),
        actor_agent_id: None,
        session_id: None,
        task_id: Some(existing.task_id.clone()),
        payload: serde_json::json!({
            "id": existing.id,
            "before": { "content": existing.content },
            "after": { "content": updated.content },
        }),
        occurred_at: now.to_string(),
    })?;

    Ok(updated)
}

pub fn complete_progress(
    progress_repo: &dyn ProgressRepository,
    event_repo: &dyn EventRepository,
    project_id: &str,
    ref_or_id: &str,
    now: &str,
) -> Result<ProgressItemRecord, CarryCtxError> {
    transition_progress_item(
        progress_repo,
        event_repo,
        project_id,
        ref_or_id,
        ProgressAction::Complete,
        now,
    )
}

pub fn reopen_progress(
    progress_repo: &dyn ProgressRepository,
    event_repo: &dyn EventRepository,
    project_id: &str,
    ref_or_id: &str,
    now: &str,
) -> Result<ProgressItemRecord, CarryCtxError> {
    transition_progress_item(
        progress_repo,
        event_repo,
        project_id,
        ref_or_id,
        ProgressAction::Reopen,
        now,
    )
}

pub fn remove_progress(
    progress_repo: &dyn ProgressRepository,
    event_repo: &dyn EventRepository,
    project_id: &str,
    ref_or_id: &str,
    now: &str,
) -> Result<ProgressItemRecord, CarryCtxError> {
    transition_progress_item(
        progress_repo,
        event_repo,
        project_id,
        ref_or_id,
        ProgressAction::Remove,
        now,
    )
}

fn transition_progress_item(
    progress_repo: &dyn ProgressRepository,
    event_repo: &dyn EventRepository,
    project_id: &str,
    ref_or_id: &str,
    action: ProgressAction,
    now: &str,
) -> Result<ProgressItemRecord, CarryCtxError> {
    let existing = resolve_progress(progress_repo, project_id, ref_or_id)?;

    let new_status = evaluate_progress_transition(existing.status, action)?;

    let updated = progress_repo.update_status(&existing.id, project_id, new_status, now)?;

    let event_type = match action {
        ProgressAction::Complete => "progress.completed",
        ProgressAction::Reopen => "progress.reopened",
        ProgressAction::Remove => "progress.removed",
    };

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: project_id.to_string(),
        event_type: event_type.into(),
        actor_agent_id: None,
        session_id: None,
        task_id: Some(existing.task_id.clone()),
        payload: serde_json::json!({
            "id": existing.id,
            "before_status": existing.status,
            "after_status": updated.status,
        }),
        occurred_at: now.to_string(),
    })?;

    Ok(updated)
}

pub struct ReorderProgressInput {
    pub project_id: String,
    pub task_id: String,
    pub ordered_refs: Vec<String>,
}

pub fn reorder_progress(
    progress_repo: &dyn ProgressRepository,
    task_repo: &dyn TaskRepository,
    event_repo: &dyn EventRepository,
    input: &ReorderProgressInput,
    now: &str,
) -> Result<(), CarryCtxError> {
    let task = task_repo
        .find_by_id(&input.project_id, &input.task_id)?
        .or_else(|| {
            task_repo
                .find_by_display_id(&input.project_id, &input.task_id)
                .ok()
                .flatten()
        })
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!("Task '{}' not found", input.task_id))
        })?;

    let mut resolved_ids = Vec::new();
    for r in &input.ordered_refs {
        let item = resolve_progress(progress_repo, &input.project_id, r)?;
        resolved_ids.push(item.id.clone());
    }

    progress_repo.reorder(&input.project_id, &task.id, &resolved_ids)?;

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: input.project_id.clone(),
        event_type: "progress.reordered".into(),
        actor_agent_id: None,
        session_id: None,
        task_id: Some(task.id.clone()),
        payload: serde_json::json!({
            "task_id": task.id,
            "ordered_ids": resolved_ids,
        }),
        occurred_at: now.to_string(),
    })?;

    Ok(())
}

pub fn list_progress(
    progress_repo: &dyn ProgressRepository,
    filter: &ProgressFilter,
) -> Result<Vec<ProgressItemRecord>, CarryCtxError> {
    progress_repo.list(filter)
}

fn resolve_progress(
    progress_repo: &dyn ProgressRepository,
    project_id: &str,
    ref_or_id: &str,
) -> Result<ProgressItemRecord, CarryCtxError> {
    let item = progress_repo
        .find_by_display_id(project_id, ref_or_id)?
        .or_else(|| {
            progress_repo
                .find_by_id(project_id, ref_or_id)
                .ok()
                .flatten()
        })
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!("Progress item '{}' not found", ref_or_id))
        })?;
    Ok(item)
}