plansolve 0.25.3

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/solvers/fieldservice/solve";

/// Client for the field service optimization API.
#[derive(Clone)]
pub struct FieldServiceClient {
    transport: Transport,
}

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

    /// Starts a new field service optimization job.
    pub async fn start(&self, request: &FieldServiceRequest) -> Result<FieldServiceStartResponse> {
        self.transport.post_json(ROUTE, request).await
    }

    /// Gets the status of a field service optimization job.
    pub async fn get_status(&self, job_id: &str) -> Result<SolverStatusResponse> {
        self.transport
            .get_json(&format!("{ROUTE}/{job_id}/status"))
            .await
    }

    /// Gets the result of a completed field service optimization job.
    pub async fn get_result(&self, job_id: &str) -> Result<FieldServiceResultResponse> {
        let mut result: FieldServiceResultResponse = self
            .transport
            .get_json(&format!("{ROUTE}/{job_id}"))
            .await?;
        result.job_id = Some(job_id.to_string());
        Ok(result)
    }

    /// Gets analysis for a completed field service optimization job.
    pub async fn analyze(&self, job_id: &str) -> Result<Value> {
        self.transport
            .get_json(&format!("{ROUTE}/{job_id}/analyze"))
            .await
    }

    /// Starts a job and polls until it completes.
    pub async fn start_and_wait_for_completion(
        &self,
        request: &FieldServiceRequest,
        poll_interval_ms: u64,
        max_attempts: u32,
    ) -> Result<FieldServiceResultResponse> {
        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.
    pub async fn wait_for_completion(
        &self,
        job_id: &str,
        poll_interval_ms: u64,
        max_attempts: u32,
    ) -> Result<FieldServiceResultResponse> {
        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;
            }
        }
    }
}

#[cfg(test)]
mod http_tests {
    use super::*;
    use crate::transport::Transport;
    use serde_json::json;
    use wiremock::matchers::{body_json, header, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn client(base_url: String) -> FieldServiceClient {
        FieldServiceClient::new(Transport::new(base_url, "secret-key".into()))
    }

    #[tokio::test]
    async fn start_posts_json_with_api_key_header() {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/api/v1/solvers/fieldservice/solve"))
            .and(header("X-API-KEY", "secret-key"))
            .and(body_json(json!({ "vehicles": [], "visits": [] })))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "jobId": "job-42" })))
            .expect(1)
            .mount(&server)
            .await;

        let resp = client(server.uri())
            .start(&FieldServiceRequest::default())
            .await
            .unwrap();
        assert_eq!(resp.job_id, "job-42");
        // `.expect(1)` is verified on server drop: the request actually fired.
    }

    #[tokio::test]
    async fn non_success_status_maps_to_api_error() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/api/v1/solvers/fieldservice/solve/job-1/status"))
            .respond_with(ResponseTemplate::new(403).set_body_string("forbidden"))
            .mount(&server)
            .await;

        let err = client(server.uri()).get_status("job-1").await.unwrap_err();
        match err {
            Error::Api { status, body } => {
                assert_eq!(status, 403);
                assert!(body.contains("forbidden"), "body was: {body}");
            }
            other => panic!("expected Error::Api, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn get_result_stamps_the_job_id() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/api/v1/solvers/fieldservice/solve/job-7"))
            .respond_with(ResponseTemplate::new(200).set_body_json(
                json!({ "vehicles": [], "visits": [], "totalDrivingTimeSeconds": 0 }),
            ))
            .mount(&server)
            .await;

        let result = client(server.uri()).get_result("job-7").await.unwrap();
        // The API body omits jobId; the client stamps it from the request.
        assert_eq!(result.job_id.as_deref(), Some("job-7"));
    }

    #[tokio::test]
    async fn wait_for_completion_polls_until_done_then_fetches_result() {
        let server = MockServer::start().await;

        // Fallback: "done" once the solving mock is exhausted. Mounted first so
        // it is matched last (wiremock evaluates most-recently-mounted first).
        Mock::given(method("GET"))
            .and(path("/api/v1/solvers/fieldservice/solve/job-9/status"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "solverStatus": "NOT_SOLVING",
                "solving": false,
                "score": "0hard/0medium/-3soft"
            })))
            .mount(&server)
            .await;

        // First poll: still solving. Exhausts after one hit.
        Mock::given(method("GET"))
            .and(path("/api/v1/solvers/fieldservice/solve/job-9/status"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "solverStatus": "SOLVING_ACTIVE",
                "solving": true,
                "score": ""
            })))
            .up_to_n_times(1)
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/api/v1/solvers/fieldservice/solve/job-9"))
            .respond_with(ResponseTemplate::new(200).set_body_json(
                json!({ "vehicles": [], "visits": [], "score": "0hard/0medium/-3soft", "totalDrivingTimeSeconds": 0 }),
            ))
            .expect(1)
            .mount(&server)
            .await;

        // Tight poll interval so the test stays fast.
        let result = client(server.uri())
            .wait_for_completion("job-9", 10, 5)
            .await
            .unwrap();
        assert_eq!(result.job_id.as_deref(), Some("job-9"));
        assert_eq!(result.score.as_deref(), Some("0hard/0medium/-3soft"));
    }

    #[tokio::test]
    async fn wait_for_completion_times_out_if_never_done() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/api/v1/solvers/fieldservice/solve/job-x/status"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "solverStatus": "SOLVING_ACTIVE",
                "solving": true,
                "score": ""
            })))
            .mount(&server)
            .await;

        let err = client(server.uri())
            .wait_for_completion("job-x", 10, 3)
            .await
            .unwrap_err();
        assert!(matches!(err, Error::SolverTimeout));
    }

    #[tokio::test]
    async fn wait_for_completion_rejects_empty_job_id() {
        let err = client("http://unused.invalid".into())
            .wait_for_completion("", 10, 3)
            .await
            .unwrap_err();
        assert!(matches!(err, Error::MissingJobId));
    }
}