diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..db9fc6f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +env: + CARGO_TERM_COLOR: always + SYSTEM_DEPS: >- + build-essential + pkg-config + libglib2.0-dev + libgtk-4-dev + libadwaita-1-dev + libcairo2-dev + libpango1.0-dev + libgdk-pixbuf-xlib-2.0-dev + libwayland-dev + libx11-dev + libxkbcommon-dev + libgirepository1.0-dev + libgtk4-layer-shell-dev + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ${{ env.SYSTEM_DEPS }} + + - name: Setup Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Build + run: cargo build --all-targets + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ${{ env.SYSTEM_DEPS }} + + - name: Setup Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run tests + run: cargo test --all-targets + + lint: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ${{ env.SYSTEM_DEPS }} + + - name: Setup Rust toolchain + uses: dtolnay/rust-action@stable + with: + components: clippy + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run Clippy + run: cargo clippy --all-targets -- -D warnings diff --git a/README.md b/README.md index b000985..099b184 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,12 @@ TODO ## Development -TODO: `./run-dev`, options +tl;dr: +* Run in development: `./run-dev` * Set the `SHEPHERD_MOCK_TIME` environment variable to mock the time, such as `SHEPHERD_MOCK_TIME="2025-12-25 15:30:00" ./run-dev` +* Run tests: `cargo test` +* Lint: `cargo clippy` ## Contributing diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..29e6609 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,10 @@ +# Clippy configuration for shepherd-launcher +# +# This file configures clippy lints for the workspace. + +# Disallow direct usage of Local::now() to ensure mock time is used consistently. +# Use shepherd_util::now() instead, which respects the SHEPHERD_MOCK_TIME env var +# in debug builds. +disallowed-methods = [ + { path = "chrono::Local::now", reason = "Use shepherd_util::now() instead to support mock time in debug builds" }, +] diff --git a/crates/shepherd-api/src/events.rs b/crates/shepherd-api/src/events.rs index 7ea70a2..3125f2e 100644 --- a/crates/shepherd-api/src/events.rs +++ b/crates/shepherd-api/src/events.rs @@ -100,7 +100,7 @@ mod tests { session_id: SessionId::new(), entry_id: EntryId::new("game-1"), label: "Test Game".into(), - deadline: Some(Local::now()), + deadline: Some(shepherd_util::now()), }); let json = serde_json::to_string(&event).unwrap(); diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index 67787ad..f69e0f6 100644 --- a/crates/shepherd-config/src/policy.rs +++ b/crates/shepherd-config/src/policy.rs @@ -349,7 +349,7 @@ mod tests { always: true, }; - let dt = Local::now(); + let dt = shepherd_util::now(); assert!(policy.is_available(&dt)); } diff --git a/crates/shepherd-core/src/engine.rs b/crates/shepherd-core/src/engine.rs index aaaa3e7..c1fab00 100644 --- a/crates/shepherd-core/src/engine.rs +++ b/crates/shepherd-core/src/engine.rs @@ -591,7 +591,7 @@ mod tests { let caps = HostCapabilities::minimal(); let engine = CoreEngine::new(policy, store, caps); - let entries = engine.list_entries(Local::now()); + let entries = engine.list_entries(shepherd_util::now()); assert_eq!(entries.len(), 1); assert!(entries[0].enabled); } @@ -604,7 +604,7 @@ mod tests { let engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test-game"); - let decision = engine.request_launch(&entry_id, Local::now()); + let decision = engine.request_launch(&entry_id, shepherd_util::now()); assert!(matches!(decision, LaunchDecision::Approved(_))); } @@ -617,7 +617,7 @@ mod tests { let mut engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test-game"); - let now = Local::now(); + let now = shepherd_util::now(); let now_mono = MonotonicInstant::now(); // Launch first session @@ -672,7 +672,7 @@ mod tests { let mut engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test"); - let now = Local::now(); + let now = shepherd_util::now(); let now_mono = MonotonicInstant::now(); // Start session @@ -733,7 +733,7 @@ mod tests { let mut engine = CoreEngine::new(policy, store, caps); let entry_id = EntryId::new("test"); - let now = Local::now(); + let now = shepherd_util::now(); let now_mono = MonotonicInstant::now(); // Start session diff --git a/crates/shepherd-store/src/sqlite.rs b/crates/shepherd-store/src/sqlite.rs index 4f7a54b..93198f9 100644 --- a/crates/shepherd-store/src/sqlite.rs +++ b/crates/shepherd-store/src/sqlite.rs @@ -114,7 +114,7 @@ impl Store for SqliteStore { let (id, timestamp_str, event_json) = row?; let timestamp = DateTime::parse_from_rfc3339(×tamp_str) .map(|dt| dt.with_timezone(&Local)) - .unwrap_or_else(|_| Local::now()); + .unwrap_or_else(|_| shepherd_util::now()); let event: crate::AuditEventType = serde_json::from_str(&event_json)?; events.push(AuditEvent { @@ -284,7 +284,7 @@ mod tests { fn test_usage_accounting() { let store = SqliteStore::in_memory().unwrap(); let entry_id = EntryId::new("game-1"); - let today = Local::now().date_naive(); + let today = shepherd_util::now().date_naive(); // Initially zero let usage = store.get_usage(&entry_id, today).unwrap(); @@ -314,7 +314,7 @@ mod tests { assert!(store.get_cooldown_until(&entry_id).unwrap().is_none()); // Set cooldown - let until = Local::now() + chrono::Duration::hours(1); + let until = shepherd_util::now() + chrono::Duration::hours(1); store.set_cooldown_until(&entry_id, until).unwrap(); let stored = store.get_cooldown_until(&entry_id).unwrap().unwrap(); @@ -334,7 +334,7 @@ mod tests { // Save snapshot let snapshot = StateSnapshot { - timestamp: Local::now(), + timestamp: shepherd_util::now(), active_session: None, }; store.save_snapshot(&snapshot).unwrap(); diff --git a/crates/shepherd-util/src/time.rs b/crates/shepherd-util/src/time.rs index e0a6baa..7264412 100644 --- a/crates/shepherd-util/src/time.rs +++ b/crates/shepherd-util/src/time.rs @@ -30,6 +30,7 @@ static MOCK_TIME_OFFSET: OnceLock> = OnceLock::new(); /// Initialize the mock time offset based on the environment variable. /// Returns the offset between mock time and real time at process start. +#[allow(clippy::disallowed_methods)] // This is the internal implementation that wraps Local::now() fn get_mock_time_offset() -> Option { *MOCK_TIME_OFFSET.get_or_init(|| { #[cfg(debug_assertions)] @@ -79,6 +80,7 @@ pub fn is_mock_time_active() -> bool { /// In release builds, this always returns the real system time. /// In debug builds, if `SHEPHERD_MOCK_TIME` is set, this returns a time /// that advances from the mock time at the same rate as real time. +#[allow(clippy::disallowed_methods)] // This is the wrapper that provides mock time support pub fn now() -> DateTime { let real_now = chrono::Local::now(); @@ -464,6 +466,7 @@ mod tests { } #[test] + #[allow(clippy::disallowed_methods)] // Testing the offset calculation requires real time fn test_mock_time_offset_calculation() { // Test that the offset calculation works correctly let mock_time_str = "2025-12-25 14:30:00"; @@ -487,6 +490,7 @@ mod tests { } #[test] + #[allow(clippy::disallowed_methods)] // Testing time advancement requires real time fn test_mock_time_advances_with_real_time() { // Test that mock time advances at the same rate as real time // This tests the concept, not the actual implementation (since OnceLock is static)