planter-core 0.0.7

Domain logic for PlanTer, a project management application
Documentation
//! An end-to-end tour of the `Project` API: build a project, organize its tasks, attach
//! resources and costs to it and read the results back. Follows one project from creation to
//! a cost estimate, in the order a new user of the library would likely do it.

use anyhow::Context;
use chrono::{Duration, Utc};
use planter_core::{
    money::{Currency, Money},
    person::Person,
    project::{Project, TimeRelationship},
    resources::{Purchase, Resource},
    stakeholders::Stakeholder,
    task::Task,
};
use uuid::Uuid;

#[test]
fn test_project() -> anyhow::Result<()> {
    // === Creating a project ===
    // A project starts with a name; description and start date are optional.
    let start_date = Utc::now();
    let mut project = Project::builder()
        .name("World domination")
        .description("My second attempt to conquer the world with a crowbar and a stimpack")
        .start_date(start_date)
        .build();

    // === Adding tasks ===
    // Tasks are created independently, then added to the project.
    let crowbar_id = project.add_task(Task::new("Find a crowbar"));
    let stimpack_id = project.add_task(Task::new("Find a stimpack"));
    let software_id = project.add_task(Task::new("Open a proprietary software house"));
    let prey_id = project.add_task(Task::new("Prey on free software projects"));
    let profit_id = project.add_task(Task::new("Profit"));
    assert_eq!(project.tasks().count(), 5);

    // === Organizing tasks ===
    // Tasks can be nested into subtasks, ordered with time relationships, and reordered
    // or reparented later on.
    project
        .add_subtask(stimpack_id, software_id)
        .context("Failed to add subtask")?;
    project
        .add_subtask(stimpack_id, prey_id)
        .context("Failed to add subtask")?;
    assert_eq!(project.subtasks(stimpack_id).count(), 2);

    assert_eq!(project.task_parent(software_id), Some(stimpack_id));
    assert!(project.task_parent(crowbar_id).is_none());

    // A subtask can be detached back to top-level, and reattached later.
    project.remove_subtask(software_id)?;
    assert!(project.task_parent(software_id).is_none());
    assert_eq!(project.subtasks(stimpack_id).count(), 1);

    project.add_subtask(stimpack_id, software_id)?;
    assert_eq!(project.subtasks(stimpack_id).count(), 2);

    project
        .add_time_relationship(crowbar_id, stimpack_id, TimeRelationship::StartToFinish)
        .context("Tasks don't exist or circular dependencies detected")?;
    project
        .add_time_relationship(crowbar_id, profit_id, TimeRelationship::StartToFinish)
        .context("Tasks don't exist or circular dependencies detected")?;

    // New tasks can be inserted next to an existing one, and existing tasks can be moved
    // around, both of which affect display order only.
    let sibling = project.add_sibling_before(Task::new("Sibling before"), profit_id)?;
    let order: Vec<_> = project.tasks().map(|t| t.id()).collect();
    assert!(
        order.iter().position(|&id| id == sibling).unwrap()
            < order.iter().position(|&id| id == profit_id).unwrap()
    );

    project.move_task_after(profit_id, crowbar_id)?;
    let order: Vec<_> = project.tasks().map(|t| t.id()).collect();
    assert_eq!(
        order.iter().position(|&id| id == profit_id).unwrap(),
        order.iter().position(|&id| id == crowbar_id).unwrap() + 1
    );

    // === Resources: one-time purchases ===
    // A resource can carry a purchase history; its title can be edited without losing that
    // history or its id.
    let crowbar_resource_id = project.add_resource(Resource::new("Crobwar".parse()?));
    project
        .add_purchase(
            crowbar_resource_id,
            Purchase::builder()
                .quantity(5)
                .unit_price(Money::from_minor_units(150, Currency::EUR))
                .build(),
        )
        .context("Crowbar resource not found")?;

    project
        .resource_mut(crowbar_resource_id)
        .context("Crowbar resource not found")?
        .set_title("Crowbar".parse()?);
    let crowbar_res = project
        .resource(crowbar_resource_id)
        .context("Crowbar resource not found")?;
    assert_eq!(crowbar_res.title(), "Crowbar");
    assert_eq!(crowbar_res.purchases().count(), 1);
    assert_eq!(
        crowbar_res.purchases().next().unwrap().total(),
        Money::from_minor_units(750, Currency::EUR),
    );

    // === Resources: people, contacts, and removal ===
    // A resource can also represent a person, optionally with a stakeholder contact attached.
    let stimpack_res_id = project.add_resource(Resource::new("Stimpack".parse()?));

    project.add_resource(Resource::new("Sebastiano Giordano".parse()?).with_contact(
        Stakeholder::individual(
            Person::new("Sebastiano", "Giordano").context("Failed to parse a name.")?,
            None,
        ),
    ));
    assert_eq!(project.resources().count(), 3);

    // Resources can be removed just as freely as they're added.
    let _ = project.rm_resource(stimpack_res_id);
    assert_eq!(project.resources().count(), 2);

    // === Resources: hourly rates ===
    // A resource can instead (or also) charge by the hour a task engages it.
    let server_id = project.add_resource(
        Resource::new("Server".parse()?)
            .at_hourly_rate(Money::from_minor_units(2_000, Currency::EUR)),
    );
    let interns_id = project.add_resource(
        Resource::new("Interns".parse()?)
            .at_hourly_rate(Money::from_minor_units(1_500, Currency::EUR)),
    );

    // A consultant who invoices in USD.
    let consultant_id = project.add_resource(
        Resource::new("Mastro Geppetto".parse()?)
            // The contact for a Person resource could be the person itself, but also someone else (EG their boss).
            .with_contact(Stakeholder::individual(
                Person::new("Geppetto", "Mastro").context("Failed to parse a name.")?,
                Some("Invoices in USD".to_owned()),
            ))
            .at_hourly_rate(Money::from_minor_units(10_000, Currency::USD)),
    );

    // === Assigning resources to tasks ===
    // A task needs a duration before an hourly-rate resource assigned to it can be costed.
    project.edit_task_duration(crowbar_id, Duration::hours(2).try_into().unwrap())?;
    project
        .assign_resource(crowbar_id, server_id)
        .context("Failed to assign the server")?;

    project.edit_task_duration(profit_id, Duration::hours(3).try_into().unwrap())?;
    // A task can engage more than one unit of the same resource.
    project
        .assign_resource_units(profit_id, interns_id, std::num::NonZeroU32::new(2).unwrap())
        .context("Failed to assign interns")?;

    project
        .assign_resource(profit_id, consultant_id)
        .context("Failed to assign the consultant")?;

    assert!(project.assign_resource(crowbar_id, Uuid::new_v4()).is_err());

    // === Computing the total cost ===
    // Each currency is tracked separately rather than silently converted.
    // EUR: purchases (crowbars, bought once: 5 * 150) + usage (server: 2_000/h * 2h,
    // interns: 1_500/h * 3h * 2). USD: the consultant, 10_000/h * 3h, kept separate from EUR.
    let expected_cost = Money::from_minor_units(750 + 4_000 + 9_000, Currency::EUR)
        + Money::from_minor_units(30_000, Currency::USD);
    assert_eq!(project.total_cost(), expected_cost);

    // === Project details and stakeholders ===
    let end_date = Utc::now();
    project.set_end_date(end_date);
    assert_eq!(project.end_date(), Some(end_date));

    let person = Person::new("Margherita", "Hack").context("Failed to parse a name")?;
    project.add_stakeholder(Stakeholder::Individual {
        person,
        description: Some("She could try to stop me".to_owned()),
    });
    project.add_stakeholder(Stakeholder::Organization {
        name: "Acme".to_owned(),
        description: Some("They might decide to buy me more stimpacks".to_owned()),
    });
    assert_eq!(project.stakeholders().len(), 2);

    // === Keeping dates in sync ===
    // Editing a subtask's start/finish automatically rolls the change up through every
    // ancestor.
    let now = Utc::now();
    project.edit_task_start(prey_id, now)?;
    assert_eq!(
        project
            .task(stimpack_id)
            .context("Stimpack task not found")?
            .start(),
        Some(now),
    );

    Ok(())
}