solti-model 0.0.1

Solti SDK domain model.
Documentation
//! # Task identifier.
//!
//! [`TaskId`] is a unique identifier for a task resource (newtype over `Arc<str>`).

use super::validate_identity;
use crate::error::ModelError;

/// Maximum length of a `TaskId`.
pub const TASK_ID_MAX_LEN: usize = 256;

arc_str_newtype! {
    /// Unique identifier for a task instance.
    ///
    /// Generated by the runner during task creation via `solti_runner::make_run_id`.
    /// Format: `{runner}-{slot}-{seq}` (e.g., `subprocess-demo-slot-42`).
    ///
    /// ```text
    ///  TaskSpec { slot: "build" }
    ///    ///           ▼  submit #1
    ///  TaskId: "subprocess-build-1"   ← attempt 1..N, then done
    ///    ///           ▼  submit #2
    ///  TaskId: "subprocess-build-2"   ← new run, new id
    ///    ///           ▼  submit #3
    ///  TaskId: "subprocess-build-3"
    /// ```
    ///
    /// The [`Slot`](crate::Slot) stays the same across submissions.
    /// The `TaskId` is unique per run: same slot, different execution, different id.
    pub struct TaskId;
}

impl TaskId {
    /// Validate that the task id is safe to use across the SDK.
    ///
    /// See [`validate_identity`] for the exact rules.
    pub fn validate_format(&self) -> Result<(), ModelError> {
        validate_identity("task_id", self.as_str(), TASK_ID_MAX_LEN)
    }
}

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

    #[test]
    fn task_id_from_string() {
        let id = TaskId::from("subprocess-slot-2a");
        assert_eq!(id.as_str(), "subprocess-slot-2a");
    }

    #[test]
    fn task_id_display() {
        let id = TaskId::new("test-id");
        assert_eq!(format!("{}", id), "test-id");
    }

    #[test]
    fn task_id_serde_transparent() {
        let id = TaskId::from("runner-slot-ff");
        let json = serde_json::to_string(&id).unwrap();
        assert_eq!(json, r#""runner-slot-ff""#);

        let back: TaskId = serde_json::from_str(&json).unwrap();
        assert_eq!(back, id);
    }

    #[test]
    fn task_id_hash_equality() {
        use std::collections::HashSet;

        let mut set = HashSet::new();
        set.insert(TaskId::from("id-1"));
        set.insert(TaskId::from("id-2"));
        set.insert(TaskId::from("id-1"));

        assert_eq!(set.len(), 2);
        assert!(set.contains(&TaskId::from("id-1")));
    }

    #[test]
    fn clone_is_cheap() {
        let id = TaskId::new("shared-task");
        let cloned = id.clone();
        let a: Arc<str> = id.into_inner();
        let b: Arc<str> = cloned.into_inner();
        assert!(Arc::ptr_eq(&a, &b));
    }

    #[test]
    fn validate_format_accepts_runner_generated() {
        TaskId::new("subprocess-build-1").validate_format().unwrap();
        TaskId::new("subprocess-build.frontend-ff")
            .validate_format()
            .unwrap();
    }

    #[test]
    fn validate_format_rejects_invalid() {
        assert!(TaskId::new("").validate_format().is_err());
        assert!(TaskId::new("with/slash").validate_format().is_err());
        assert!(TaskId::new("with space").validate_format().is_err());
        assert!(TaskId::new(&"x".repeat(257)).validate_format().is_err());
    }
}