diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index dffa44f..6d502df 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -11,5 +11,5 @@ jobs: security_audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 725b2ee..baaee82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: name: Test with nightly runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@nightly - name: Run tests run: cargo test @@ -30,7 +30,7 @@ jobs: name: Test with stable runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - name: Run tests run: cargo test @@ -39,7 +39,7 @@ jobs: name: Rustfmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@nightly with: components: rustfmt @@ -50,7 +50,7 @@ jobs: name: Clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -62,8 +62,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - - run: cargo install cargo-udeps - - name: Check for unused dependencies - run: cargo +nightly udeps + uses: actions/checkout@v5 + - name: Machete + uses: bnjbvr/cargo-machete@main diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index 03c08a7..cc6305b 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -1,4 +1,4 @@ -name: Release-plz +name: Post-merge permissions: pull-requests: write @@ -13,15 +13,21 @@ jobs: release-plz: name: Release-plz runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'Krahos' }} + concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly + uses: dtolnay/rust-toolchain@stable - name: Run release-plz - uses: MarcoIeni/release-plz-action@v0.5 + uses: release-plz/action@v0.5 + with: + command: release-pr env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} @@ -30,7 +36,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - name: Install tarpaulin run: cargo install cargo-tarpaulin diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml deleted file mode 100644 index 5a366c4..0000000 --- a/.github/workflows/release-plz.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Post merge - -permissions: - pull-requests: write - contents: write - -on: - push: - branches: - - main - -jobs: - release-plz: - name: Release-plz - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly - - name: Run release-plz - uses: MarcoIeni/release-plz-action@v0.5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - coverage: - name: Code coverage - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - name: Install tarpaulin - run: cargo install cargo-tarpaulin - - name: Generate code coverage - run: cargo tarpaulin --verbose --workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f13d8..12a1fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.3](https://github.com/Krahos/planter-core/compare/v0.0.2...v0.0.3) - 2025-09-12 + +### Other + +- Updating github actions ([#7](https://github.com/Krahos/planter-core/pull/7)) + ## [0.0.2](https://github.com/Krahos/planter-core/compare/v0.0.1...v0.0.2) - 2025-05-31 ### Other diff --git a/Cargo.lock b/Cargo.lock index d1532e7..0006f36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,7 +487,7 @@ dependencies = [ [[package]] name = "planter-core" -version = "0.0.2" +version = "0.0.3" dependencies = [ "anyhow", "bon", diff --git a/Cargo.toml b/Cargo.toml index e18ba7e..c7508f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "planter-core" -version = "0.0.2" +version = "0.0.3" edition = "2024" authors = ["Sebastiano Giordano"] description = "Domain logic for PlanTer, a project management application" diff --git a/deny.toml b/deny.toml index a32a134..8c04840 100644 --- a/deny.toml +++ b/deny.toml @@ -88,7 +88,20 @@ ignore = [ # List of explicitly allowed licenses # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. -allow = ["MIT", "Apache-2.0", "Unicode-3.0", "AGPL-3.0-or-later", "GPL-3.0", "GPL-2.0", "LGPL-3.0", "LGPL-2.1", "AGPL-1.0", "MPL-2.0", "EPL-2.0"] +allow = [ + "MIT", + "Apache-2.0", + "Unicode-3.0", + "AGPL-3.0-or-later", + "GPL-3.0", + "GPL-2.0", + "LGPL-3.0", + "LGPL-2.1", + "AGPL-1.0", + "MPL-2.0", + "EPL-2.0", + "Zlib" +] # The confidence threshold for detecting a license from license text. # The higher the value, the more closely the license text must be to the # canonical license text of a valid SPDX license file. diff --git a/src/project.rs b/src/project.rs index 5a6542e..9a60f4b 100644 --- a/src/project.rs +++ b/src/project.rs @@ -10,8 +10,13 @@ use daggy::{ visit::{IntoNeighborsDirected, IntoNodeIdentifiers}, }, }; +use thiserror::Error; -use crate::{resources::Resource, stakeholders::Stakeholder, task::Task}; +use crate::{ + resources::{Material, Resource}, + stakeholders::Stakeholder, + task::Task, +}; #[derive(Debug, Default, Builder)] #[builder(on(String, into))] @@ -622,12 +627,41 @@ impl Project { /// }); /// /// assert!(project.resource(0).is_some()); - /// # assert!(project.resource(1).is_none()); /// ``` pub fn resource(&self, index: usize) -> Option<&Resource> { self.resources.get(index) } + /// Remove a resource from the project. + /// + /// # Panics + /// + /// Panics if the resource index is out of bounds. + /// + /// # Example + /// + /// ``` + /// use planter_core::{resources::Resource, project::Project, person::Person}; + /// + /// let mut project = Project::new("World domination"); + /// project.add_resource(Resource::Personnel { + /// person: Person::new("Sebastiano", "Giordano").unwrap(), + /// hourly_rate: None, + /// }); + /// + /// assert!(project.resource(0).is_some()); + /// project.rm_resource(0); + /// assert!(project.resource(0).is_none()); + /// + /// let result = std::panic::catch_unwind(move || { + /// project.rm_resource(0); + /// }); + /// assert!(result.is_err()); + /// ``` + pub fn rm_resource(&mut self, index: usize) -> Resource { + self.resources.remove(index) + } + /// Get a mutable reference to a resource used in the project. /// /// # Example @@ -674,6 +708,88 @@ impl Project { &self.resources } + /// Converts a resource into a `Consumable`, if that's possible. + /// + /// # Arguments + /// + /// * resource_index - The index of a `Material`, that's not a `Consumable` + /// + /// # Errors + /// + /// * `ResourceConversionError::ResourceNotFound` - If a resource with the specified index does not exist. + /// * `ResourceConversionError::ConversionNotPossible` - When trying to convert a resource that's not a `Material`. + /// + /// # Example + /// + /// ``` + /// use planter_core::{resources::{Resource, Material, NonConsumable}, project::Project}; + /// + /// let mut project = Project::new("World domination"); + /// project.add_resource(Resource::Material(Material::NonConsumable( + /// NonConsumable::new("Crowbar"), + /// ))); + /// assert!(project.res_into_consumable(0).is_ok()); + /// ``` + /// + pub fn res_into_consumable( + &mut self, + resource_index: usize, + ) -> Result<(), ResourceConversionError> { + let res = self + .resources + .get_mut(resource_index) + .ok_or(ResourceConversionError::ResourceNotFound)?; + match res { + Resource::Material(Material::NonConsumable(non_consumable)) => { + *res = Resource::Material(Material::Consumable(non_consumable.clone().into())) + } + Resource::Material(Material::Consumable(_)) => {} + _ => return Err(ResourceConversionError::ConversionNotPossible), + } + Ok(()) + } + + /// Converts a resource into a `NonConsumable`, if that's possible. + /// + /// # Arguments + /// + /// * resource_index - The index of a `Material`, that's not a `NonConsumable` + /// + /// # Errors + /// + /// * `ResourceConversionError::ResourceNotFound` - If a resource with the specified index does not exist. + /// * `ResourceConversionError::ConversionNotPossible` - When trying to convert a resource that's not a `Material`. + /// + /// # Example + /// + /// ``` + /// use planter_core::{resources::{Resource, Material, Consumable}, project::Project}; + /// + /// let mut project = Project::new("World domination"); + /// project.add_resource(Resource::Material(Material::Consumable( + /// Consumable::new("Stimpack"), + /// ))); + /// assert!(project.res_into_nonconsumable(0).is_ok()); + /// ``` + pub fn res_into_nonconsumable( + &mut self, + resource_index: usize, + ) -> Result<(), ResourceConversionError> { + let res = self + .resources + .get_mut(resource_index) + .ok_or(ResourceConversionError::ResourceNotFound)?; + + match res { + Resource::Material(Material::Consumable(consumable)) => { + *res = Resource::Material(Material::NonConsumable(consumable.clone().into())); + } + Resource::Material(Material::NonConsumable(_)) => {} + _ => return Err(ResourceConversionError::ConversionNotPossible), + } + Ok(()) + } + /// Adds a stakeholder to the project. /// /// # Arguments @@ -717,6 +833,17 @@ impl Project { } } +/// Represents an error that can occur when trying to convert `Material` resources variants to another variant. +#[derive(Error, Debug, PartialEq, Eq)] +pub enum ResourceConversionError { + /// Used when trying to convert a resource with an index out of bounds. + #[error("The resource with the specified index wasn't found")] + ResourceNotFound, + /// Used when trying to convert a resource that's not a material, for example personnel. + #[error("Tried to convert a resource that's not a material")] + ConversionNotPossible, +} + #[cfg(test)] /// Utilities to test `[Project]` pub mod test_utils { @@ -773,7 +900,14 @@ mod tests { use proptest::prelude::*; use rand::{Rng, rng}; - use crate::project::test_utils::{project_graph_strategy, project_strategy}; + use crate::{ + person::Person, + project::{ + Project, ResourceConversionError, + test_utils::{project_graph_strategy, project_strategy}, + }, + resources::{Consumable, Material, NonConsumable, Resource}, + }; proptest! { #[test] fn update_predecessors_rejects_circular_graphs(mut project in project_graph_strategy()) { @@ -909,4 +1043,94 @@ mod tests { assert!(successors.next().is_none()); } } + + #[test] + fn res_into_consumable_returns_the_correct_errors() { + let mut project = Project::new("World domination"); + + project.add_resource(Resource::Personnel { + person: Person::new("Sebastiano", "Giordano").unwrap(), + hourly_rate: None, + }); + + // The correct error when trying to convert a reasource that's not a `Material`. + assert_eq!( + project.res_into_consumable(0), + Err(ResourceConversionError::ConversionNotPossible) + ); + + // The correct error when trying to convert a reasource that does not exist. + assert_eq!( + project.res_into_consumable(1), + Err(ResourceConversionError::ResourceNotFound) + ); + + // Doesn't return an error when trying to convert a `Consumable` into a `Consumable`. + project.add_resource(Resource::Material(Material::Consumable(Consumable::new( + "Stimpack", + )))); + + assert!(project.res_into_consumable(1).is_ok()); + + // Doesn't change the type of the resource when not needed. + if let Resource::Material(Material::Consumable(_)) = project.resources()[1] { + } else { + panic!("It changed the resource type"); + } + + // It changes it when needed. + project.add_resource(Resource::Material(Material::NonConsumable( + NonConsumable::new("Crowbar"), + ))); + project.res_into_consumable(2).unwrap(); + if let Resource::Material(Material::Consumable(_)) = project.resources()[2] { + } else { + panic!("It didn't change the resource type"); + } + } + + #[test] + fn res_into_nonconsumable_returns_the_correct_errors() { + let mut project = Project::new("World domination"); + + project.add_resource(Resource::Personnel { + person: Person::new("Sebastiano", "Giordano").unwrap(), + hourly_rate: None, + }); + + // The correct error when trying to convert a resource that's not a `Material`. + assert_eq!( + project.res_into_nonconsumable(0), + Err(ResourceConversionError::ConversionNotPossible) + ); + + // The correct error when trying to convert a resource that does not exist. + assert_eq!( + project.res_into_nonconsumable(1), + Err(ResourceConversionError::ResourceNotFound) + ); + + // Doesn't return an error when trying to convert a `NonConsumable` into a `NonConsumable`. + project.add_resource(Resource::Material(Material::NonConsumable( + NonConsumable::new("Crowbar"), + ))); + + assert!(project.res_into_nonconsumable(1).is_ok()); + + // Doesn't change the type of the resource when not needed. + if let Resource::Material(Material::NonConsumable(_)) = project.resources()[1] { + } else { + panic!("It changed the resource type"); + } + + // It changes it when needed. + project.add_resource(Resource::Material(Material::Consumable(Consumable::new( + "Stimpack", + )))); + project.res_into_nonconsumable(2).unwrap(); + if let Resource::Material(Material::NonConsumable(_)) = project.resources()[2] { + } else { + panic!("It didn't change the resource type"); + } + } } diff --git a/src/resources.rs b/src/resources.rs index d2ad0dd..3a3c6db 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -54,6 +54,27 @@ pub struct NonConsumable { hourly_rate: Option, } +impl From for Consumable { + fn from(value: NonConsumable) -> Self { + Consumable { + name: value.name, + quantity: value.quantity, + cost_per_unit: value.quantity, + } + } +} + +impl From for NonConsumable { + fn from(value: Consumable) -> Self { + NonConsumable { + name: value.name, + quantity: value.quantity, + cost_per_unit: value.cost_per_unit, + hourly_rate: None, + } + } +} + impl Material { /// Returns a consumable material by default, with the given name. pub fn new(name: impl Into) -> Self { diff --git a/tests/project.rs b/tests/project.rs index a1016ce..772d1d7 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -47,15 +47,26 @@ fn test_project() -> anyhow::Result<()> { .add_time_relationship(0, 4, TimeRelationship::StartToFinish) .context("Tasks don't exist or circular dependencies detected")?; - // Add a non consumable material to the project - project.add_resource(Resource::Material(Material::NonConsumable( - NonConsumable::new("Crowbar"), - ))); // Add a consumable material to the project project.add_resource(Resource::Material(Material::Consumable(Consumable::new( - "Stimpack", + "Crowbar", )))); + // Convert the consumable material into a non consumable + project + .res_into_nonconsumable(0) + .context("Failed to convert consumable into non consumable")?; + + // Add a consumable material to the project + project.add_resource(Resource::Material(Material::NonConsumable( + NonConsumable::new("Stimpack"), + ))); + + // Convert the non consumable material into a consumable + project + .res_into_consumable(0) + .context("Failed to convert non consumable into consumable")?; + // Add a personnel resource to the project project.add_resource(Resource::Personnel { person: Person::new("Sebastiano", "Giordano").context("Failed to parse a name.")?, @@ -63,6 +74,10 @@ fn test_project() -> anyhow::Result<()> { }); assert_eq!(project.resources().len(), 3); + // Remove a resource from the project + project.rm_resource(1); + assert_eq!(project.resources().len(), 2); + // Add stakeholders to the project let person = Person::new("Margherita", "Hack").context("Failed to parse a name")?; project.add_stakeholder(Stakeholder::Individual {