plansolve 0.27.0

Official Rust client library for the PlanSolve optimization API.
Documentation
use serde::{Deserialize, Serialize};

/// Request model for starting a professional services optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfessionalServicesRequest {
    /// Name of the plan/project being scheduled.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Free-text description of the plan.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Start of the planning window, as an ISO-8601 date/timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_date: Option<String>,
    /// End of the planning window, as an ISO-8601 date/timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_date: Option<String>,
    /// Employees available to perform the tasks.
    pub employees: Vec<Employee>,
    /// Tasks to be assigned and scheduled.
    pub tasks: Vec<Task>,
    /// Contracts referenced by employees, defining working-time limits.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub contracts: Vec<Contract>,
}

/// An employee available for task assignment.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Employee {
    /// Caller-supplied unique identifier for the employee.
    pub id: String,
    /// Employee display name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Employee email address.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    /// Solution: shifts assigned to the employee.
    #[serde(default)]
    pub shifts: Vec<Shift>,
    /// Skills the employee possesses.
    #[serde(default)]
    pub skills: Vec<String>,
    /// Cost per hour, used by cost-minimization constraints.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hourly_rate: Option<f64>,
    /// Id of the [`Contract`] governing this employee's working-time limits.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub contract_id: Option<String>,
    /// IANA time-zone id the employee works in.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zone_id: Option<String>,
    /// When set, restricts the employee to tasks for this client only.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dedicated_client_id: Option<String>,
    /// Windows during which the employee is available (or explicitly unavailable).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub availability_time_spans: Vec<AvailabilityTimeSpan>,
}

/// A time window during which an employee is available.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Shift {
    /// Caller-supplied unique identifier for the shift.
    pub id: String,
    /// Earliest the shift may start.
    pub min_start_time: String,
    /// Latest the shift may end.
    pub max_end_time: String,
}

/// A task to be scheduled.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
    /// Caller-supplied unique identifier for the task.
    pub id: String,
    /// Task display name.
    pub name: String,
    /// Free-text task description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Latest the task may be completed, as an ISO-8601 timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deadline: Option<String>,
    /// Effort required to complete the task, as an ISO-8601 duration.
    pub duration: String,
    /// Scheduling priority (e.g. `LOW`, `MEDIUM`, `HIGH`).
    pub priority: String,
    /// Skills an employee must have to be assigned the task.
    #[serde(default)]
    pub required_skills: Vec<String>,
    /// Skills that are preferred but not mandatory.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub preferred_skills: Vec<String>,
    /// Identifier of the client the task belongs to.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
    /// Identifier of the project the task belongs to.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project_id: Option<String>,
    /// IANA time-zone id the task is scheduled in.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zone_id: Option<String>,
    /// Ids of tasks that must be completed before this one can start.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub depends_on: Vec<String>,
    /// Ids of employees preferred for this task.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub preferred_employees: Vec<String>,
    /// Ids of employees that must not be assigned this task.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub prohibited_employees: Vec<String>,
}

/// An employee contract with working-hour constraints.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Contract {
    /// Caller-supplied unique identifier for the contract.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    /// Human-readable contract name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Maximum working hours per day, as an ISO-8601 duration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_hours_per_day: Option<String>,
    /// Maximum working hours per week, as an ISO-8601 duration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_hours_per_week: Option<String>,
    /// Minimum rest between consecutive shifts, as an ISO-8601 duration.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_between_shifts: Option<String>,
    /// Desired utilization ratio (0-1) the solver tries to hit.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_utilization: Option<f64>,
}

/// A span of employee availability.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AvailabilityTimeSpan {
    /// Optional caller-supplied identifier for the span.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    /// Start of the span, as an ISO-8601 timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start: Option<String>,
    /// End of the span, as an ISO-8601 timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end: Option<String>,
    /// Whether the span marks availability or unavailability (e.g. `AVAILABLE`, `UNAVAILABLE`).
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
}

/// Response from starting a professional services optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfessionalServicesStartResponse {
    /// Public PlanSolve job identifier - use it to poll status and fetch the solution.
    pub job_id: String,
    /// The underlying solver engine's job identifier, when exposed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub solver_job_id: Option<String>,
    /// Inline solver result as raw JSON, when available synchronously.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<String>,
    /// Error message when the solve request could not be accepted or run.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// Response from getting professional services optimization results.
