plansolve 0.26.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 {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_date: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end_date: Option<String>,
    pub employees: Vec<Employee>,
    pub tasks: Vec<Task>,
    #[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 {
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(default)]
    pub shifts: Vec<Shift>,
    #[serde(default)]
    pub skills: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hourly_rate: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub contract_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zone_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dedicated_client_id: Option<String>,
    #[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 {
    pub id: String,
    pub min_start_time: String,
    pub max_end_time: String,
}

/// A task to be scheduled.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
    pub id: String,
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub deadline: Option<String>,
    pub duration: String,
    pub priority: String,
    #[serde(default)]
    pub required_skills: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub preferred_skills: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zone_id: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub depends_on: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub preferred_employees: Vec<String>,
    #[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 {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_hours_per_day: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_hours_per_week: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_between_shifts: Option<String>,
    #[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 {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end: Option<String>,
    #[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 {
    pub job_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub solver_job_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result: Option<String>,
    #[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`] and is never read from the wire.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfessionalServicesResultResponse {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job_id: Option<String>,
    // Echoed request fields.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub start_date: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub end_date: Option<String>,
    #[serde(default)]
    pub employees: Vec<Employee>,
    #[serde(default)]
    pub tasks: Vec<Task>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub contracts: Vec<Contract>,
    // Result fields.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub solver_status: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub feasible: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score_string: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score: Option<serde_json::Value>,
    #[serde(default)]
    pub unassigned_tasks: Vec<String>,
    #[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());
    }
}