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 has two variants, and they answer different questions:

Variant Meaning Project state after the error
Error::Input(InputError) The data or options you passed are invalid. Unchanged. The previous accepted state and its cached result are kept.
Error::Project(ProjectError) 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 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 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 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 covers the one place where the library can crash instead of returning an Err.

Input errors#

An InputError carries three fields:

  • kind is an InputErrorKind. 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)) or schedule 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, TaskData and AssignmentData.

Reporting a validation error with its path#

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 (and of Error::Input) 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, a solve(Some(data)) that fails validation installs nothing. The project keeps its accepted baseline, and solve(None) still returns the result you had before:

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 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)). 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#

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

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)) again. The recovery how-to 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 Dependency cycles. A cyclic solve returns unchanged dates and empty updates, and still installs a baseline.
ScheduleResult::conflicts ConstraintViolation (a link and a hard constraint disagree, the constraint wins) and CalendarNonWorking
ScheduleResult::notes 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 Operations that apply refused. The other operations in the batch were still applied.
CandidateResult::error An evaluate candidate whose change set was rejected. That candidate has no metrics; the other candidates are scored normally.

Checking a schedule for problems#

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.

Repair instead of reject#

Validation::Strict suits data your own code produces. For data from a UI or an older dhtmlxGantt store, Validation::Repair 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.

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 shows the repair flow end to end.

The skill layer's MatchError#

The plen-assign crate has its own error type. slots returns Result<Vec<Slot>, MatchError>, and MatchError has two variants:

  • Invalid { path, message } for a bad pool or requirement. The path uses the same format as InputError: 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 before slots, including after apply.

MatchError converts into plen_core::Error: 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 with ? and handle both through one type:

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.