[Canonical page](<https://docs.dhtmlx.com/planning-engine/rust/how-to/serialize-results-to-json/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# 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](<https://docs.dhtmlx.com/planning-engine/rust/quick-start.md>).
- `serde_json`, or any other serde format crate.

## Steps

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

   ```bash
   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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md>) returns an `Arc<ScheduleResult>`, and you can serialize the `Arc` as is:

   ```rust
   let json = serde_json::to_string_pretty(&result)?;
   ```

3. To read the JSON back, deserialize it into [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/schedule-result.md>):

   ```rust
   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:

```rust
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:

```json
{
  "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
}
```

```text
round trip equal: true
{"baselineId":"1-ad1b2f9c36ab9d21","updates":[],"cycles":[],"conflicts":[],"notes":[],"converged":true,"overallocations":[],"rounds":0}
```

[`LevelingResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/resources/leveling-result.md>) writes the [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/schedule-result.md>) 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/reason.md>), [`Conflict`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/conflict.md>) and [`Note`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>) 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:

| Type | Serialize | Deserialize |
|---|---|---|
| [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/schedule-result.md>), [`LevelingResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/resources/leveling-result.md>), [`EvaluateResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/evaluate/evaluate-result.md>), [`LoadProfile`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/resources/load-profile.md>), [`ApplyReport`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/apply-report.md>) | yes | yes |
| [`Id`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/id.md>), [`DateTime`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/date-time.md>), [`Patch<T>`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/patch.md>), [`Pause`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/pause.md>), [`PauseSource`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/pause-source.md>), and the enums [`TimeUnit`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/time-unit.md>), [`ConstraintType`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/constraint-type.md>), [`GapBehavior`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/gap-behavior.md>), [`CalendarMode`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/calendar-mode.md>), [`Basis`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/basis.md>) | yes | yes |
| [`SuggestResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/suggest/suggest-result.md>), [`Slot`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/suggest/slot.md>) | yes | no |

`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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/error.md>) doesn't implement `Serialize`. Build the JSON from its public fields yourself:

```rust
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:

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

[Error handling](<https://docs.dhtmlx.com/planning-engine/rust/error-handling.md>) lists the error kinds and the [`ProjectError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/project-error.md>) codes.

## JSON input

The `serde` feature covers output only. Input records such as [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/project-data.md>), [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/scheduling-changes.md>) 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 -->
