Serialize results to JSON#

Turn a scheduling result into JSON for an HTTP response, a log or a cache, and read it back into a typed value.

Prerequisites#

  • A project that already schedules. See Quick start.
  • serde_json, or any other serde format crate.

Steps#

  1. Turn on the serde feature of plen-core and add serde_json:

    cargo add plen-core -features serde
    cargo add serde_json
    

    Without the feature, the result types don't implement Serialize.

  2. Pass the result to serde_json::to_string or serde_json::to_string_pretty. Project::solve returns an Arc<ScheduleResult>, and you can serialize the Arc as is:

    let json = serde_json::to_string_pretty(&result)?;
    
  3. To read the JSON back, deserialize it into ScheduleResult:

    let back: ScheduleResult = serde_json::from_str(&json)?;
    

The full example solves two linked tasks, prints the result as JSON, reads it back and serializes a leveling result too:

use plen_core::{
    DateTime, LevelingOptions, LevelingResult, LinkData, LinkType, Project, ProjectData,
    ScheduleResult, SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 8, 10, 8, 0).ok_or("invalid date")?;

    let mut design = TaskData::new(1);
    design.start_date = Some(monday);
    design.duration = Some(2.0);

    let mut build = TaskData::new("build");
    build.start_date = Some(monday);
    build.duration = Some(1.0);

    let data = ProjectData {
        tasks: vec![design, build],
        links: vec![LinkData {
            id: 1.into(),
            source: 1.into(),
            target: "build".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..Default::default()
    };

    let options = SolveOptions {
        reasons: true,
        ..Default::default()
    };

    let mut project = Project::default();
    let result = project.solve(Some(data), &options)?;

    let json = serde_json::to_string_pretty(&result)?;
    println!("{json}");

    let back: ScheduleResult = serde_json::from_str(&json)?;
    println!("round trip equal: {}", back == *result);

    let leveled: LevelingResult = project.level(&LevelingOptions::default())?;
    println!("{}", serde_json::to_string(&leveled)?);
    Ok(())
}

Result#

The example prints the solve result, the round-trip check and the leveling result. The baselineId value is different on every run:

{
  "baselineId": "1-ad1b2f9c36ab9d21",
  "updates": [
    {
      "id": "build",
      "kind": "task",
      "start_date": "2026-08-12 08:00",
      "end_date": "2026-08-13 08:00",
      "reason": {
        "kind": "asap",
        "linkId": 1,
        "sourceId": 1
      }
    }
  ],
  "cycles": [],
  "conflicts": [],
  "notes": [],
  "converged": true
}
round trip equal: true
{"baselineId":"1-ad1b2f9c36ab9d21","updates":[],"cycles":[],"conflicts":[],"notes":[],"converged":true,"overallocations":[],"rounds":0}

LevelingResult writes the ScheduleResult fields flat, next to rounds. It doesn't nest them under a schedule key.

What the JSON looks like#

  • Result fields are camelCase: baselineId, criticalPath, overallocations.
  • Update entries keep the snake_case task-data names (start_date, end_date, leveling_delay, pauses), so a client can send an entry back as a task update.
  • Reason, Conflict and Note are objects tagged by kind in kebab-case, such as "asap" or "constraint-violation". Their own fields are camelCase: linkId, sourceId.
  • Plain enums are kebab-case strings: "task", "latest-start", "snlt".
  • Dates are "YYYY-MM-DD HH:MM" strings, with no time zone.
  • Ids stay numbers or strings, as in the input. A whole-number id is written as an integer: 1, not 1.0.
  • Other f64 fields follow serde_json, so a whole duration comes out as 2.0.
  • Empty optional fields are left out, never written as null.

baselineId is an opaque token. Send it to the client so it can come back with the next edit, but don't store it as a persistent id.

Other result types#

The same feature covers the other results:

SuggestResult.changes uses the change-set shape that JSON input decoding reads. It holds only its assignment changes and task clears, and it leaves out unset fields.

Send errors as JSON#

Error doesn't implement Serialize. Build the JSON from its public fields yourself:

use serde_json::{json, Value};
use plen_core::{Error, InputErrorKind, Project, ProjectData, SolveOptions, TaskData};

fn error_json(error: &Error) -> Value {
    match error {
        Error::Input(e) => json!({
            "kind": match e.kind {
                InputErrorKind::Parse => "parse",
                InputErrorKind::Validation => "validation",
            },
            "message": e.message,
            "path": e.path,
        }),
        Error::Project(e) => json!({
            "kind": "project",
            "code": e.to_string(),
        }),
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A leaf task with a duration only: validation rejects it.
    let mut task = TaskData::new(1);
    task.duration = Some(1.0);
    let data = ProjectData {
        tasks: vec![task],
        ..Default::default()
    };

    let mut project = Project::default();
    let body = match project.solve(Some(data), &SolveOptions::default()) {
        Ok(result) => json!({ "ok": true, "result": result }),
        Err(e) => json!({ "ok": false, "error": error_json(&e) }),
    };
    println!("{}", serde_json::to_string_pretty(&body)?);
    Ok(())
}

Output:

{
  "error": {
    "kind": "validation",
    "message": "task needs two of start_date / end_date / duration",
    "path": "tasks[0].start_date"
  },
  "ok": false
}

Error handling lists the error kinds and the ProjectError codes.

JSON input#

The serde feature covers output only. Input records such as ProjectData, SchedulingChanges and the call options don't implement Deserialize. JSON input decoding and {ok, result | error} envelopes live in the separate plen-wire crate, which the CLI and the WASM package use.

<!- TODO: plen-wire is not in the cheatsheet's public surface; link its docs once it has a public page -->