roder-api 0.1.2

Agentic software development tools and SDKs for Roder.
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLedgerStatus {
    Pending,
    InProgress,
    Completed,
    Blocked,
}

impl TaskLedgerStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::InProgress => "in_progress",
            Self::Completed => "completed",
            Self::Blocked => "blocked",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskLedgerItem {
    pub id: String,
    pub content: String,
    pub status: TaskLedgerStatus,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub evidence: Option<String>,
}

impl TaskLedgerItem {
    pub fn validate(&self, require_completion_evidence: bool) -> Result<(), TaskLedgerError> {
        validate_nonempty(&self.id, "id")?;
        validate_nonempty(&self.content, "content")?;
        if matches!(self.status, TaskLedgerStatus::Completed)
            && require_completion_evidence
            && self
                .evidence
                .as_deref()
                .is_none_or(|evidence| evidence.trim().is_empty())
        {
            return Err(TaskLedgerError::MissingEvidence {
                id: self.id.clone(),
            });
        }
        Ok(())
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TaskLedgerSnapshot {
    pub tasks: Vec<TaskLedgerItem>,
}

impl TaskLedgerSnapshot {
    pub fn validate(&self, require_completion_evidence: bool) -> Result<(), TaskLedgerError> {
        let mut ids = std::collections::HashSet::new();
        let mut in_progress = 0usize;
        for task in &self.tasks {
            task.validate(require_completion_evidence)?;
            if !ids.insert(task.id.clone()) {
                return Err(TaskLedgerError::DuplicateId {
                    id: task.id.clone(),
                });
            }
            if matches!(task.status, TaskLedgerStatus::InProgress) {
                in_progress += 1;
            }
        }
        if in_progress > 1 {
            return Err(TaskLedgerError::MultipleInProgress);
        }
        Ok(())
    }

    pub fn completed_count(&self) -> usize {
        self.tasks
            .iter()
            .filter(|task| matches!(task.status, TaskLedgerStatus::Completed))
            .count()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskLedgerError {
    EmptyField { field: &'static str },
    DuplicateId { id: String },
    MultipleInProgress,
    MissingEvidence { id: String },
}

impl std::fmt::Display for TaskLedgerError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::EmptyField { field } => write!(f, "task ledger {field} must not be empty"),
            Self::DuplicateId { id } => write!(f, "duplicate task ledger id {id:?}"),
            Self::MultipleInProgress => {
                write!(f, "task ledger accepts at most one in_progress task")
            }
            Self::MissingEvidence { id } => {
                write!(f, "completed task ledger item {id:?} requires evidence")
            }
        }
    }
}

impl std::error::Error for TaskLedgerError {}

fn validate_nonempty(value: &str, field: &'static str) -> Result<(), TaskLedgerError> {
    if value.trim().is_empty() {
        Err(TaskLedgerError::EmptyField { field })
    } else {
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn task_ledger_rejects_duplicate_ids_and_multiple_in_progress() {
        let snapshot = TaskLedgerSnapshot {
            tasks: vec![
                TaskLedgerItem {
                    id: "a".to_string(),
                    content: "First".to_string(),
                    status: TaskLedgerStatus::InProgress,
                    evidence: None,
                },
                TaskLedgerItem {
                    id: "a".to_string(),
                    content: "Second".to_string(),
                    status: TaskLedgerStatus::InProgress,
                    evidence: None,
                },
            ],
        };

        assert_eq!(
            snapshot.validate(false).unwrap_err(),
            TaskLedgerError::DuplicateId {
                id: "a".to_string()
            }
        );

        let snapshot = TaskLedgerSnapshot {
            tasks: vec![
                TaskLedgerItem {
                    id: "a".to_string(),
                    content: "First".to_string(),
                    status: TaskLedgerStatus::InProgress,
                    evidence: None,
                },
                TaskLedgerItem {
                    id: "b".to_string(),
                    content: "Second".to_string(),
                    status: TaskLedgerStatus::InProgress,
                    evidence: None,
                },
            ],
        };

        assert_eq!(
            snapshot.validate(false).unwrap_err(),
            TaskLedgerError::MultipleInProgress
        );
    }

    #[test]
    fn task_ledger_can_require_completion_evidence() {
        let snapshot = TaskLedgerSnapshot {
            tasks: vec![TaskLedgerItem {
                id: "done".to_string(),
                content: "Verify".to_string(),
                status: TaskLedgerStatus::Completed,
                evidence: None,
            }],
        };

        assert_eq!(
            snapshot.validate(true).unwrap_err(),
            TaskLedgerError::MissingEvidence {
                id: "done".to_string()
            }
        );
    }
}