plansolve 0.27.0

Official Rust client library for the PlanSolve optimization API.
Documentation
use std::time::Duration;

use serde_json::Value;

use super::models::*;
use crate::error::{Error, Result};
use crate::solver_status::SolverStatusResponse;
use crate::transport::Transport;

const ROUTE: &str = "/api/v1/shift";

/// Client for the shift assignment optimization API.
#[derive(Clone)]
pub struct ShiftClient {
    transport: Transport,
}

impl ShiftClient {
    pub(crate) fn new(transport: Transport) -> Self {
        Self { transport }
    }

    /// Starts a new solve and returns the job acknowledgement.
    ///
    /// `request` is the shift scheduling problem definition (shifts, employees,
    /// contracts, and optional weights/options/fairness). Returns a
    /// [`ShiftStartResponse`] carrying the public `job_id` used to poll status and
    /// fetch the result, or an [`Error`](crate::Error) for a non-success status.
    pub async fn start(&self, request: &ShiftRequest) -> Result<ShiftStartResponse> {
        self.transport.post_json(ROUTE, request).await
    }

    /// Gets the point-in-time status of a solve job by job id.
    ///
    /// `job_id` is the identifier returned by [`start`](Self::start). Returns a
    /// [`SolverStatusResponse`] describing the current solver state and best score,
    /// or an [`Error`](crate::Error) for a non-success status.
    pub async fn get_status(&self, job_id: &str) -> Result<SolverStatusResponse> {
        self.transport
            .get_json(&format!("{ROUTE}/{job_id}/status"))
            .await
    }

    /// Gets the completed solve result by job id.
    ///
    /// `job_id` is the identifier returned by [`start`](Self::start). Returns a
    /// [`ShiftResultResponse`] with the assigned and unassigned shifts (the
    /// `job_id` field is stamped client-side), or an [`Error`](crate::Error) for a
    /// non-success status.
    pub async fn get_result(&self, job_id: &str) -> Result<ShiftResultResponse> {
        let mut result: ShiftResultResponse = self
            .transport
            .get_json(&format!("{ROUTE}/{job_id}"))
            .await?;
        result.job_id = Some(job_id.to_string());
        Ok(result)
    }

    /// Gets analysis for a completed job by job id.
    ///
    /// `job_id` is the identifier returned by [`start`](Self::start). Returns the
    /// raw analysis JSON as a [`serde_json::Value`], or an [`Error`](crate::Error)
    /// for a non-success status.
    pub async fn analyze(&self, job_id: &str) -> Result<Value> {
        self.transport
            .get_json(&format!("{ROUTE}/{job_id}/analyze"))
            .await
    }

    /// Stops an in-progress solve and returns the best solution found so far
    /// (`DELETE /api/v1/shift/{job_id}`).
    ///
    /// Use this to terminate a long-running solve early instead of waiting for it
    /// to hit its own time limit; the response is the same shape as
    /// [`get_result`](Self::get_result). `job_id` is the id returned by
    /// [`start`](Self::start).
    pub async fn stop(&self, job_id: &str) -> Result<ShiftResultResponse> {
        let mut result: ShiftResultResponse = self
            .transport
            .delete_json(&format!("{ROUTE}/{job_id}"))
            .await?;
        result.job_id = Some(job_id.to_string());
        Ok(result)
    }

    /// Starts a job and polls until it completes.
    ///
    /// Convenience wrapper over [`start`](Self::start) and
    /// [`wait_for_completion`](Self::wait_for_completion). `poll_interval_ms` is the
    /// delay between status polls (falls back to 5000 when `0`) and `max_attempts`
    /// caps the number of polls (falls back to 10 when `0`). Returns the final
    /// [`ShiftResultResponse`], or an [`Error`](crate::Error) —
    /// [`Error::SolverTimeout`](crate::Error::SolverTimeout) if the solver is still
    /// running once the poll budget is exhausted.
    pub async fn start_and_wait_for_completion(
        &self,
        request: &ShiftRequest,
        poll_interval_ms: u64,
        max_attempts: u32,
    ) -> Result<ShiftResultResponse> {
        let start = self.start(request).await?;
        self.wait_for_completion(&start.job_id, poll_interval_ms, max_attempts)
            .await
    }

    /// Polls for job status until the solver finishes or `max_attempts` is reached.
    ///
    /// `job_id` is the identifier returned by [`start`](Self::start);
    /// `poll_interval_ms` is the delay between polls (falls back to 5000 when `0`)
    /// and `max_attempts` caps the number of polls (falls back to 10 when `0`).
    /// Returns the [`ShiftResultResponse`] once solving completes. Returns
    /// [`Error::MissingJobId`](crate::Error::MissingJobId) for an empty `job_id` and
    /// [`Error::SolverTimeout`](crate::Error::SolverTimeout) if the solver is still
    /// running once the poll budget is exhausted.
    pub async fn wait_for_completion(
        &self,
        job_id: &str,
        poll_interval_ms: u64,
        max_attempts: u32,
    ) -> Result<ShiftResultResponse> {
        if job_id.is_empty() {
            return Err(Error::MissingJobId);
        }
        let poll_interval_ms = if poll_interval_ms == 0 {
            5000
        } else {
            poll_interval_ms
        };
        let max_attempts = if max_attempts == 0 { 10 } else { max_attempts };

        let mut attempts = 0u32;
        loop {
            tokio::time::sleep(Duration::from_millis(poll_interval_ms)).await;
            let status = self.get_status(job_id).await?;
            attempts += 1;

            let still_solving = status.is_solving();
            if !still_solving || attempts >= max_attempts {
                if still_solving {
                    return Err(Error::SolverTimeout);
                }
                return self.get_result(job_id).await;
            }
        }
    }
}