plansolve 0.25.4

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

/// The state of the solver for a given job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SolverStatus {
    #[serde(rename = "NOT_SOLVING")]
    NotSolving,
    #[serde(rename = "SOLVING_ACTIVE")]
    SolvingActive,
    #[serde(rename = "TERMINATING_EARLY")]
    TerminatingEarly,
    #[serde(rename = "SOLVING_SCHEDULED")]
    SolvingScheduled,
    /// Any status value not otherwise recognized.
    #[serde(other)]
    Unknown,
}

/// The status of a solver job, returned by the `/status` endpoints.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SolverStatusResponse {
    #[serde(default)]
    pub job_id: String,
    pub solver_status: SolverStatus,
    #[serde(default)]
    pub score: String,
    #[serde(default)]
    pub feasible: bool,
    #[serde(default)]
    pub solving: bool,
    #[serde(default)]
    pub message: String,
}

impl SolverStatusResponse {
    /// Returns true while the solver is still working on this job.
    ///
    /// Mirrors the polling logic used by the other PlanSolve SDKs: a job is
    /// considered "still solving" until it reports `NOT_SOLVING` with a score.
    pub fn is_solving(&self) -> bool {
        self.solving
            || self.solver_status == SolverStatus::SolvingScheduled
            || self.solver_status != SolverStatus::NotSolving
            || self.score.is_empty()
    }
}

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

    fn status(solving: bool, s: SolverStatus, score: &str) -> SolverStatusResponse {
        SolverStatusResponse {
            job_id: "j".into(),
            solver_status: s,
            score: score.into(),
            feasible: true,
            solving,
            message: String::new(),
        }
    }

    #[test]
    fn done_only_when_idle_with_a_score() {
        // The one and only "finished" combination.
        assert!(!status(false, SolverStatus::NotSolving, "0hard/0medium/0soft").is_solving());
    }

    #[test]
    fn still_solving_in_every_other_case() {
        assert!(status(true, SolverStatus::NotSolving, "0hard/0medium/0soft").is_solving());
        assert!(status(false, SolverStatus::SolvingActive, "0hard/0medium/0soft").is_solving());
        assert!(status(false, SolverStatus::SolvingScheduled, "0hard/0medium/0soft").is_solving());
        // Idle + NotSolving but no score yet => not done.
        assert!(status(false, SolverStatus::NotSolving, "").is_solving());
    }

    #[test]
    fn deserializes_known_and_unknown_statuses() {
        let known: SolverStatusResponse =
            serde_json::from_str(r#"{"solverStatus":"SOLVING_ACTIVE"}"#).unwrap();
        assert_eq!(known.solver_status, SolverStatus::SolvingActive);

        // Unrecognized status values fall back to Unknown instead of erroring.
        let unknown: SolverStatusResponse =
            serde_json::from_str(r#"{"solverStatus":"SOMETHING_NEW"}"#).unwrap();
        assert_eq!(unknown.solver_status, SolverStatus::Unknown);
    }

    #[test]
    fn tolerates_missing_optional_fields() {
        // Only solverStatus is required; the rest default.
        let s: SolverStatusResponse =
            serde_json::from_str(r#"{"solverStatus":"NOT_SOLVING"}"#).unwrap();
        assert_eq!(s.job_id, "");
        assert!(!s.feasible);
        assert!(s.score.is_empty());
    }
}