[Canonical page](<https://docs.dhtmlx.com/planning-engine/rust/error-handling/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Error handling

This guide covers how Planning Engine reports failures in Rust: which problems come back as an `Err`, which arrive as data inside a successful result, and how to recover a live project after each kind.

## How failures surface

Every fallible call returns `Result<_, plen_core::Error>`. The library has no async API, so there's no second failure path: each call is synchronous and either returns a value or an `Err`.

[`Error`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/error.md>) has two variants, and they answer different questions:

| Variant | Meaning | Project state after the error |
|---|---|---|
| [`Error::Input(InputError)`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/input-error.md>) | The data or options you passed are invalid. | Unchanged. The previous accepted state and its cached result are kept. |
| [`Error::Project(ProjectError)`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/project-error.md>) | The input may be fine, but the call doesn't fit the project's current state. | Unchanged. You fix it by calling the right method first. |

[`ProjectError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/project-error.md>) has three variants:

| Variant | Display | When it occurs |
|---|---|---|
| `NoBaseline` | `no-baseline` | `preview`, `load_profile`, `evaluate`, `suggest_assignments` or `roster` on a project that has no solved baseline, including right after `apply` |
| `StaleBaseline` | `stale-baseline` | A `baseline_id` in the options or in [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/scheduling-changes.md>) doesn't name the current baseline |
| `NeedsResync` | `needs-resync` | `solve(None)`, `apply` or `level` after an `apply` rejected an operation, until a full `solve(Some(data))` succeeds |

Most of what goes wrong in a plan isn't an error at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected [`apply`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#apply>) operations come back inside `Ok(...)`. An `Err` means the call itself couldn't run.

`Error` implements `std::error::Error` (through `thiserror`), so `?` converts it into `Box<dyn std::error::Error>` or `anyhow::Error` without extra code. [Panics](<https://docs.dhtmlx.com/planning-engine/rust/error-handling.md#panics>) covers the one place where the library can crash instead of returning an `Err`.

## Input errors

An [`InputError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/input-error.md>) carries three fields:

- `kind` is an [`InputErrorKind`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/input-error-kind.md>). Native calls produce `Validation`. `Parse` comes only from JSON decoding in the separate `plen-wire` crate.
- `message` is a short description, such as `invalid duration` or `duplicate task id`.
- `path` points at the offending field, in the form `tasks[0].duration`, `assignments[1].id` or `scope.resources`. It's `None` only for wire decode errors.

With the default `Validation::Strict`, a [`solve(Some(data))`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>) or [`schedule`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/schedule.md>) call rejects the input on the first problem it finds. Strict validation rejects, among other things:

- a leaf task with fewer than two of `start_date`, `end_date` and `duration`, or with all three when they don't agree
- a dated constraint (`Snet`, `Fnlt`, ...) without a `constraint_date`
- duplicate task, link or calendar ids (`1` and `"1"` count as the same id)
- an unknown `parent`, or a cycle in the `parent` hierarchy
- a summary with `start_date` + `duration` (a summary takes both dates or neither)
- a negative `duration`, a `priority` above 1000, a negative `leveling_delay`
- an assignment to an unknown task or resource, or with `value: 0`
- `week`, `month` or `year` duration units, and calendars with no working time

The full list per field is on the reference pages for [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/project-data.md>), [`TaskData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/task-data.md>) and [`AssignmentData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/assignment-data.md>).

### Reporting a validation error with its path

```rust
use plen_core::{schedule, DateTime, Error, ProjectData, SolveOptions, TaskData};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut task = TaskData::new(1);
    task.start_date = Some(DateTime::from_ymd_hm(2026, 1, 5, 8, 0).ok_or("invalid date")?);
    task.duration = Some(-1.0);
    let data = ProjectData {
        tasks: vec![task],
        ..Default::default()
    };

    match schedule(data, &SolveOptions::default()) {
        Ok(result) => println!("{} tasks moved", result.updates.len()),
        Err(Error::Input(e)) => {
            // Prints "tasks[0].duration: invalid duration"
            let path = e.path.as_deref().unwrap_or("<input>");
            eprintln!("{path}: {}", e.message);
        }
        Err(e) => return Err(e.into()),
    }
    Ok(())
}
```

The `Display` text of an [`InputError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/input-error.md>) (and of [`Error::Input`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/error.md>)) is only the `message`. Read `path` yourself when you need to point the user at the field.

### A rejected replacement keeps the old state

On a live [`Project`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md>), a [`solve(Some(data))`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>) that fails validation installs nothing. The project keeps its accepted baseline, and `solve(None)` still returns the result you had before:

```rust
use plen_core::{DateTime, Error, Project, ProjectData, SolveOptions, TaskData};

fn leaf(id: i32, duration: f64) -> Result<TaskData, Box<dyn std::error::Error>> {
    let mut task = TaskData::new(id);
    task.start_date = Some(DateTime::from_ymd_hm(2026, 1, 5, 8, 0).ok_or("invalid date")?);
    task.duration = Some(duration);
    Ok(task)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let options = SolveOptions::default();
    let mut project = Project::default();
    let first = project.solve(
        Some(ProjectData { tasks: vec![leaf(1, 2.0)?], ..Default::default() }),
        &options,
    )?;

    let bad = ProjectData { tasks: vec![leaf(2, -1.0)?], ..Default::default() };
    if let Err(Error::Input(e)) = project.solve(Some(bad), &options) {
        eprintln!("replacement rejected: {e}");
    }

    // Still the first data set.
    let again = project.solve(None, &options)?;
    assert_eq!(again, first);
    Ok(())
}
```

## Project errors

A [`ProjectError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/project-error.md>) tells you the call came at the wrong moment. Each variant has one recovery:

| Variant | Recovery |
|---|---|
| `NoBaseline` | Call `solve` first. After `apply`, call `solve(None)` before `preview`, `load_profile`, `evaluate` or `suggest_assignments`. |
| `StaleBaseline` | The project was solved, edited or leveled since you read the token. Take `baseline_id` from the latest result and retry. |
| `NeedsResync` | Send the full data again with [`solve(Some(data))`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>). Nothing else is accepted until that call succeeds. |

`ProjectError` is `Copy` and `PartialEq`, so you can match on it directly or compare it.

### Telling the variants apart

```rust
use plen_core::{Error, Project, PreviewOptions, ProjectError, SchedulingChanges};

fn preview_or_explain(project: &Project, changes: &SchedulingChanges, baseline_id: String) -> String {
    let options = PreviewOptions {
        baseline_id: Some(baseline_id),
        ..Default::default()
    };
    match project.preview(changes, &options) {
        Ok(result) => format!("{} tasks would move", result.updates.len()),
        Err(Error::Project(ProjectError::NoBaseline)) => "solve the project first".into(),
        Err(Error::Project(ProjectError::StaleBaseline)) => {
            "the plan changed since you loaded it; refresh and try again".into()
        }
        Err(Error::Project(ProjectError::NeedsResync)) => "reload the full project".into(),
        Err(Error::Input(e)) => format!("invalid change at {:?}: {}", e.path, e.message),
    }
}

fn main() {
    let project = Project::default();
    // An unsolved project: prints "solve the project first".
    println!("{}", preview_or_explain(&project, &SchedulingChanges::default(), String::new()));
}
```

[`preview`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#preview>) checks both `options.baseline_id` and `changes.baseline_id`. Baseline tokens are opaque and can differ between runs for the same data, so never store them across processes or compare them in tests.

### Recovering from `NeedsResync`

A rejected operation in `apply` isn't an `Err`. It's listed in [`ApplyReport::rejected`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/apply-report.md>) and sets `needs_resync`. From then on, `solve(None)`, `apply` and `level` fail with `NeedsResync`, because your model and the project's model no longer agree. Resync with the full data you hold:

```rust
use std::sync::Arc;
use plen_core::{
    DateTime, Error, Patch, Project, ProjectData, ScheduleResult, SchedulingChanges, SolveOptions,
    TaskData, TaskUpdate,
};

fn commit(
    project: &mut Project,
    changes: &SchedulingChanges,
    source_of_truth: &ProjectData,
) -> Result<Arc<ScheduleResult>, Error> {
    let options = SolveOptions::default();
    let report = project.apply(changes)?;
    if report.needs_resync {
        for rejected in &report.rejected {
            eprintln!("rejected {}: {}", rejected.path, rejected.message);
        }
        return project.solve(Some(source_of_truth.clone()), &options);
    }
    project.solve(None, &options)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut task = TaskData::new(1);
    task.start_date = Some(DateTime::from_ymd_hm(2026, 1, 5, 8, 0).ok_or("invalid date")?);
    task.duration = Some(2.0);
    let data = ProjectData { tasks: vec![task], ..Default::default() };

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

    // Task 999 doesn't exist: the operation is rejected.
    let mut update = TaskUpdate::new(999);
    update.duration = Patch::Value(3.0);
    let mut changes = SchedulingChanges::default();
    changes.tasks.update.push(update);

    let result = commit(&mut project, &changes, &data)?;
    println!("resynced, baseline {:?}", result.baseline_id);
    Ok(())
}
```

If the resync data itself fails validation, the project stays in the `NeedsResync` state. Fix the data and call [`solve(Some(data))`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>) again. The [recovery how-to](<https://docs.dhtmlx.com/planning-engine/rust/how-to/recover-from-rejected-changes.md>) covers this flow in an editor.

## Results that are not errors

These all come back in `Ok(...)`. Check them on every result you show to a user:

| Where | What it means |
|---|---|
| [`ScheduleResult::cycles`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/cycle.md>) | Dependency cycles. A cyclic solve returns unchanged dates and empty `updates`, and still installs a baseline. |
| [`ScheduleResult::conflicts`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/conflict.md>) | `ConstraintViolation` (a link and a hard constraint disagree, the constraint wins) and `CalendarNonWorking` |
| [`ScheduleResult::notes`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>) | Input the engine dropped (`DroppedLink`, `DroppedTask`, `DroppedAssignment`) or fixed (`Repaired`) |
| `ScheduleResult::converged` | `false` when the planner didn't reach a fixed point within `config.max_iterations`. The dates are still valid output. |
| [`ApplyReport::rejected`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/changes/rejected-operation.md>) | Operations that [`apply`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#apply>) refused. The other operations in the batch were still applied. |
| [`CandidateResult::error`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/evaluate/candidate-result.md>) | An [`evaluate`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#evaluate>) candidate whose change set was rejected. That candidate has no metrics; the other candidates are scored normally. |

### Checking a schedule for problems

```rust
use plen_core::{
    schedule, Conflict, DateTime, LinkData, LinkType, ProjectData, SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let start = DateTime::from_ymd_hm(2026, 1, 5, 8, 0).ok_or("invalid date")?;
    let mut a = TaskData::new("a");
    a.start_date = Some(start);
    a.duration = Some(1.0);
    let mut b = TaskData::new("b");
    b.start_date = Some(start);
    b.duration = Some(1.0);

    // a -> b -> a: a dependency cycle, reported as data.
    let link = |id: &str, source: &str, target: &str| LinkData {
        id: id.into(),
        source: source.into(),
        target: target.into(),
        link_type: LinkType::Fs,
        lag: 0.0,
        lag_unit: None,
    };
    let data = ProjectData {
        tasks: vec![a, b],
        links: vec![link("l1", "a", "b"), link("l2", "b", "a")],
        ..Default::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for cycle in &result.cycles {
        println!("cycle through tasks {:?}", cycle.tasks);
    }
    for conflict in &result.conflicts {
        if let Conflict::ConstraintViolation { task, required, actual, .. } = conflict {
            println!("task {task}: required {required}, got {actual}");
        }
    }
    if !result.converged {
        println!("planner hit max_iterations");
    }
    Ok(())
}
```

Dependency cycles are accepted state. Cycles in the `parent` hierarchy are different: they're invalid input and fail with [`Error::Input`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/error.md>).

## Repair instead of reject

`Validation::Strict` suits data your own code produces. For data from a UI or an older dhtmlxGantt store, [`Validation::Repair`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/validation.md>) applies gantt's coercions instead of failing:

- duplicate ids: the last record wins, at the first record's position
- missing dates or durations are derived
- in an inconsistent start/end/duration triple the dates win
- dated constraints without a date are cleared
- invalid `pauses` lists and assignment slice values are cleared

Each fix shows up as a `Note::Repaired { path, message }`, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still return [`Error::Input`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/error.md>).

```rust
use plen_core::{
    DateTime, Note, Project, ProjectData, ProjectOptions, SolveOptions, TaskData, Validation,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut first = TaskData::new(1);
    first.start_date = Some(DateTime::from_ymd_hm(2026, 1, 5, 8, 0).ok_or("invalid date")?);
    first.duration = Some(1.0);
    let mut duplicate = first.clone();
    duplicate.duration = Some(2.0);
    let data = ProjectData {
        tasks: vec![first, duplicate],
        ..Default::default()
    };

    let mut project = Project::new(ProjectOptions {
        validation: Validation::Repair,
    });
    let result = project.solve(Some(data), &SolveOptions::default())?;
    for note in &result.notes {
        if let Note::Repaired { path, message } = note {
            println!("repaired {path}: {message}");
        }
    }
    Ok(())
}
```

The [messy input how-to](<https://docs.dhtmlx.com/planning-engine/rust/how-to/accept-messy-input.md>) shows the repair flow end to end.

## The skill layer's `MatchError`

The `plen-assign` crate has its own error type. [`slots`](<https://docs.dhtmlx.com/planning-engine/rust/api/assign/slots.md>) returns `Result<Vec<Slot>, MatchError>`, and [`MatchError`](<https://docs.dhtmlx.com/planning-engine/rust/api/assign/match-error.md>) has two variants:

- `Invalid { path, message }` for a bad pool or requirement. The path uses the same format as [`InputError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/input-error.md>): `pool.workers[0].resource`, `requirements[2].count`, `requirements[1].assignments[0]`. `Display` prints `"{path}: {message}"`.
- `Project(ProjectError)` when the project has no clean solved baseline (`NoBaseline`). Call [`solve`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>) before `slots`, including after [`apply`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#apply>).

`MatchError` converts into [`plen_core::Error`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/error.md>): `Invalid` becomes `Error::Input` with kind `Validation` and the same path, and `Project` becomes `Error::Project`. A function that returns `plen_core::Error` can call `slots` and [`suggest_assignments`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#suggest_assignments>) with `?` and handle both through one type:

```rust
use plen_assign::{slots, Pool, Requirement, SkillMatch, Skill, Worker};
use plen_core::{Error, Project, SuggestOptions, SuggestResult};

fn staff(project: &Project, pool: &Pool, requirements: &[Requirement]) -> Result<SuggestResult, Error> {
    let slots = slots(project, pool, requirements)?; // MatchError -> Error
    project.suggest_assignments(&slots, &SuggestOptions::default())
}

fn main() {
    let pool = Pool {
        workers: vec![Worker {
            resource: "dev1".into(),
            skills: vec![Skill { id: "rust".into(), value: 1.0 }],
        }],
    };
    let requirements = vec![Requirement::new("backend", 1, vec![SkillMatch::at_least("rust", 1.0)])];

    // An unsolved project: the error converts to Error::Project(NoBaseline).
    match staff(&Project::default(), &pool, &requirements) {
        Ok(result) => println!("{} steps", result.steps.len()),
        Err(Error::Input(e)) => eprintln!("{}: {}", e.path.unwrap_or_default(), e.message),
        Err(Error::Project(e)) => eprintln!("project state: {e}"),
    }
}
```

## Panics

Valid input never panics. 
