routa-core 0.14.0

Routa.js core domain — models, stores, protocols, and JSON-RPC (transport-agnostic)
Documentation
use chrono::Utc;
use std::collections::HashSet;

use crate::error::ServerError;
use crate::events::{AgentEvent, AgentEventType};
use crate::models::kanban::{KanbanBoard, KanbanColumn};
use crate::models::task::{Task, TaskPriority};
use crate::rpc::error::RpcError;
use crate::state::AppState;

pub(super) fn default_workspace_id() -> String {
    "default".into()
}

pub(super) async fn emit_kanban_workspace_event(
    state: &AppState,
    workspace_id: &str,
    entity: &str,
    action: &str,
    resource_id: Option<&str>,
    source: &str,
) {
    state
        .event_bus
        .emit(AgentEvent {
            event_type: AgentEventType::WorkspaceUpdated,
            agent_id: format!("kanban-{}", source),
            workspace_id: workspace_id.to_string(),
            data: serde_json::json!({
                "scope": "kanban",
                "entity": entity,
                "action": action,
                "resourceId": resource_id,
                "source": source,
            }),
            timestamp: Utc::now(),
        })
        .await;
}

pub(super) async fn ensure_workspace_exists(
    state: &AppState,
    workspace_id: &str,
) -> Result<(), ServerError> {
    if workspace_id == "default" {
        state.workspace_store.ensure_default().await?;
        return Ok(());
    }

    if state.workspace_store.get(workspace_id).await?.is_some() {
        Ok(())
    } else {
        Err(ServerError::NotFound(format!(
            "Workspace {} not found",
            workspace_id
        )))
    }
}

pub(super) async fn resolve_board(
    state: &AppState,
    workspace_id: &str,
    board_id: Option<&str>,
) -> Result<KanbanBoard, RpcError> {
    ensure_workspace_exists(state, workspace_id).await?;

    if let Some(board_id) = board_id {
        match state.kanban_store.get(board_id).await? {
            Some(board) if board.workspace_id == workspace_id => return Ok(board),
            Some(board) => {
                tracing::warn!(
                    board_id = %board_id,
                    board_workspace_id = %board.workspace_id,
                    requested_workspace_id = %workspace_id,
                    "kanban board workspace mismatch; falling back to workspace default board"
                );
            }
            None => {
                tracing::warn!(
                    board_id = %board_id,
                    requested_workspace_id = %workspace_id,
                    "kanban board not found; falling back to workspace default board"
                );
            }
        }
    }

    state
        .kanban_store
        .ensure_default_board(workspace_id)
        .await
        .map_err(Into::into)
}

pub(super) async fn tasks_for_board(
    state: &AppState,
    board: &KanbanBoard,
) -> Result<Vec<Task>, RpcError> {
    Ok(state
        .task_store
        .list_by_workspace(&board.workspace_id)
        .await?
        .into_iter()
        .filter(|task| task.board_id.as_deref() == Some(board.id.as_str()))
        .collect())
}

pub(super) async fn next_position_in_column(
    state: &AppState,
    workspace_id: &str,
    board_id: &str,
    column_id: &str,
) -> Result<i64, RpcError> {
    let count = state
        .task_store
        .list_by_workspace(workspace_id)
        .await?
        .into_iter()
        .filter(|task| {
            task.board_id.as_deref() == Some(board_id)
                && task.column_id.as_deref().unwrap_or("backlog") == column_id
        })
        .count();
    Ok(count as i64)
}

pub(super) fn ensure_column_exists(board: &KanbanBoard, column_id: &str) -> Result<(), RpcError> {
    if board.columns.iter().any(|column| column.id == column_id) {
        Ok(())
    } else {
        Err(RpcError::NotFound(format!(
            "Column {} not found",
            column_id
        )))
    }
}

pub(super) fn build_columns_from_names(names: &[String]) -> Result<Vec<KanbanColumn>, RpcError> {
    if names.is_empty() {
        return Err(RpcError::BadRequest(
            "columns cannot be an empty array".to_string(),
        ));
    }

    let mut seen = HashSet::new();
    let mut columns = Vec::with_capacity(names.len());
    for (index, name) in names.iter().enumerate() {
        let trimmed = name.trim();
        if trimmed.is_empty() {
            return Err(RpcError::BadRequest(
                "column names cannot be blank".to_string(),
            ));
        }
        let id = slugify(trimmed);
        if !seen.insert(id.clone()) {
            return Err(RpcError::BadRequest(format!(
                "duplicate column id generated from name: {}",
                trimmed
            )));
        }
        columns.push(KanbanColumn {
            id,
            name: trimmed.to_string(),
            color: None,
            position: index as i64,
            stage: "backlog".to_string(),
            automation: None,
            visible: Some(true),
            width: None,
        });
    }
    Ok(columns)
}

pub(super) fn normalize_columns(columns: Vec<KanbanColumn>) -> Result<Vec<KanbanColumn>, RpcError> {
    if columns.is_empty() {
        return Err(RpcError::BadRequest(
            "columns cannot be an empty array".to_string(),
        ));
    }

    let mut seen = HashSet::new();
    let mut normalized = Vec::with_capacity(columns.len());
    for (index, mut column) in columns.into_iter().enumerate() {
        column.id = column.id.trim().to_string();
        column.name = column.name.trim().to_string();
        if column.id.is_empty() || column.name.is_empty() {
            return Err(RpcError::BadRequest(
                "column id and name cannot be blank".to_string(),
            ));
        }
        if !seen.insert(column.id.clone()) {
            return Err(RpcError::BadRequest(format!(
                "duplicate column id: {}",
                column.id
            )));
        }
        column.position = index as i64;
        normalized.push(column);
    }
    Ok(normalized)
}

pub(super) fn parse_priority(priority: Option<&str>) -> Result<Option<TaskPriority>, RpcError> {
    match priority {
        Some(priority) => TaskPriority::from_str(priority)
            .map(Some)
            .ok_or_else(|| RpcError::BadRequest(format!("Invalid priority: {}", priority))),
        None => Ok(None),
    }
}

pub(super) fn slugify(value: &str) -> String {
    value
        .split_whitespace()
        .map(|segment| segment.to_ascii_lowercase())
        .collect::<Vec<_>>()
        .join("-")
}