From ccb8eee86a1df531965f10f190ee50d8281eb05f Mon Sep 17 00:00:00 2001 From: onelrian Date: Wed, 28 Jan 2026 15:19:25 +0100 Subject: [PATCH] feat(config): implement configurable scheduling system - Add configurable assignment_interval_days to Settings struct (1-365 days, defaults to 14) - Create config/default.toml for centralized configuration - Update db::should_run() to accept configurable interval parameter - Replace hardcoded 14-day interval with configuration-driven approach - Add comprehensive config tests with serial execution (7 tests) - Add serial_test dev dependency to prevent env var test interference - Fix doctests in people_config.rs Workflow optimization: - Remove inefficient daily cron trigger from GitHub Actions - Default to manual workflow_dispatch for better resource efficiency - Add APP__ASSIGNMENT_INTERVAL_DAYS environment variable support - Document scheduling strategies (manual, external scheduler, daily cron) Documentation: - Update README.md with configuration guide and scheduling options - Add cross-reference in docs/PEOPLE_DATA.md - Document configuration precedence (env vars > files > defaults) Test results: All 41 tests passing - 7 lib tests + 9 main tests + 7 config tests + 16 people_config tests + 2 doctests Backward compatible: Existing setups continue using 14 days automatically. --- .github/workflows/worker.yml | 16 ++-- Cargo.lock | 90 +++++++++++++++++++++++ Cargo.toml | 3 + README.md | 56 ++++++++++++-- config/default.toml | 7 ++ docs/PEOPLE_DATA.md | 2 + src/config.rs | 31 ++++++++ src/db.rs | 9 ++- src/lib.rs | 4 + src/main.rs | 11 ++- src/people_config.rs | 7 ++ tests/config_test.rs | 138 +++++++++++++++++++++++++++++++++++ 12 files changed, 352 insertions(+), 22 deletions(-) create mode 100644 tests/config_test.rs diff --git a/.github/workflows/worker.yml b/.github/workflows/worker.yml index f55450d..d627cbd 100644 --- a/.github/workflows/worker.yml +++ b/.github/workflows/worker.yml @@ -1,9 +1,12 @@ -name: Bi-Weekly Work Generator +name: Work Assignment Generator +# Default to manual/API triggering for efficiency. +# See README.md for scheduling options (external scheduler, cron, etc.) on: - schedule: - - cron: '0 9 * * *' # Run every day to check eligibility - workflow_dispatch: # Allow manual trigger + workflow_dispatch: # Manual trigger + # Uncomment for daily automated checks (less efficient): + # schedule: + # - cron: '0 9 * * *' # Daily at 9 AM UTC jobs: run-generator: @@ -28,9 +31,10 @@ jobs: - name: Build and Run env: DATABASE_URL: ${{ secrets.DATABASE_URL }} + APP__ASSIGNMENT_INTERVAL_DAYS: ${{ secrets.ASSIGNMENT_INTERVAL_DAYS || '14' }} run: | - # The Rust app itself will handle the "check date" logic. - # It will also set SHOULD_NOTIFY=true/false in GITHUB_ENV. + # The app checks if scheduled interval has passed. + # It sets SHOULD_NOTIFY=true/false in GITHUB_ENV. cargo run --release > output.txt cat output.txt diff --git a/Cargo.lock b/Cargo.lock index 35b6af2..7624d2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,6 +299,42 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -584,6 +620,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" @@ -728,6 +770,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scheduled-thread-pool" version = "0.2.7" @@ -743,6 +794,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "serde" version = "1.0.226" @@ -795,6 +852,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.9" @@ -821,6 +904,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" @@ -1159,6 +1248,7 @@ dependencies = [ "rand", "serde", "serde_json", + "serial_test", "thiserror", "toml 0.8.23", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 3b2c568..3f56fff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1.0" thiserror = "1.0" toml = "0.8" + +[dev-dependencies] +serial_test = "3.2.0" diff --git a/README.md b/README.md index 33070dd..c1676dc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Rust-based application that automatically distributes household chores among r ## Features -- **Automated Scheduling**: Runs daily via GitHub Actions but only generates assignments every 14 days +- **Configurable Scheduling**: Assignment interval fully configurable (1-365 days, defaults to 14) - **Fair Rotation**: Tracks assignment history to ensure people don't get the same tasks repeatedly - **Group-Based Constraints**: Enforces rules based on group membership (Group A vs Group B) - **Discord Integration**: Automatically posts new assignments to Discord when generated @@ -46,7 +46,30 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require RUST_LOG=info ``` -### 3. Run Migrations +### 3. Configure Assignment Interval (Optional) + +The default interval is 14 days. To customize: + +**Option 1: Edit configuration file** + +Edit `config/default.toml`: +```toml +# Assignment interval in days (1-365) +assignment_interval_days = 7 # Weekly +# assignment_interval_days = 14 # Bi-weekly (default) +# assignment_interval_days = 30 # Monthly +``` + +**Option 2: Use environment variable** + +Add to `.env`: +```bash +APP__ASSIGNMENT_INTERVAL_DAYS=7 # Weekly assignments +``` + +> **Note**: Environment variables override file configuration. Valid range: 1-365 days. + +### 4. Run Migrations ```bash diesel migration run @@ -56,7 +79,7 @@ This will: - Create the `people` and `assignments` tables - Seed initial data from legacy files (if present) -### 4. Run the Application +### 5. Run the Application ```bash cargo run @@ -92,17 +115,34 @@ Configure these in your GitHub repository settings: - `DATABASE_URL`: Your Neon PostgreSQL connection string - `DISCORD_WEBHOOK`: Discord webhook URL for notifications +- `ASSIGNMENT_INTERVAL_DAYS` (optional): Override assignment interval (defaults to 14) -### Workflow +### Workflow Scheduling Options -The workflow runs daily but only sends notifications when new assignments are generated: +By default, the workflow uses **manual triggering** (`workflow_dispatch`) for efficiency: ```yaml -schedule: - - cron: '0 9 * * *' # Daily at 9 AM UTC +on: + workflow_dispatch: # Trigger manually or via API ``` -The Rust application enforces the 14-day interval internally. +**Scheduling Strategies:** + +1. **Manual Trigger** (Default) + - Run from GitHub Actions UI when needed + - Most efficient - no unnecessary workflow runs + +2. **External Scheduler** (Recommended for automation) + - Use GitHub API with cron service (cron-job.org, etc.) + - Trigger only when needed based on interval + - Example: Weekly API call triggers workflow + +3. **Daily Cron** (Less efficient but simpler) + - Uncomment cron schedule in `.github/workflows/worker.yml` + - Runs daily but only generates assignments when interval passes + - Can waste Actions minutes on unnecessary checks + +The Rust application enforces the configured interval regardless of trigger method. ## Customization diff --git a/config/default.toml b/config/default.toml index 297c630..e59fa9b 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1,3 +1,10 @@ +# VividShift Default Configuration + +# Assignment interval: How many days between shuffling work assignments +# Valid range: 1-365 days +# Common values: 7 (weekly), 14 (bi-weekly), 30 (monthly) +assignment_interval_days = 14 + [work_assignments] "Parlor" = 5 "Frontyard" = 3 diff --git a/docs/PEOPLE_DATA.md b/docs/PEOPLE_DATA.md index 7385a27..fd90d98 100644 --- a/docs/PEOPLE_DATA.md +++ b/docs/PEOPLE_DATA.md @@ -12,6 +12,8 @@ Structured TOML configuration for managing resident data, replacing legacy `file - **Module**: `src/people_config.rs` - **Tests**: `tests/people_config_test.rs` +> **Related**: See [README.md](../README.md#3-configure-assignment-interval-optional) for scheduling interval configuration. + ### Extending Metadata To add new fields (e.g., email, preferences): diff --git a/src/config.rs b/src/config.rs index c8727e0..2b52d29 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,9 @@ pub struct Settings { pub database_url: String, pub work_assignments: HashMap, pub github_env_path: Option, + /// Configurable interval in days between assignment shuffles + /// Defaults to 14 if not specified + pub assignment_interval_days: Option, } impl Settings { @@ -27,4 +30,32 @@ impl Settings { s.try_deserialize() } + + /// Returns the configured assignment interval in days with validation + /// - Defaults to 14 days if not specified + /// - Validates range: 1-365 days + /// - Invalid values are clamped to valid range + pub fn assignment_interval_days(&self) -> i64 { + match self.assignment_interval_days { + Some(interval) => { + // Validate and clamp to reasonable range + if interval < 1 { + tracing::warn!( + "Invalid assignment_interval_days: {}. Defaulting to 14.", + interval + ); + 14 + } else if interval > 365 { + tracing::warn!( + "Assignment interval {} exceeds maximum (365 days). Using 365.", + interval + ); + 365 + } else { + interval + } + } + None => 14, // Default to 14 days + } + } } diff --git a/src/db.rs b/src/db.rs index ad8f5ee..d94b9dc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -114,8 +114,9 @@ pub fn fetch_history( Ok(history_map) } -/// Checks if it has been 14 days since the last assignment run. -pub fn should_run(conn: &mut PgConnection) -> QueryResult { +/// Checks if enough time has passed since the last assignment run. +/// Uses the configured interval_days instead of a hardcoded value. +pub fn should_run(conn: &mut PgConnection, interval_days: i64) -> QueryResult { use diesel::dsl::max; let last_run: Option = assignments_dsl::assignments @@ -128,8 +129,8 @@ pub fn should_run(conn: &mut PgConnection) -> QueryResult { let days_diff = (now - date).num_days(); info!("Days Now: {} ", now); info!("Days Date: {} ", date); - info!("Days Left: {} ", days_diff); - Ok(days_diff >= 14) + info!("Days since last run: {} (interval: {})", days_diff, interval_days); + Ok(days_diff >= interval_days) } None => Ok(true), // No history, so we should run } diff --git a/src/lib.rs b/src/lib.rs index 69a9dd4..19e02df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,8 @@ //! //! This library provides modules for managing work group assignments. +pub mod config; +pub mod db; +pub mod models; pub mod people_config; +pub mod schema; diff --git a/src/main.rs b/src/main.rs index 7109e6d..f170961 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,11 +49,14 @@ fn main() -> anyhow::Result<()> { let pool = db::establish_connection(&settings.database_url); let mut conn = pool.get().context("Failed to get DB connection")?; - // 4. Check Schedule (14 day rule) - match db::should_run(&mut conn) { - Ok(true) => info!("✅ It has been 14+ days (or first run). Proceeding."), + // 4. Check Schedule (configurable interval) + let interval = settings.assignment_interval_days(); + info!("⏱️ Assignment interval configured: {} days", interval); + + match db::should_run(&mut conn, interval) { + Ok(true) => info!("✅ It has been {}+ days (or first run). Proceeding.", interval), Ok(false) => { - info!("⏳ It has NOT been 14 days since the last run. Skipping."); + info!("⏳ It has NOT been {} days since the last run. Skipping.", interval); set_github_output(false, settings.github_env_path.as_deref()); return Ok(()); } diff --git a/src/people_config.rs b/src/people_config.rs index 4a5b125..ad1c2e6 100644 --- a/src/people_config.rs +++ b/src/people_config.rs @@ -17,9 +17,12 @@ //! ```no_run //! use work_group_generator::people_config::PeopleConfiguration; //! +//! # fn main() -> Result<(), Box> { //! let config = PeopleConfiguration::load()?; //! let group_a_people = config.get_people_by_group("A"); //! let active_people = config.get_active_people(); +//! # Ok(()) +//! # } //! ``` //! //! # Error Handling @@ -132,7 +135,11 @@ impl PeopleConfiguration { /// # Example /// /// ```no_run + /// use work_group_generator::people_config::PeopleConfiguration; + /// # fn main() -> Result<(), Box> { /// let config = PeopleConfiguration::load()?; + /// # Ok(()) + /// # } /// ``` pub fn load() -> Result { Self::load_from_path(Self::DEFAULT_CONFIG_PATH) diff --git a/tests/config_test.rs b/tests/config_test.rs new file mode 100644 index 0000000..58a31a7 --- /dev/null +++ b/tests/config_test.rs @@ -0,0 +1,138 @@ +use work_group_generator::config::Settings; +use serial_test::serial; + +/// Test that config/default.toml provides default interval of 14 days +#[test] +#[serial] +fn test_default_interval_from_config_file() { + // Setup: Ensure no env override + std::env::remove_var("APP__ASSIGNMENT_INTERVAL_DAYS"); + std::env::set_var("DATABASE_URL", "postgres://dummy:dummy@localhost/dummy"); + + // When: Loading settings (reads from config/default.toml) + let settings = Settings::new().expect("Failed to load settings"); + + // Then: Should get 14 from config/default.toml + assert_eq!( + settings.assignment_interval_days(), + 14, + "Default from config file should be 14 days" + ); + + // Cleanup + std::env::remove_var("DATABASE_URL"); +} + +/// Test that environment variable APP__ASSIGNMENT_INTERVAL_DAYS overrides config file +#[test] +#[serial] +fn test_env_override_takes_precedence() { + // Given: Env var set to override config file value (14) + std::env::set_var("APP__ASSIGNMENT_INTERVAL_DAYS", "7"); + std::env::set_var("DATABASE_URL", "postgres://dummy:dummy@localhost/dummy"); + + // When: Loading settings + let settings = Settings::new().expect("Failed to load settings"); + + // Then: Env var should override config file + assert_eq!( + settings.assignment_interval_days(), + 7, + "Environment variable should override config file" + ); + + // Cleanup + std::env::remove_var("APP__ASSIGNMENT_INTERVAL_DAYS"); + std::env::remove_var("DATABASE_URL"); +} + +/// Test validation: values < 1 are clamped to 14 +#[test] +#[serial] +fn test_validation_clamps_zero_to_default() { + std::env::set_var("APP__ASSIGNMENT_INTERVAL_DAYS", "0"); + std::env::set_var("DATABASE_URL", "postgres://dummy:dummy@localhost/dummy"); + + let settings = Settings::new().expect("Failed to load settings"); + + // Validation should clamp 0 to 14 + assert_eq!( + settings.assignment_interval_days(), + 14, + "Zero interval should be clamped to 14" + ); + + // Cleanup + std::env::remove_var("APP__ASSIGNMENT_INTERVAL_DAYS"); + std::env::remove_var("DATABASE_URL"); +} + +/// Test validation: values > 365 are clamped to 365 +#[test] +#[serial] +fn test_validation_clamps_excessive_to_max() { + std::env::set_var("APP__ASSIGNMENT_INTERVAL_DAYS", "500"); + std::env::set_var("DATABASE_URL", "postgres://dummy:dummy@localhost/dummy"); + + let settings = Settings::new().expect("Failed to load settings"); + + // Validation should clamp 500 to 365 + assert_eq!( + settings.assignment_interval_days(), + 365, + "Excessive interval should be clamped to 365" + ); + + // Cleanup + std::env::remove_var("APP__ASSIGNMENT_INTERVAL_DAYS"); + std::env::remove_var("DATABASE_URL"); +} + +/// Test validation: negative values are clamped to 14 +#[test] +#[serial] +fn test_validation_clamps_negative_to_default() { + std::env::set_var("APP__ASSIGNMENT_INTERVAL_DAYS", "-10"); + std::env::set_var("DATABASE_URL", "postgres://dummy:dummy@localhost/dummy"); + + let settings = Settings::new().expect("Failed to load settings"); + + // Validation should clamp negative to 14 + assert_eq!( + settings.assignment_interval_days(), + 14, + "Negative interval should be clamped to 14" + ); + + // Cleanup + std::env::remove_var("APP__ASSIGNMENT_INTERVAL_DAYS"); + std::env::remove_var("DATABASE_URL"); +} + +/// Test common use case: weekly (7-day) interval +#[test] +#[serial] +fn test_weekly_interval_via_env() { + std::env::set_var("APP__ASSIGNMENT_INTERVAL_DAYS", "7"); + std::env::set_var("DATABASE_URL", "postgres://dummy:dummy@localhost/dummy"); + + let settings = Settings::new().expect("Failed to load settings"); + assert_eq!(settings.assignment_interval_days(), 7, "Weekly interval"); + + std::env::remove_var("APP__ASSIGNMENT_INTERVAL_DAYS"); + std::env::remove_var("DATABASE_URL"); +} + +/// Test common use case: monthly (30-day) interval +#[test] +#[serial] +fn test_monthly_interval_via_env() { + std::env::set_var("APP__ASSIGNMENT_INTERVAL_DAYS", "30"); + std::env::set_var("DATABASE_URL", "postgres://dummy:dummy@localhost/dummy"); + + let settings = Settings::new().expect("Failed to load settings"); + assert_eq!(settings.assignment_interval_days(), 30, "Monthly interval"); + + std::env::remove_var("APP__ASSIGNMENT_INTERVAL_DAYS"); + std::env::remove_var("DATABASE_URL"); +}