From 1b148f1d001c93467d32584a5caddf4b89048442 Mon Sep 17 00:00:00 2001 From: Gil Sharon Date: Tue, 28 Jul 2026 15:19:08 -0400 Subject: [PATCH 1/2] feat: add parameters setup template Add template selection to the Ixa project setup script, ncluding a validated parameters starter and CI coverage for generated projects. --- .github/workflows/test-setup-new-project.yaml | 77 ++++++- docs/book/src/get-started.md | 23 ++- scripts/setup_new_ixa_project.sh | 191 +++++++++++++----- scripts/templates/parameters/main.rs | 31 +++ scripts/templates/parameters/parameters.rs | 139 +++++++++++++ 5 files changed, 407 insertions(+), 54 deletions(-) create mode 100644 scripts/templates/parameters/main.rs create mode 100644 scripts/templates/parameters/parameters.rs diff --git a/.github/workflows/test-setup-new-project.yaml b/.github/workflows/test-setup-new-project.yaml index b56d8649..2857745e 100644 --- a/.github/workflows/test-setup-new-project.yaml +++ b/.github/workflows/test-setup-new-project.yaml @@ -17,8 +17,13 @@ jobs: include: - flow_name: default ixa_branch: "" - - flow_name: release + template: "" + - flow_name: parameters + ixa_branch: "" + template: parameters + - flow_name: release-blank ixa_branch: release + template: blank steps: - uses: actions/checkout@v7 @@ -26,6 +31,12 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-env + - name: Select repository Rust toolchain + shell: bash + run: | + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + - name: Create project with setup script shell: bash run: | @@ -36,16 +47,70 @@ jobs: echo "PROJECT_DIR=$project_dir" >> "$GITHUB_ENV" cd "$project_dir" - if [ -z "${{ matrix.ixa_branch }}" ]; then - curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh - else - curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh -s "${{ matrix.ixa_branch }}" + setup_args=() + if [ -n "${{ matrix.ixa_branch }}" ]; then + setup_args+=("${{ matrix.ixa_branch }}") + fi + if [ -n "${{ matrix.template }}" ]; then + setup_args+=(--template "${{ matrix.template }}") fi + IXA_SETUP_ASSET_BASE_URL="file://$GITHUB_WORKSPACE" \ + sh "$GITHUB_WORKSPACE/scripts/setup_new_ixa_project.sh" "${setup_args[@]}" + + - name: cargo fmt + run: cargo fmt --check + working-directory: ${{ env.PROJECT_DIR }} + + - name: cargo test + run: cargo test + working-directory: ${{ env.PROJECT_DIR }} + - name: cargo run run: cargo run working-directory: ${{ env.PROJECT_DIR }} - name: cargo clippy - run: cargo clippy + run: cargo clippy -- -D warnings working-directory: ${{ env.PROJECT_DIR }} + + invalid-arguments: + name: invalid setup arguments + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Reject invalid template arguments before project creation + shell: bash + run: | + set -euo pipefail + project_dir="$(mktemp -d)" + script="$GITHUB_WORKSPACE/scripts/setup_new_ixa_project.sh" + cd "$project_dir" + + assert_rejected() { + local name="$1" + local expected="$2" + shift 2 + + if sh "$script" "$@" > "$name.log" 2>&1; then + echo "$name unexpectedly succeeded" + exit 1 + fi + grep -F "$expected" "$name.log" + test ! -e Cargo.toml + } + + assert_rejected unknown-template \ + "Unknown template 'unknown'" --template unknown + assert_rejected missing-template \ + "Missing value for --template" --template + assert_rejected unknown-option \ + "Unknown option: --unknown" --unknown + assert_rejected multiple-branches \ + "Only one Ixa branch may be specified" main release + + sh "$script" --help > help.log + grep -F -- "--template " help.log + test ! -e Cargo.toml diff --git a/docs/book/src/get-started.md b/docs/book/src/get-started.md index fe206cf5..84cfc042 100644 --- a/docs/book/src/get-started.md +++ b/docs/book/src/get-started.md @@ -12,10 +12,11 @@ cargo new --bin ixa_model cd ixa_model ``` -Use Ixa's new project setup script to setup the project for Ixa. +Use Ixa's new project setup script to set up the project for Ixa. By default, +the script installs the blank starter used by this guide. ```bash -curl -s https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh -s +curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh ``` Open `src/main.rs` in your favorite editor or IDE to verify the model looks like @@ -43,3 +44,21 @@ To run with logging enabled for just `ixa_model`: ```bash cargo run -- --log-level ixa_model=trace ``` + +## Other templates + +When creating a new project, you can select the `parameters` template to start +with a parameters module, parameter validation, and basic global property +integration: + +```bash +curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | + sh -s -- --template parameters +``` + +This template creates `src/main.rs` and `src/parameters.rs`. Running +`cargo run` validates and stores the default parameters, then prints: + +```text +The current time is 100 +``` diff --git a/scripts/setup_new_ixa_project.sh b/scripts/setup_new_ixa_project.sh index 3de0918e..790e5806 100755 --- a/scripts/setup_new_ixa_project.sh +++ b/scripts/setup_new_ixa_project.sh @@ -1,71 +1,170 @@ -# To be run in a new project directory. +#!/bin/sh +# +# Run in a new project directory. +# +# Install the default blank template: # curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh -# or if you want to use a specific branch and not the cargo release -# curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh -s -# ixa-branch: the branch of Ixa to use, default is main +# +# Install the parameters template: +# curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh -s -- --template parameters +# +# Use a specific Ixa branch instead of the released crate: +# curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh -s -- --template + ixa_branch="main" +ixa_branch_was_set="false" +template="blank" + +usage() { + printf '%s\n' \ + "Usage: setup_new_ixa_project.sh [ixa-branch] [--template ]" \ + "" \ + "Set up a new Ixa project in the current directory." \ + "" \ + "Arguments:" \ + " ixa-branch Optional Ixa Git branch. Uses the released crate when omitted." \ + "" \ + "Options:" \ + " --template Project template: blank (default) or parameters." \ + " -h, --help Show this help message." +} + +fail() { + printf 'Error: %s\n' "$1" >&2 + printf "Run 'setup_new_ixa_project.sh --help' for usage.\n" >&2 + exit 2 +} urlencode() { - local tmp="${1}" - local encoded="" - - while [ -n "$tmp" ]; do - rest="${tmp#?}" # All but the first character of the string - first="${tmp%"$rest"}" # Remove $rest, and you're left with the first character - case "$first" in - [a-zA-Z0-9.~_-]) encoded="$encoded$first" ;; - *) encoded="$encoded$(printf '%%%02X' "'$first")" ;; + urlencode_tmp=$1 + urlencode_encoded="" + + while [ -n "$urlencode_tmp" ]; do + urlencode_rest="${urlencode_tmp#?}" + urlencode_first="${urlencode_tmp%"$urlencode_rest"}" + case "$urlencode_first" in + [a-zA-Z0-9.~_-]) + urlencode_encoded="$urlencode_encoded$urlencode_first" + ;; + *) + urlencode_encoded="$urlencode_encoded$(printf '%%%02X' "'$urlencode_first")" + ;; esac - tmp="$rest" + urlencode_tmp=$urlencode_rest done - echo "$encoded" + printf '%s\n' "$urlencode_encoded" } -if [ -n "$1" ]; then - ixa_branch=$(urlencode $1) +while [ "$#" -gt 0 ]; do + case "$1" in + --template) + if [ "$#" -lt 2 ] || [ -z "$2" ]; then + fail "Missing value for --template." + fi + template=$2 + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + ;; + -*) + fail "Unknown option: $1" + ;; + *) + if [ "$ixa_branch_was_set" = "true" ]; then + fail "Only one Ixa branch may be specified." + fi + ixa_branch=$1 + ixa_branch_was_set="true" + shift + ;; + esac +done + +case "$template" in + blank | parameters) ;; + *) + fail "Unknown template '$template'. Expected 'blank' or 'parameters'." + ;; +esac + +ixa_asset_ref=$(urlencode "$ixa_branch") +# CI overrides the asset base so pull requests test files from their checkout. +if [ -n "${IXA_SETUP_ASSET_BASE_URL:-}" ]; then + ixa_asset_base_url=${IXA_SETUP_ASSET_BASE_URL%/} +else + ixa_asset_base_url="https://raw.githubusercontent.com/CDCgov/ixa/$ixa_asset_ref" fi -# function to check if last shell command was successful, if not print input message and exit -check_success() { - if [ $? -ne 0 ]; then - echo $1 - exit +download_asset() { + download_source=$1 + download_destination=$2 + download_description=$3 + + if ! curl -s -f -L -o "$download_destination" \ + "$ixa_asset_base_url/$download_source"; then + printf 'Failed to download %s from Ixa.\n' "$download_description" >&2 + exit 1 fi } -echo "Setting up new Ixa project with branch $ixa_branch" +printf 'Setting up new Ixa project with branch %s and template %s\n' \ + "$ixa_branch" "$template" -# check if cargo is installed if [ -z "$(command -v cargo)" ]; then - echo "cargo could not be found, run:" - echo "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" - exit + printf '%s\n' \ + "cargo could not be found, run:" \ + "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" >&2 + exit 1 fi -# check if cargo.toml does not exists if [ ! -f "Cargo.toml" ]; then - echo "Creating Cargo project" - cargo init + printf 'Creating Cargo project\n' + if ! cargo init; then + printf 'Failed to create Cargo project.\n' >&2 + exit 1 + fi fi -if [ -z "$1" ]; then - cargo add ixa +if [ "$ixa_branch_was_set" = "false" ]; then + if ! cargo add ixa; then + printf 'Failed to add the released Ixa crate.\n' >&2 + exit 1 + fi else - cargo add --git "https://github.com/CDCgov/ixa" ixa --branch $ixa_branch + if ! cargo add --git "https://github.com/CDCgov/ixa" \ + --branch "$ixa_branch" ixa; then + printf 'Failed to add Ixa from branch %s.\n' "$ixa_branch" >&2 + exit 1 + fi fi -# add .gitignore from Ixa -curl -s -f -o .gitignore "https://raw.githubusercontent.com/CDCgov/ixa/$ixa_branch/.gitignore" -check_success "Failed to download .gitignore from Ixa" - -# add the clippy.toml from Ixa -curl -s -f -o clippy.toml https://raw.githubusercontent.com/CDCgov/ixa/$ixa_branch/clippy.toml -check_success "Failed to download clippy.toml from Ixa" +download_asset ".gitignore" ".gitignore" ".gitignore" +download_asset "clippy.toml" "clippy.toml" "clippy.toml" -# override main.rs with Ixa basic example -curl -s -f -o src/main.rs https://raw.githubusercontent.com/CDCgov/ixa/$ixa_branch/examples/basic/main.rs -check_success "Failed to download main.rs from Ixa" +case "$template" in + blank) + download_asset "examples/basic/main.rs" "src/main.rs" \ + "the blank template" + ;; + parameters) + if ! cargo add serde --features derive; then + printf 'Failed to add Serde for the parameters template.\n' >&2 + exit 1 + fi + download_asset "scripts/templates/parameters/main.rs" "src/main.rs" \ + "the parameters template main.rs" + download_asset "scripts/templates/parameters/parameters.rs" \ + "src/parameters.rs" "the parameters template parameters.rs" + ;; +esac -echo "Project setup complete from branch $ixa_branch" -echo "Run 'cargo run' to test the example code" -echo "Check out the Ixa documentation for more examples and usage: https://ixa.rs/" +printf 'Project setup complete from branch %s with template %s\n' \ + "$ixa_branch" "$template" +printf "%s\n" \ + "Run 'cargo run' to test the example code" \ + "Check out the Ixa documentation for more examples and usage: https://ixa.rs/" diff --git a/scripts/templates/parameters/main.rs b/scripts/templates/parameters/main.rs new file mode 100644 index 00000000..c8289d71 --- /dev/null +++ b/scripts/templates/parameters/main.rs @@ -0,0 +1,31 @@ +mod parameters; + +use ixa::prelude::*; + +use crate::parameters::Parameters; + +define_global_property!(Params, Parameters); + +fn main() { + let parameters = Parameters::default(); + parameters.validate().expect("invalid Parameters"); + + let mut context = Context::new(); + context + .set_global_property_value(Params, parameters) + .expect("parameters should only be initialized once"); + + let (seed, max_time) = { + let parameters = context + .get_global_property_value(Params) + .expect("parameters should be initialized"); + (parameters.seed, parameters.max_time) + }; + + context.init_random(seed); + context.add_plan(max_time, |context| { + println!("The current time is {}", context.get_current_time()); + context.shutdown(); + }); + context.execute(); +} diff --git a/scripts/templates/parameters/parameters.rs b/scripts/templates/parameters/parameters.rs new file mode 100644 index 00000000..84822a86 --- /dev/null +++ b/scripts/templates/parameters/parameters.rs @@ -0,0 +1,139 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Parameters { + /// New infections per unit time per infectious individual. + pub infection_rate: f64, + /// Mean time an individual remains infectious. + pub infectious_period: f64, + pub population: usize, + pub initial_infections: usize, + pub seed: u64, + pub max_time: f64, +} + +impl Default for Parameters { + fn default() -> Self { + Self { + infection_rate: 0.5, + infectious_period: 3.0, + population: 10_000, + initial_infections: 5, + seed: 0, + max_time: 100.0, + } + } +} + +impl Parameters { + pub fn validate(&self) -> Result<(), String> { + if !self.infection_rate.is_finite() || self.infection_rate < 0.0 { + return Err(format!( + "infection_rate must be a finite non-negative number, got {}", + self.infection_rate + )); + } + if !self.infectious_period.is_finite() || self.infectious_period <= 0.0 { + return Err(format!( + "infectious_period must be a finite positive number, got {}", + self.infectious_period + )); + } + if !self.max_time.is_finite() || self.max_time < 0.0 { + return Err(format!( + "max_time must be a finite non-negative number, got {}", + self.max_time + )); + } + if self.initial_infections > self.population { + return Err(format!( + "initial_infections ({}) must not exceed population ({})", + self.initial_infections, self.population + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_valid() { + Parameters::default().validate().unwrap(); + } + + #[test] + fn rejects_negative_infection_rate() { + let parameters = Parameters { + infection_rate: -1.0, + ..Parameters::default() + }; + assert!(parameters.validate().is_err()); + } + + #[test] + fn rejects_non_finite_infection_rate() { + let parameters = Parameters { + infection_rate: f64::NAN, + ..Parameters::default() + }; + assert!(parameters.validate().is_err()); + } + + #[test] + fn accepts_zero_infection_rate() { + let parameters = Parameters { + infection_rate: 0.0, + ..Parameters::default() + }; + parameters.validate().unwrap(); + } + + #[test] + fn rejects_zero_infectious_period() { + let parameters = Parameters { + infectious_period: 0.0, + ..Parameters::default() + }; + assert!(parameters.validate().is_err()); + } + + #[test] + fn rejects_non_finite_infectious_period() { + let parameters = Parameters { + infectious_period: f64::INFINITY, + ..Parameters::default() + }; + assert!(parameters.validate().is_err()); + } + + #[test] + fn rejects_negative_max_time() { + let parameters = Parameters { + max_time: -1.0, + ..Parameters::default() + }; + assert!(parameters.validate().is_err()); + } + + #[test] + fn rejects_non_finite_max_time() { + let parameters = Parameters { + max_time: f64::NAN, + ..Parameters::default() + }; + assert!(parameters.validate().is_err()); + } + + #[test] + fn rejects_initial_infections_exceeding_population() { + let parameters = Parameters { + population: 10, + initial_infections: 11, + ..Parameters::default() + }; + assert!(parameters.validate().is_err()); + } +} From df30cae6de8a7722d920d82604e6033ea983d5e2 Mon Sep 17 00:00:00 2001 From: Gil Sharon Date: Tue, 28 Jul 2026 15:28:46 -0400 Subject: [PATCH 2/2] fix: install Rust toolchain config during project setup --- .github/workflows/test-setup-new-project.yaml | 7 +------ docs/book/src/get-started.md | 4 ++++ scripts/setup_new_ixa_project.sh | 5 +++++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-setup-new-project.yaml b/.github/workflows/test-setup-new-project.yaml index 2857745e..c7fb3803 100644 --- a/.github/workflows/test-setup-new-project.yaml +++ b/.github/workflows/test-setup-new-project.yaml @@ -31,12 +31,6 @@ jobs: - name: Setup environment uses: ./.github/actions/setup-env - - name: Select repository Rust toolchain - shell: bash - run: | - toolchain="$(rustup show active-toolchain | awk '{print $1}')" - echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" - - name: Create project with setup script shell: bash run: | @@ -57,6 +51,7 @@ jobs: IXA_SETUP_ASSET_BASE_URL="file://$GITHUB_WORKSPACE" \ sh "$GITHUB_WORKSPACE/scripts/setup_new_ixa_project.sh" "${setup_args[@]}" + test -f rust-toolchain.toml - name: cargo fmt run: cargo fmt --check diff --git a/docs/book/src/get-started.md b/docs/book/src/get-started.md index 84cfc042..4d3dc6f1 100644 --- a/docs/book/src/get-started.md +++ b/docs/book/src/get-started.md @@ -19,6 +19,10 @@ the script installs the blank starter used by this guide. curl -s -f -L https://raw.githubusercontent.com/CDCgov/ixa/main/scripts/setup_new_ixa_project.sh | sh ``` +The script also installs Ixa's `rust-toolchain.toml`. When Cargo is managed by +rustup, this file automatically selects or installs the compatible Rust +toolchain. + Open `src/main.rs` in your favorite editor or IDE to verify the model looks like the following: diff --git a/scripts/setup_new_ixa_project.sh b/scripts/setup_new_ixa_project.sh index 790e5806..2aada0db 100755 --- a/scripts/setup_new_ixa_project.sh +++ b/scripts/setup_new_ixa_project.sh @@ -122,6 +122,11 @@ if [ -z "$(command -v cargo)" ]; then exit 1 fi +# Install the selected branch's toolchain configuration before invoking Cargo +# so rustup can select or install the compatible Rust toolchain automatically. +download_asset "rust-toolchain.toml" "rust-toolchain.toml" \ + "rust-toolchain.toml" + if [ ! -f "Cargo.toml" ]; then printf 'Creating Cargo project\n' if ! cargo init; then