plansolve 0.25.8

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

/// Request model for starting a shift optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftRequest {
    #[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 description: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub contracts: Vec<Contract>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shifts: Vec<ShiftAssignment>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub employees: Vec<ShiftEmployee>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub options: Option<Options>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub weights: Option<Weights>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fairness: Option<Fairness>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub day_off_requests: Vec<DayOffRequest>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shift_off_requests: Vec<ShiftOffRequest>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hook: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub constraint_weight_overrides: Option<ConstraintWeightOverrides>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score: Option<serde_json::Value>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub unassigned_shifts: Vec<ShiftAssignment>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub assigned_shifts: Vec<ShiftAssignment>,
}

/// A time window for shift scheduling.
#[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 shift assignment to be scheduled.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftAssignment {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub from: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub to: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<String>,
    #[serde(default)]
    pub cost: f64,
    #[serde(default)]
    pub value: i64,
    #[serde(default)]
    pub priority: i64,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub desired_skills: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    #[serde(default)]
    pub pinned_by_user: bool,
}

/// An employee in the shift optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftEmployee {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub contract: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_rest_date: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub availability: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub preference: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub period_rules: Vec<PeriodRule>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub unavailable_dates: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub maximum_minutes_per_week: Option<i64>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shifts: Vec<ShiftAssignment>,
}

/// A shift employee contract.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Contract {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min: Option<String>,
    #[serde(default)]
    pub max_consecutive_work_days: i64,
    #[serde(default)]
    pub max_shifts_day: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_between_shifts_same_day: Option<String>,
    #[serde(default)]
    pub max_working_days: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub latest_shift_start: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub earliest_shift_start: Option<String>,
    #[serde(default)]
    pub minimum_consecutive_days_off: i64,
    #[serde(default)]
    pub minimum_hours_off_between_shifts: i64,
}

/// Constraint weights for the shift optimizer.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Weights {
    pub required_skills: i64,
    pub shift_capacity: i64,
    pub minimum_staffing: i64,
    pub no_double_booking: i64,
    pub rest_between_shifts: i64,
    pub employee_availability: i64,
    pub shift_preferences: i64,
    pub cost_minimization: i64,
    pub workload_balance: i64,
    pub fairness: i64,
    pub max_consecutive_work_days: i64,
    pub max_shifts_per_day: i64,
    pub max_working_days_per_week: i64,
    pub contract_rest_between_shifts: i64,
    pub earliest_shift_start: i64,
    pub latest_shift_start: i64,
    pub minimum_consecutive_days_off: i64,
    pub period_rule_violation: i64,
    pub desired_skills: i64,
    pub desired_day_off: i64,
    pub shift_off_request: i64,
    pub balance_time_worked: i64,
    pub employee_affinity: i64,
    pub avoid_shift_close_to_day_off: i64,
}

/// Solver options for the shift optimizer.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Options {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub partial_planning: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_iterations: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_limit: Option<i64>,
}

/// Fairness configuration for the shift optimizer.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fairness {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub fairness_buckets: Vec<FairnessBucket>,
}

/// A fairness bucket grouping employees and shifts.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FairnessBucket {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub employees: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shifts: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub period: Option<String>,
}

/// An employee's request for a day off.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DayOffRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub employee_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub date: Option<String>,
    #[serde(default)]
    pub weight: i64,
}

/// An employee's request to be off a specific shift.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftOffRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub employee_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shift_name: Option<String>,
    #[serde(default)]
    pub weight: i64,
}

/// A period-based working constraint.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PeriodRule {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub period: Option<PlanningPeriod>,
    #[serde(default)]
    pub max_working_days: i64,
    #[serde(default)]
    pub min_working_days: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_working_duration: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_working_duration: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_duration_between_shifts_same_day: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_rest_duration: Option<String>,
}

/// A time period for planning rules.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanningPeriod {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub from: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub to: Option<String>,
}

/// Overrides for constraint weights.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConstraintWeightOverrides {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub known_constraint_names: Vec<String>,
}

/// Response from starting a shift optimization.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftStartResponse {
    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 shift optimization results.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShiftResultResponse {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub job_id: 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 assigned_shifts: Vec<ShiftAssignment>,
    #[serde(default)]
    pub unassigned_shifts: Vec<ShiftAssignment>,
    #[serde(default)]
    pub employees: Vec<ShiftEmployee>,
}

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

    #[test]
    fn serializes_and_omits_empty_collections() {
        let json = serde_json::to_string(&ShiftRequest::default()).unwrap();
        // Empty optional vectors are omitted, keeping payloads lean.
        assert!(!json.contains("\"shifts\""));
        assert!(!json.contains("\"employees\""));
    }

    #[test]
    fn serializes_populated_assignment() {
        let request = ShiftRequest {
            shifts: vec![ShiftAssignment {
                name: Some("Night".into()),
                cost: 12.5,
                priority: 3,
                pinned_by_user: true,
                ..Default::default()
            }],
            ..Default::default()
        };
        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("\"shifts\""));
        assert!(json.contains("\"pinnedByUser\":true"));
        assert!(json.contains("\"cost\":12.5"));
    }

    #[test]
    fn deserializes_result_response() {
        let json = r#"{
            "feasible": true,
            "scoreString": "0hard/0medium/-45soft",
            "score": {"hard": 0, "medium": 0, "soft": -45},
            "assignedShifts": [
                {"name": "Ward A", "from": "2024-01-15T22:00:00", "to": "2024-01-16T06:00:00",
                 "skills": ["nurse"], "cost": 12.5, "value": 2, "priority": 3}
            ],
            "unassignedShifts": [
                {"name": "Ward B", "from": "2024-01-16T22:00:00", "to": "2024-01-17T06:00:00",
                 "skills": ["nurse", "first_aid"]}
            ],
            "employees": [
                {"name": "Alice", "skills": ["nurse"], "shifts": [
                    {"name": "Ward A", "from": "2024-01-15T22:00:00", "to": "2024-01-16T06:00:00"}
                ]}
            ]
        }"#;

        let result: ShiftResultResponse = serde_json::from_str(json).unwrap();
        assert_eq!(result.feasible, Some(true));
        assert_eq!(result.score_string.as_deref(), Some("0hard/0medium/-45soft"));
        assert_eq!(result.assigned_shifts.len(), 1);
        assert_eq!(result.assigned_shifts[0].name.as_deref(), Some("Ward A"));
        assert_eq!(
            result.assigned_shifts[0].from.as_deref(),
            Some("2024-01-15T22:00:00")
        );
        assert_eq!(result.assigned_shifts[0].cost, 12.5);
        assert_eq!(result.unassigned_shifts.len(), 1);
        assert_eq!(result.employees.len(), 1);
        assert_eq!(result.employees[0].shifts.len(), 1);
    }
}