///
/// The server returns a full echo of the request (all planning inputs) plus the
/// result fields `solverStatus`, `feasible`, `scoreString`, `score`,
/// `unassignedTasks`, and `assignedTasks`. The wire body carries no `jobId`;
/// `job_id` here is a client-side convenience stamped by
/// [`ProfessionalServicesClient::get_result`](crate::professional_services::ProfessionalServicesClient::get_result)
/// and is never read from the wire.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfessionalServicesResultResponse {
    /// Public PlanSolve job identifier, stamped client-side from the request.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job_id: Option<String>,
    // Echoed request fields.
    /// Identifier of the solved plan.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    /// Echoed plan name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Echoed plan description.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Echoed planning-window start.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub start_date: Option<String>,
    /// Echoed planning-window end.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub end_date: Option<String>,
    /// Employees with their assigned shifts populated by the solver.
    #[serde(default)]
    pub employees: Vec<Employee>,
    /// Tasks with their assignment details populated by the solver.
    #[serde(default)]
    pub tasks: Vec<Task>,
    /// Echoed contracts.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub contracts: Vec<Contract>,
    // Result fields.
    /// Raw solver engine status.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub solver_status: Option<String>,
    /// Whether the returned solution satisfies all hard constraints.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub feasible: Option<bool>,
    /// Final score as a solver score string.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score_string: Option<String>,
    /// Score broken down into its component levels.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score: Option<serde_json::Value>,
    /// Ids of tasks the solver could not assign.
    #[serde(default)]
    pub unassigned_tasks: Vec<String>,
    /// Ids of tasks the solver successfully assigned.
    #[serde(default)]
    pub assigned_tasks: Vec<String>,
}

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

    #[test]
    fn serializes_expected_keys() {
        let json = serde_json::to_string(&ProfessionalServicesRequest::default()).unwrap();
        assert!(json.contains("\"employees\""));
        assert!(json.contains("\"tasks\""));
        // start_date is optional and unset => omitted.
        assert!(!json.contains("\"startDate\""));
    }

    #[test]
    fn availability_span_uses_reserved_type_key() {
        // The `kind` field maps to the reserved word "type" in the contract.
        let span = AvailabilityTimeSpan {
            kind: Some("VACATION".into()),
            ..Default::default()
        };
        let json = serde_json::to_string(&span).unwrap();
        assert!(json.contains("\"type\":\"VACATION\""));
        assert!(!json.contains("\"kind\""));

        // ...and round-trips back.
        let parsed: AvailabilityTimeSpan = serde_json::from_str(r#"{"type":"SICK"}"#).unwrap();
        assert_eq!(parsed.kind.as_deref(), Some("SICK"));
    }

    #[test]
    fn deserializes_result_response() {
        // The response echoes the request (employees/tasks/contracts as the input
        // types) and adds solver status plus assigned/unassigned task-id lists.
        let json = r#"{
            "id": "plan-1",
            "name": "Sprint 5",
            "startDate": "2024-01-15",
            "endDate": "2024-01-19",
            "employees": [
                {"id": "e1", "name": "Dev A", "skills": ["dev"]}
            ],
            "tasks": [
                {"id": "t1", "name": "Build", "duration": "PT2H", "priority": "HIGH",
                 "requiredSkills": ["dev"]}
            ],
            "contracts": [{"id": "c1", "name": "full-time"}],
            "solverStatus": "NOT_SOLVING",
            "feasible": true,
            "scoreString": "0hard/0medium/-5soft",
            "score": {"hard": 0, "medium": 0, "soft": -5},
            "assignedTasks": ["t1"],
            "unassignedTasks": []
        }"#;

        let result: ProfessionalServicesResultResponse = serde_json::from_str(json).unwrap();
        assert_eq!(result.id.as_deref(), Some("plan-1"));
        assert_eq!(result.employees.len(), 1);
        assert_eq!(result.employees[0].name.as_deref(), Some("Dev A"));
        assert_eq!(result.tasks[0].name, "Build");
        assert_eq!(result.contracts.len(), 1);
        assert_eq!(result.solver_status.as_deref(), Some("NOT_SOLVING"));
        assert_eq!(result.feasible, Some(true));
        assert_eq!(result.score_string.as_deref(), Some("0hard/0medium/-5soft"));
        assert_eq!(result.assigned_tasks, vec!["t1".to_string()]);
        assert!(result.unassigned_tasks.is_empty());
    }
}