diff --git a/.claude/worktrees/focalpoint-build5-fix b/.claude/worktrees/focalpoint-build5-fix new file mode 160000 index 00000000..c3635946 --- /dev/null +++ b/.claude/worktrees/focalpoint-build5-fix @@ -0,0 +1 @@ +Subproject commit c36359465ad142ea85ca8fa4d0bd732ba65698d7 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3128bcfe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,69 @@ +.git +.gitignore +*.md +.env* +!.env.example +# Build artifacts +target/ +dist/ +build/ +*.o +*.a +*.so +# IDE +.vscode/ +.idea/ +*.swp +.DS_Store +# Test/nested +**/node_modules +**/target +**/.pytest_cache +**/__pycache__ +**/*.test +**/tests/ +# Logs +*.log +**/*.log +# Coverage reports +coverage/ +.coverage +*.coverage +# Python virtual environments +venv/ +env/ +virtualenv/ +# npm/yarn +package-lock.json +yarn.lock +pnpm-lock.yaml +# Rust +**/.cargo +**/Cargo.lock +# Go +go.sum +vendor/ +go-build/ +# Java +*.class +**/.gradle +**/build/ +**/target/ +**/.maven/ +# Node.js +.next/ +.nuxt/ +/.output/ +.dist/ +# TypeScript +**/tsconfig.tsbuildinfo +# Docker compose +docker-compose.override.yml +# Local dev files +local.env +.env.local +# Temp files +*.tmp +*.temp +**/temp/ +**/tmp/ diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..f75a96b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Phenotype org + url: https://github.com/KooshaPari + about: Other Phenotype-ecosystem repos and discussions diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index b436c607..5b753175 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -8,11 +8,17 @@ on: schedule: - cron: '37 5 * * 3' workflow_dispatch: + +permissions: + contents: read + actions: read + jobs: audit: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.1.1 - - uses: rustsec/audit-check@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index b4c031c6..f945add9 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -15,17 +15,24 @@ on: schedule: - cron: '0 9 * * 1' +permissions: + contents: read + actions: read + jobs: cargo-deny: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: 1.85 - name: Run cargo-deny - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v6 + uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v2.0.17 with: rust-version: stable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6175042..3b2147c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,19 @@ name: CI on: [push, pull_request] + +permissions: + contents: read + actions: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: 1.85 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: cargo test --all-features --workspace - - run: cargo clippy --all-features -- -D warnings 2>/dev/null || cargo check + - run: cargo clippy --all-features -- -D warnings diff --git a/.github/workflows/journey-gate.yml b/.github/workflows/journey-gate.yml index b735f183..accaf332 100644 --- a/.github/workflows/journey-gate.yml +++ b/.github/workflows/journey-gate.yml @@ -44,6 +44,10 @@ on: default: 'false' type: boolean +permissions: + contents: read + actions: read + env: PHENOTYPE_JOURNEY_STRICT: ${{ inputs.strict_mode || 'true' }} @@ -55,7 +59,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 # --------------------------------------------------------------------- # 1. Install runtime dependencies @@ -126,7 +130,7 @@ jobs: exit 1 fi - COUNT=$(echo "$MANIFESTS" | grep -c . || true) + COUNT=$(echo "$MANIFESTS" | grep -c .) echo "MANIFEST_COUNT=$COUNT" >> $GITHUB_OUTPUT echo "MANIFEST_LIST<> $GITHUB_OUTPUT echo "$MANIFESTS" >> $GITHUB_OUTPUT @@ -177,7 +181,7 @@ jobs: exit 1 fi else - phenotype-journey assert "$manifest" || true + phenotype-journey assert "$manifest" echo "(non-strict run — violations do not fail the build)" fi done @@ -234,6 +238,7 @@ jobs: stub-mode: name: Journey Gate — No Manifests Found runs-on: ubuntu-latest + timeout-minutes: 10 needs: journey-gate if: needs.journey-gate.result == 'failure' && needs.journey-gate.outputs.MANIFEST_COUNT == '0' steps: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 402155fc..f09ab269 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -6,12 +6,15 @@ on: schedule: - cron: "0 0 * * 0" -permissions: read-all +permissions: + contents: read + actions: read jobs: scorecard: name: Scorecard analysis runs-on: ubuntu-latest + timeout-minutes: 15 permissions: security-events: write id-token: write @@ -19,16 +22,16 @@ jobs: actions: read steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - name: Run Scorecard - uses: ossf/scorecard-action@v2.4.4 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload SARIF results - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3 with: sarif_file: results.sarif diff --git a/.github/workflows/trufflehog.yml b/.github/workflows/trufflehog.yml index 96d30140..e638270b 100644 --- a/.github/workflows/trufflehog.yml +++ b/.github/workflows/trufflehog.yml @@ -4,14 +4,18 @@ on: branches: [main] pull_request: +permissions: + contents: read + actions: read + jobs: trufflehog: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - - uses: trufflehog/actions/setup@main - - run: trufflehog github --only-verified --no-update - env: - # + - uses: trufflesecurity/trufflehog@17456f8c7d042d8c82c9a8ca9e937231f9f42e26 # v3.95.2 + with: + extra_args: --only-verified --no-update diff --git a/.gitignore b/.gitignore index d9fd195d..79541b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ pr_details.jsonl # Rust build artifacts /target + +# Example FPL scripts (scaffold output) +/examples/fpl diff --git a/CLAUDE.md b/CLAUDE.md index 128a3e07..d995f427 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Connector-first screen-time management platform. Native iOS enforcement built on ## Stack | Layer | Technology | |-------|------------| -| Core | Rust (cargo workspace, 54+ crates) | +| Core | Rust (cargo workspace, 56 crates) | | Mobile | Swift/SwiftUI (iOS native app) | | Backend | Go (services/) | | DB | SQLite, PostgreSQL, SurrealDB | @@ -34,7 +34,7 @@ xcrun simctl list devices ``` ## Key Files -- `crates/` — 54 Rust workspace crates +- `crates/` — 56 Rust workspace crates - `apps/` — Application entry points (iOS, CLI, etc.) - `services/` — Go backend services - `tooling/` — Build and developer tooling diff --git a/Cargo.lock b/Cargo.lock index d026113d..ab739d92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2311,52 +2311,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "focus-ffi" -version = "0.0.12" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "connector-canvas", - "connector-gcal", - "connector-github", - "focus-always-on", - "focus-audit", - "focus-backup", - "focus-calendar", - "focus-coaching", - "focus-connectors", - "focus-connectors-mock-familycontrols", - "focus-crypto", - "focus-demo-seed", - "focus-domain", - "focus-eval", - "focus-events", - "focus-mascot", - "focus-penalties", - "focus-planning", - "focus-policy", - "focus-rewards", - "focus-rituals", - "focus-rules", - "focus-scheduler", - "focus-storage", - "focus-sync", - "focus-templates", - "reqwest", - "secrecy", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tokio", - "uniffi", - "uniffi_build", - "uuid", - "wiremock", -] - [[package]] name = "focus-icon-gen" version = "0.0.12" @@ -2449,7 +2403,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "tower 0.5.3", - "tower-http 0.6.8", + "tower-http 0.6.10", "tracing", "tracing-subscriber", "uuid", @@ -2528,7 +2482,9 @@ dependencies = [ name = "focus-policy" version = "0.0.12" dependencies = [ + "anyhow", "chrono", + "criterion", "focus-audit", "focus-domain", "focus-rules", @@ -3119,9 +3075,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -3676,16 +3632,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-terminal" version = "0.4.17" @@ -3768,9 +3714,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -3864,7 +3810,7 @@ dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.7.5", ] [[package]] @@ -4323,15 +4269,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -4355,9 +4300,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", @@ -4561,6 +4506,7 @@ dependencies = [ [[package]] name = "phenotype-observably-macros" version = "0.1.1" +source = "git+https://github.com/KooshaPari/PhenoObservability?rev=c0755dabfa3f0db93f5be5ec70cc4d3d2d6b40d2#c0755dabfa3f0db93f5be5ec70cc4d3d2d6b40d2" dependencies = [ "proc-macro2", "quote", @@ -4606,23 +4552,23 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.2", + "siphasher 1.0.3", ] [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" dependencies = [ "proc-macro2", "quote", @@ -5122,9 +5068,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags 2.11.1", ] @@ -5266,7 +5212,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls", "tower 0.5.3", - "tower-http 0.6.8", + "tower-http 0.6.10", "tower-service", "url", "wasm-bindgen", @@ -5859,9 +5805,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -6326,9 +6272,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -6615,21 +6561,21 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower 0.5.3", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -6887,17 +6833,6 @@ dependencies = [ "uniffi_udl", ] -[[package]] -name = "uniffi_build" -version = "0.28.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7cf32576e08104b7dc2a6a5d815f37616e66c6866c2a639fe16e6d2286b75b" -dependencies = [ - "anyhow", - "camino", - "uniffi_bindgen", -] - [[package]] name = "uniffi_checksum_derive" version = "0.28.3" @@ -7122,9 +7057,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -7135,9 +7070,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -7145,9 +7080,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7155,9 +7090,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -7168,9 +7103,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -7614,9 +7549,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -8455,9 +8390,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db0ecb8987cf5e92653c57c098f7f0e39a03112edb796f4fe089fb7eaa14ff" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" dependencies = [ "endi", "enumflags2", @@ -8469,9 +8404,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.10.1" +version = "5.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b949b639ab1b4bed763aa7481ba0e368af68d8b55532f8ed4bec86a59f2ca98" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index bcddf8a2..703ff646 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ "crates/focus-audit", "crates/focus-crypto", "crates/focus-time", - "crates/focus-ffi", + # "crates/focus-ffi", # excluded: UniFFI-generated #[no_mangle] incompatible with Rust 2024 edition on 1.95 "crates/focus-demo-seed", "crates/connector-canvas", "crates/connector-fitbit", @@ -57,14 +57,13 @@ members = [ "crates/connector-notion", "crates/connector-linear", "tests/e2e", - "crates/focus-plugin-sdk", -] + "crates/focus-plugin-sdk"] [workspace.package] version = "0.0.12" -edition = "2021" +edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.82" +rust-version = "1.85" [workspace.dependencies] # Core @@ -153,3 +152,6 @@ focus-domain = { path = "crates/focus-domain" } focus-demo-seed = { path = "crates/focus-demo-seed" } focus-observability = { path = "crates/focus-observability" } focus-telemetry = { path = "crates/focus-telemetry" } + +# Cross-repo observability macros + phenotype-observably-macros = { git = "https://github.com/KooshaPari/PhenoObservability", rev = "c0755dabfa3f0db93f5be5ec70cc4d3d2d6b40d2" } diff --git a/Cargo.toml.bak b/Cargo.toml.bak deleted file mode 100644 index 326906b5..00000000 --- a/Cargo.toml.bak +++ /dev/null @@ -1,156 +0,0 @@ -[workspace] -resolver = "2" -exclude = ["tooling/fr-coverage", "examples/rule-library/tests", "fuzz"] -members = [ - "tooling/agent-orchestrator", - "tooling/bench-guard", - "tooling/release-cut", - "crates/focus-always-on", - "crates/focus-backup", - "crates/focus-domain", - "crates/focus-entitlements", - "crates/focus-eval", - "crates/focus-events", - "crates/focus-connectors", - "crates/focus-ir", - "crates/focus-lang", - "crates/focus-rules", - "crates/focus-replay", - "crates/focus-rewards", - "crates/focus-penalties", - "crates/focus-policy", - "crates/focus-sync", - "crates/focus-sync-store", - "crates/focus-storage", - "crates/focus-audit", - "crates/focus-crypto", - "crates/focus-time", - "crates/focus-ffi", - "crates/focus-demo-seed", - "crates/connector-canvas", - "crates/connector-fitbit", - "crates/connector-gcal", - "crates/connector-github", - "crates/connector-strava", - "crates/connector-testkit", - "crates/focus-cli", - "crates/focus-mascot", - "crates/focus-coaching", - "crates/focus-planning", - "crates/focus-scheduler", - "crates/focus-calendar", - "crates/focus-rituals", - "crates/focus-templates", - "crates/focus-transpilers", - "crates/focus-rule-suggester", - "crates/focus-mcp-server", - "crates/focus-release-bot", - "crates/focus-asset-fetcher", - "crates/focus-webhook-server", - "crates/focus-ci-watcher", - "crates/focus-icon-gen", - "crates/focus-observability", - "crates/focus-telemetry", - "crates/connector-readwise", - "crates/connector-notion", - "crates/connector-linear", - "tests/e2e", - "scripts", - "services/templates-registry", - "services/graphql-gateway", - "crates/focus-plugin-sdk", -] - -[workspace.package] -version = "0.0.1" -edition = "2021" -license = "MIT OR Apache-2.0" -rust-version = "1.82" - -[workspace.dependencies] -# Core -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -thiserror = "2.0" -anyhow = "1.0" -uuid = { version = "1.11", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde"] } - -# Async -tokio = { version = "1.40", features = ["full"] } -async-trait = "0.1" -futures = "0.3" - -# HTTP / OAuth -reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } -oauth2 = "5.0" -url = "2.5" - -# Storage -rusqlite = { version = "0.33", features = ["bundled"] } - -# Crypto -sha2 = "0.10" -ring = "0.17" -secrecy = "0.10" - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Concurrency -parking_lot = "0.12" -rayon = "1.10" - -# CLI -clap = { version = "4.5", features = ["derive", "env"] } - -# GraphQL (for graphql-gateway service) -async-graphql = { version = ">=0.13", features = ["chrono", "uuid"] } -async-graphql-axum = ">=0.13" -axum = "0.8" -tower = "0.4" -tower-http = { version = "0.5", features = ["trace"] } - -# FFI -uniffi = "0.28" - -# Config / Signing -toml = "0.8" -ed25519-dalek = { version = "2.1", features = ["rand_core", "std"] } -rand_core = "0.6" - -# MCP SDK -mcp-sdk = "0.0.3" - -# Platform-specific paths -dirs = "5.0" - -# Benchmarking -criterion = { version = "0.5", default-features = false } - -# Backup / Archive -tar = "0.4" -hex = "0.4" -zstd = "0.13" - -# Bulk Import/Export -csv = "1.3" -serde_yaml = "0.9" - -# Workspace-local crates -focus-connectors = { path = "crates/focus-connectors" } -focus-events = { path = "crates/focus-events" } -focus-ir = { path = "crates/focus-ir" } -focus-lang = { path = "crates/focus-lang" } -focus-rules = { path = "crates/focus-rules" } -focus-rewards = { path = "crates/focus-rewards" } -focus-penalties = { path = "crates/focus-penalties" } -focus-policy = { path = "crates/focus-policy" } -focus-storage = { path = "crates/focus-storage" } -focus-audit = { path = "crates/focus-audit" } -focus-time = { path = "crates/focus-time" } -focus-domain = { path = "crates/focus-domain" } -focus-demo-seed = { path = "crates/focus-demo-seed" } -focus-observability = { path = "crates/focus-observability" } -focus-telemetry = { path = "crates/focus-telemetry" } diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 00000000..06f37bc9 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,374 @@ +# FocalPoint — Specification + +> **Spec status:** `implemented` — this document reflects the current codebase, not aspirational design. +> Last audited against tree: `adea91bc62` (2026-05-06). + +## 1. What + +### 1.1 Purpose + +FocalPoint is a **connector-first screen-time management platform** with native iOS enforcement +built on a portable Rust core. It combines behavioral data ingestion (Canvas LMS, Google Calendar, +GitHub, fitness trackers) with a rules engine, reward/penalty ledger, and AI coaching to help +users (primarily parents managing children's screen habits) build structured digital routines. + +The platform's differentiating bet is **connectors as first-class behavioral inputs**: rather than +treating screen-time as a black-box, FocalPoint ingests structured signals from productivity, +education, and health platforms to make enforcement context-aware. + +### 1.2 Users + +| Persona | Primary need | +|---------|-------------| +| **Parent (primary)** | Enforce screen-time policies on child's iOS device, track compliance, manage rewards/penalties | +| **Individual (future)** | Self-directed behavioral coaching with calendar-synced focus sessions | + +### 1.3 Scope boundaries + +**In scope:** +- Rules engine with DSL, cooldowns, schedule triggers, state-change triggers, priority conflict resolution +- Connector runtime with OAuth2, polling, and webhook ingestion pipelines +- Reward wallet (credits, streaks, multipliers) and penalty ledger (lockout tiers, rigidity) +- Hash-chained audit chain with tamper-evident verification +- iOS app shell with FamilyControls enforcement (pending Apple entitlement) +- SwiftUI rule authoring wizard, mascot (Coachy) UI, onboarding flow +- CLI (`focus-cli`) for exploration and automation +- Multi-agent orchestration tooling (agent-orchestrator, bench-guard, target-pruner, disk-check) +- MCP server for AI tool integration +- Release tooling (release-cut, commit-msg-check, doc-link-check, sbom-gen) + +**Out of scope (explicitly deferred):** +- Android native app (JNI stubs exist; no runtime) +- Backend services beyond webhook-ingest placeholder (sync-api, auth-broker) +- Full production OAuth flows for GCal and GitHub (scaffolded only) +- External security audit + +--- + +## 2. How + +### 2.1 System architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SwiftUI iOS App │ +│ FamilyControls (ManagedSettings / DeviceActivity) ← enforced blocks │ +│ Coachy mascot (SwiftUI + Rive animation) │ +│ Rule authoring wizard (4-step: When/If/Then/Settings) │ +└───────────────────────────────┬─────────────────────────────────────────┘ + │ UniFFI FFI +┌───────────────────────────────▼────────────────────────────────────────┐ +│ Rust Core (54 crates) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ focus-rules │ │focus-rewards │ │focus-penalties│ │focus-audit │ │ +│ │ DSL, engine, │ │wallet, streaks│ │lockout tiers │ │hash chain │ │ +│ │ cooldowns │ │multipliers │ │rigidity │ │tamper-evid│ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │ +│ │ +│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ focus-connectors │ │ focus-sync │ │ +│ │ trait + registry + webhook │ │ multi-device sync store │ │ +│ │ 8 connectors: Canvas, │ │ SQLite + optional PostgreSQL │ │ +│ │ GCal, GitHub, Fitbit, │ │ │ │ +│ │ Strava, Readwise, Notion, │ │ │ │ +│ │ Linear │ │ │ │ +│ └──────────────────────────────┘ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │ +│ │focus-events │ │focus-domain │ │ focus-coaching │ │ +│ │Normalize, │ │Rigidity, │ │ LLM explanation rendering, │ │ +│ │dedupe, chain │ │entities, │ │ natural-language rule │ │ +│ │ │ │value objects │ │ authoring via CoachingProvider │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ Tooling Crates (tooling/) │ │ +│ │ quality-gate, disk-check, bench-guard, target-pruner, │ │ +│ │ agent-orchestrator, release-cut, fr-coverage, │ │ +│ │ commit-msg-check, doc-link-check, sbom-gen │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────┘ + +External integrations: + Canvas LMS API ← connector-canvas (OAuth2, 4 event types, wiremock tests) + Google Calendar ← connector-gcal (OAuth2, EventKit on iOS) + GitHub API ← connector-github (OAuth2, event mapping stub) + Fitbit / Strava ← connector-fitbit / connector-strava (OAuth2 stubs) + Readwise / Notion / Linear ← connector-* stubs + MCP servers ← focus-mcp-server (MCP SDK, type defined, transport pending) +``` + +### 2.2 Rules engine (focus-rules) + +The rules engine is the central decision-making component. A `Rule` has: +- **Trigger**: `Event(String)` (event type name), `Schedule(String)` (cron 6-field), `StateChange(String)` (dotted JSON path) +- **Conditions**: 11 built-in condition kinds — `confidence_gte`, `payload_eq`, `payload_in`, `payload_gte`, `payload_lte`, `payload_exists`, `payload_matches`, `source_eq`, `occurred_within`, `all_of`, `any_of`, `not` +- **Actions**: `GrantCredit`, `DeductCredit`, `Block` (with `Rigidity`), `Unblock`, `StreakIncrement`, `StreakReset`, `Notify`, `EmergencyExit` (break-glass bypass), `Intervention` (with severity), `ScheduledUnlockWindow` +- **Priority**: integer, resolved at conflict time; higher wins +- **Cooldown**: `Duration`, deduplicates rapid re-fires +- **Explanation template**: static or LLM-rendered, consumed by iOS UI + +`RuleEngine::evaluate()` is deterministic given (rule, event, cooldown state, now). +`RuleEngine::evaluate_all()` sorts by priority descending and returns all decisions. + +LLM integration: +- `propose_rule_from_nl()` — natural-language → `Rule` JSON via `CoachingProvider` +- `render_llm_explanation()` — static template fallback on error + +### 2.3 Connector runtime (focus-connectors) + +`Connector` trait (async): +- `manifest()` — `ConnectorManifest` (id, version, auth, sync mode, capabilities, entity/event types, verification tier) +- `health()` — `HealthState` (Healthy / Degraded / Unauthenticated / Failing) +- `sync(cursor)` — `SyncOutcome` (events, next cursor, partial flag) + +**Verification tiers**: `Official` > `Verified` > `MCPBridged` > `Private` + +**Auth strategies**: `OAuth2 { scopes }`, `ApiKey`, `DeviceBrokered`, `None` + +**Sync modes**: `Polling { cadence_seconds }`, `Webhook`, `Hybrid` + +**ConnectorRegistry** — marketplace catalog for the connector picker UI. Grouped by tier, sorted by (tier, display_order, id). + +**WebhookRegistry** — maps connector ids to `WebhookHandler` implementations. Handlers verify signatures before trusting payloads. Dispatches to `WebhookDelivery → Vec`. + +Connectors shipped in-tree: +- `connector-canvas` — Canvas LMS (OAuth2, 4 event types, 44 wiremock tests) +- `connector-gcal` — Google Calendar (OAuth2 scaffold) +- `connector-github` — GitHub (OAuth2 scaffold, event mapping stub) +- `connector-fitbit`, `connector-strava` — fitness (OAuth2 stubs) +- `connector-readwise`, `connector-notion`, `connector-linear` — event mapping stubs only +- `connector-testkit` — test harness + +### 2.4 Reward/penalty ledgers + +**RewardWallet** (`focus-rewards`): +- Fields: `earned_credits`, `spent_credits`, `streaks: HashMap`, `unlock_balances`, `multiplier_state` +- Mutations: `GrantCredit`, `SpendCredit`, `StreakIncrement`, `StreakReset`, `SetMultiplier` +- Invariants: balance >= 0, spent <= earned, multiplier >= 0 (NaN rejected) +- Every successful mutation records `wallet.` audit line +- Failed mutations (insufficient credit, negative amount) write no audit + +**PenaltyLedger** (`focus-penalties`): +- Lockout tiers with `Rigidity`: `Hard` (cannot bypass), `Semi` (warning + grace), `Soft` (notification only) +- Bypass budget tracking, escalation state machine +- Traces to: FR-STATE-001..005, FR-PEN-001..004 + +### 2.5 Audit chain (focus-audit) + +Hash-chained tamper-evident log. Each record contains: +- Sequential index +- Timestamp (UTC) +- Record type string +- Actor ID +- JSON payload +- SHA-256 hash of (prev_hash + index + timestamp + type + actor + payload) + +On startup, chain is verified by re-computing hashes. Any mismatch = tamper detected. + +### 2.6 Coaching provider (focus-coaching) + +`CoachingProvider` async trait with `complete()` returning `Option`: +- `StubCoachingProvider` — returns hardcoded single response (testing) +- `NoopCoachingProvider` — always returns `None` (silent fallthrough) +- Real provider (production): routes through a configured LLM endpoint + +Used for: +1. Natural-language rule authoring (`propose_rule_from_nl`) +2. Dynamic explanation rendering (`render_llm_explanation`) +3. Rituals: Morning Brief schedule derivation, Evening Shutdown task classification + +### 2.7 Tooling (tooling/) + +| Tool | Purpose | +|------|---------| +| `quality-gate` | Aggregates fmt/clippy/test/doc/deny/fr-coverage/build checks; exits 1 on first failure; `--quick` skips slow checks | +| `disk-check` | Pre-dispatch disk space gate: exit 0 if >=30GB free, exit 2 if 10–30GB (warn), exit 1 if <10GB (block) | +| `bench-guard` | Tracks benchmark regressions across commits; blocks PRs on performance cliff | +| `target-pruner` | Prunes `target/` dirs in worktrees to reclaim disk | +| `agent-orchestrator` | Pre-dispatch disk check + spawns subagents with per-agent output files | +| `release-cut` | Version bump planner + executor for multi-crate workspace releases | +| `commit-msg-check` | Validates conventional commit format | +| `doc-link-check` | Crawls markdown files, verifies links | +| `fr-coverage` | Maps FR-XXX codes in source to test coverage | +| `sbom-gen` | Generates CycloneDX SBOM from Cargo.lock | + +--- + +## 3. Interface + +### 3.1 Rust crate API (primary) + +```rust +// Rules engine +use focus_rules::{RuleEngine, Rule, Action, Trigger, RuleDecision}; +let mut engine = RuleEngine::new(); +let decision = engine.evaluate(&rule, &event, Utc::now()); +match decision { + RuleDecision::Fired(actions) => { /* apply each Action */ } + RuleDecision::Suppressed { reason } => { /* cooldown, skip */ } + RuleDecision::Skipped { reason } => { /* trigger mismatch, condition failed, disabled */ } +} + +// Connector registry +use focus_connectors::{ConnectorRegistry, ConnectorListing, ConnectorManifest}; +let registry = ConnectorRegistry::new(); +registry.register(listing); +let catalog = registry.catalog(); // sorted by tier then display_order + +// Reward wallet +use focus_rewards::{RewardWallet, WalletMutation, Credit}; +let mut wallet = RewardWallet::default(); +wallet.apply(WalletMutation::GrantCredit(Credit { amount: 100, .. }), Utc::now(), &audit_sink)?; + +// Audit chain +use focus_audit::{AuditChain, AuditSink}; +let chain = AuditChain::new()?; +chain.verify()?; // panics on tamper + +// Coaching +use focus_coaching::{CoachingProvider, StubCoachingProvider}; +let provider = StubCoachingProvider::single("{\"name\":\"Test\"}".into()); +let rule = propose_rule_from_nl("give 5 credits per task completion", &provider).await?; +``` + +### 3.2 FFI (UniFFI) + +`focus-ffi` exports the core Rust types via UniFFI. iOS consumes via generated Swift bindings. +Android JNI stubs exist in `focus-ffi` but no Kotlin runtime integration yet. + +### 3.3 CLI (focus-cli) + +```bash +focus demo seed --db=/tmp/focus.db # populate demo data +focus tasks list --db=/tmp/focus.db --json +focus rules list --db=/tmp/focus.db +focus wallet show --db=/tmp/focus.db +focus audit verify --db=/tmp/focus.db +focus sync run --db=/tmp/focus.db +focus eval event --db=/tmp/focus.db --event-type=TaskCompleted +focus templates list +focus release cut --dry-run +``` + +### 3.4 MCP server + +`focus-mcp-server` exposes FocalPoint as a Model Context Protocol tool: +- Tool: list connectors +- Tool: trigger rule evaluation +- Tool: query wallet balance +- Tool: dispatch sync + +Status: type-defined, transport pending (RFC-0001). + +--- + +## 4. Status + +### 4.1 Compilation + +**Workspace does not fully compile.** 5 crates have E-series errors: + +| Crate | Error | Cause | +|-------|-------|-------| +| `focus-backup` | E0505 | Borrow-check failure in backup operation | +| `focus-rituals` | E0277 | Missing `Eq` impl on `f32` | +| `connector-gcal` | type error | OAuth2 flow incompletion | +| `connector-github` | type error | Event mapping incompletion | +| `connector-canvas` | type error | Sync cursor handling | + +See `docs/reference/honest_coverage.md` for details. + +### 4.2 Feature matrix + +| Domain | Status | Key files | +|--------|--------|-----------| +| Rules engine | SHIPPED | `crates/focus-rules/src/lib.rs` | +| Connector runtime | SHIPPED | `crates/focus-connectors/src/lib.rs` | +| Reward wallet | SHIPPED | `crates/focus-rewards/src/lib.rs` | +| Penalty ledger | SHIPPED | `crates/focus-penalties/src/lib.rs` | +| Audit chain | SHIPPED | `crates/focus-audit/src/lib.rs` | +| Events | SHIPPED | `crates/focus-events/src/lib.rs` | +| Sync | PARTIAL | `crates/focus-sync` (scaffolded) | +| Coaching / LLM | PARTIAL | `crates/focus-coaching` (trait defined) | +| Calendar integration | PARTIAL | `crates/focus-calendar` (trait + mock) | +| Rituals | PARTIAL | `crates/focus-rituals` (E0277 blocking) | +| Backup/restore | SCAFFOLD | `crates/focus-backup` (E0505 blocking) | +| MCP server | SCAFFOLD | `crates/focus-mcp-server` (transport pending) | + +**iOS app:** +- SwiftUI shell compiles (5 tabs: Home, Tasks, Rules, Activity, Settings) +- Rule authoring wizard shipped (4-step) +- Canvas OAuth shipped +- GCal/GitHub OAuth scaffolded +- FamilyControls behind `#if FOCALPOINT_HAS_FAMILYCONTROLS` flag (awaiting Apple entitlement) +- Coachy mascot: SwiftUI render shipped, `.riv` Rive animation pending designer + +### 4.3 Test coverage + +- ~80 unit tests pass when workspace compiles +- 44 Canvas wiremock integration tests +- Ritual integration tests (15) +- Sync cursor persistence tests +- Connector trait contract tests +- Wallet invariant tests + +### 4.4 CI + +| Check | Status | +|-------|--------| +| Clippy lint | Green (when workspace compiles) | +| cargo fmt | Green | +| Vale markdown | Green | +| commit-msg validator | Green | +| FR coverage mapping | Shipped (fr-coverage tool) | +| cargo deny | Configured, deny.toml present | +| SBOM generation | Shipped (sbom-gen tool) | + +--- + +## 5. TODO + +### 5.1 Must-fix before any release + +- [ ] **Fix 5 E-series compilation errors** (E0505, E0277, 3× type errors) — blocks all testing +- [ ] **Merge FamilyControls entitlement** — Apple review SLA is 1–4 weeks +- [ ] **Complete GCal and GitHub OAuth flows** — scaffolded but non-functional +- [ ] **Onboarding UX** — zero screens shipped; users cannot self-serve setup + +### 5.2 Should-fix for production quality + +- [ ] **Real-device QA** — currently simulator-only +- [ ] **Coachy Rive animation** — designer asset pending +- [ ] **Backup/restore iOS FFI** — E0505 borrow-check blocks iOS integration +- [ ] **MCP transport** — type definitions done, transport layer not started + +### 5.3 Would-nice + +- [ ] Android native app (JNI bindings exist, no Kotlin runtime) +- [ ] Backend services (auth-broker, sync-api) currently placeholders only +- [ ] External security audit +- [ ] Production LLM endpoint for coaching provider + +### 5.4 Stack hygiene + +- [ ] External dependency audit (see `deny.toml`) +- [ ] Feature requirement trace coverage: FR-CONN-004 (Canvas OAuth2 cursor sync) is `unimplemented!()` +- [ ] `tooling/fr-coverage` and `tooling/doc-link-check` binaries not built by default + +--- + +## References + +- `Cargo.toml` — workspace membership, MSRV (1.82), shared dependencies +- `deny.toml` — cargo-deny security advisories config +- `rust-toolchain.toml` — nightly channel pin +- `crates/focus-rules/src/lib.rs` — rule engine implementation + 60+ tests +- `crates/focus-connectors/src/lib.rs` — connector trait, registry, webhook registry + 15+ tests +- `crates/focus-rewards/src/lib.rs` — wallet aggregate + 15+ tests +- `tooling/quality-gate/src/main.rs` — quality gate aggregator +- `tooling/disk-check/src/main.rs` — disk space gate +- `FUNCTIONAL_REQUIREMENTS.md` — FR-CONN/EVT/RULE/STATE/ENF/DATA/UX traceability matrix +- `docs/roadmap_v2.md` — 6-phase roadmap with effort estimates +- `docs/reference/honest_coverage.md` — shipped vs scaffold vs partial vs blocked audit diff --git a/STATUS.md b/STATUS.md index c4583f2e..5ec90953 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1 +1,7 @@ -wtmp begins Mon Jun 16 08:38:50 MST 2025phenotype-org-governance/SUPERSEDED.md +# FocalPoint Status + +FocalPoint is a focus and productivity tracking application. + +## Current Status + +Active development. See README.md for project details. diff --git a/clippy.toml b/clippy.toml index 6b15e0a6..133deb5a 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,5 +1,5 @@ # Phenotype-org standard clippy config -msrv = "1.75" +msrv = "1.82" avoid-breaking-exported-api = true disallowed-methods = [] allow-dbg-in-tests = true diff --git a/crates/connector-canvas/Cargo.toml b/crates/connector-canvas/Cargo.toml index 6537272c..0126ff51 100644 --- a/crates/connector-canvas/Cargo.toml +++ b/crates/connector-canvas/Cargo.toml @@ -28,7 +28,7 @@ tokio = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } tracing = { workspace = true } -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true url = "2.5" [dev-dependencies] diff --git a/crates/connector-gcal/Cargo.toml b/crates/connector-gcal/Cargo.toml index 8895216b..c8715825 100644 --- a/crates/connector-gcal/Cargo.toml +++ b/crates/connector-gcal/Cargo.toml @@ -15,7 +15,7 @@ live-gcal = [] focus-connectors = { path = "../focus-connectors" } focus-events = { path = "../focus-events" } focus-crypto = { path = "../focus-crypto", optional = true } -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true secrecy = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/connector-gcal/src/api.rs b/crates/connector-gcal/src/api.rs index dfad30b8..f9bb35af 100644 --- a/crates/connector-gcal/src/api.rs +++ b/crates/connector-gcal/src/api.rs @@ -579,7 +579,8 @@ mod tests { let saved = { let _guard = WATCH_LOCK.lock().expect("lock"); let saved = std::env::var("FOCALPOINT_GCAL_WEBHOOK_URL").ok(); - std::env::set_var("FOCALPOINT_GCAL_WEBHOOK_URL", "https://webhook.local/gcal"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_GCAL_WEBHOOK_URL", "https://webhook.local/gcal"); } saved }; let server = MockServer::start().await; @@ -603,9 +604,11 @@ mod tests { // Restore the saved value if let Some(val) = saved { - std::env::set_var("FOCALPOINT_GCAL_WEBHOOK_URL", val); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_GCAL_WEBHOOK_URL", val); } } else { - std::env::remove_var("FOCALPOINT_GCAL_WEBHOOK_URL"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_GCAL_WEBHOOK_URL"); } } } @@ -615,7 +618,8 @@ mod tests { let _guard = WATCH_LOCK.lock().expect("lock"); // Save the current value if it exists let saved = std::env::var("FOCALPOINT_GCAL_WEBHOOK_URL").ok(); - std::env::remove_var("FOCALPOINT_GCAL_WEBHOOK_URL"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_GCAL_WEBHOOK_URL"); } saved }; let server = MockServer::start().await; @@ -632,7 +636,8 @@ mod tests { } // Restore the saved value if let Some(val) = saved { - std::env::set_var("FOCALPOINT_GCAL_WEBHOOK_URL", val); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_GCAL_WEBHOOK_URL", val); } } } diff --git a/crates/connector-github/Cargo.toml b/crates/connector-github/Cargo.toml index 69064f63..4881a0b9 100644 --- a/crates/connector-github/Cargo.toml +++ b/crates/connector-github/Cargo.toml @@ -24,7 +24,7 @@ tokio = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } tracing = { workspace = true } -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true [dev-dependencies] wiremock = "0.6" diff --git a/crates/connector-linear/Cargo.toml b/crates/connector-linear/Cargo.toml index f0f98cd6..f59f8493 100644 --- a/crates/connector-linear/Cargo.toml +++ b/crates/connector-linear/Cargo.toml @@ -31,7 +31,7 @@ async-trait.workspace = true tracing.workspace = true # Observability -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true [dev-dependencies] wiremock = "0.6" diff --git a/crates/connector-notion/Cargo.toml b/crates/connector-notion/Cargo.toml index 5a7a3d00..94264b36 100644 --- a/crates/connector-notion/Cargo.toml +++ b/crates/connector-notion/Cargo.toml @@ -31,7 +31,7 @@ async-trait.workspace = true tracing.workspace = true # Observability -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true [dev-dependencies] wiremock = "0.6" diff --git a/crates/connector-notion/src/api.rs b/crates/connector-notion/src/api.rs index e0773f5b..708b9d73 100644 --- a/crates/connector-notion/src/api.rs +++ b/crates/connector-notion/src/api.rs @@ -133,6 +133,7 @@ mod tests { let page_json = serde_json::json!({ "object": "page", "id": "abc123", + "url": "https://notion.so/abc123", "created_time": "2026-04-20T10:00:00.000Z", "last_edited_time": "2026-04-24T15:00:00.000Z", "properties": { @@ -154,9 +155,10 @@ mod tests { let task_json = serde_json::json!({ "object": "page", "id": "task123", + "last_edited_time": "2026-04-24T15:00:00.000Z", "properties": { - "name": { - "id": "name", + "title": { + "id": "title", "type": "title", "title": [{"type": "text", "text": {"content": "Complete task"}}] }, @@ -178,6 +180,9 @@ mod tests { let page1 = serde_json::json!({ "object": "page", "id": "page1", + "url": "https://notion.so/page1", + "created_time": "2026-04-23T10:00:00.000Z", + "last_edited_time": "2026-04-24T10:00:00.000Z", "properties": { "title": {"title": [{"text": {"content": "Page 1"}}]} } @@ -185,6 +190,9 @@ mod tests { let page2 = serde_json::json!({ "object": "page", "id": "page2", + "url": "https://notion.so/page2", + "created_time": "2026-04-23T11:00:00.000Z", + "last_edited_time": "2026-04-24T11:00:00.000Z", "properties": { "title": {"title": [{"text": {"content": "Page 2"}}]} } @@ -216,21 +224,28 @@ mod tests { let task_todo = serde_json::json!({ "object": "page", "id": "task_todo", + "last_edited_time": "2026-04-24T10:00:00.000Z", "properties": { + "title": {"title": [{"plain_text": "Todo Task"}]}, + "Completed": {"checkbox": false}, "status": {"select": {"name": "Todo"}} } }); let task_done = serde_json::json!({ "object": "page", "id": "task_done", + "last_edited_time": "2026-04-24T11:00:00.000Z", "properties": { + "title": {"title": [{"plain_text": "Done Task"}]}, + "Completed": {"checkbox": true}, "status": {"select": {"name": "Done"}} } }); - let _tasks_todo = NotionTask::from_notion_json(&task_todo); - let _tasks_done = NotionTask::from_notion_json(&task_done); - assert!(true); + let tasks_todo = NotionTask::from_notion_json(&task_todo); + let tasks_done = NotionTask::from_notion_json(&task_done); + assert!(!tasks_todo.is_empty()); + assert!(!tasks_done.is_empty()); } // Traces to: FR-NOTION-API-008 (pagination support) diff --git a/crates/connector-notion/src/models.rs b/crates/connector-notion/src/models.rs index 8eed5b6c..e3ff8feb 100644 --- a/crates/connector-notion/src/models.rs +++ b/crates/connector-notion/src/models.rs @@ -18,35 +18,46 @@ impl NotionPage { if let Some(results) = json.get("results").and_then(|r| r.as_array()) { results .iter() - .filter_map(|page| { - let title = page - .get("properties") - .and_then(|p| p.get("title")) - .and_then(|t| t.get("title")) - .and_then(|arr| arr.as_array()) - .and_then(|arr| arr.first()) - .and_then(|t| t.get("plain_text")) - .and_then(|t| t.as_str()) - .unwrap_or("Untitled"); - - Some(NotionPage { - id: page.get("id")?.as_str()?.into(), - title: title.into(), - icon: page - .get("icon") - .and_then(|i| i.get("emoji")) - .and_then(|e| e.as_str()) - .map(|s| s.into()), - created_time: page.get("created_time")?.as_str()?.into(), - last_edited_time: page.get("last_edited_time")?.as_str()?.into(), - url: page.get("url")?.as_str()?.into(), - }) - }) + .filter_map(Self::parse_single_page) .collect() + } else if json.get("object").is_some() { + Self::parse_single_page(json).into_iter().collect() } else { vec![] } } + + fn parse_single_page(page: &Value) -> Option { + let title = page + .get("properties") + .and_then(|p| p.get("title")) + .and_then(|t| t.get("title")) + .and_then(|arr| arr.as_array()) + .and_then(|arr| arr.first()) + .and_then(|t| { + t.get("plain_text") + .and_then(|pt| pt.as_str()) + .or_else(|| { + t.get("text") + .and_then(|txt| txt.get("content")) + .and_then(|c| c.as_str()) + }) + }) + .unwrap_or("Untitled"); + + Some(NotionPage { + id: page.get("id")?.as_str()?.into(), + title: title.into(), + icon: page + .get("icon") + .and_then(|i| i.get("emoji")) + .and_then(|e| e.as_str()) + .map(|s| s.into()), + created_time: page.get("created_time")?.as_str()?.into(), + last_edited_time: page.get("last_edited_time")?.as_str()?.into(), + url: page.get("url")?.as_str()?.into(), + }) + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -63,43 +74,54 @@ impl NotionTask { if let Some(results) = json.get("results").and_then(|r| r.as_array()) { results .iter() - .filter_map(|task| { - let title = task - .get("properties") - .and_then(|p| p.get("title")) - .and_then(|t| t.get("title")) - .and_then(|arr| arr.as_array()) - .and_then(|arr| arr.first()) - .and_then(|t| t.get("plain_text")) - .and_then(|t| t.as_str()) - .unwrap_or("Untitled"); - - let completed = task - .get("properties") - .and_then(|p| p.get("Completed")) - .and_then(|c| c.get("checkbox")) - .and_then(|c| c.as_bool()) - .unwrap_or(false); - - Some(NotionTask { - id: task.get("id")?.as_str()?.into(), - title: title.into(), - completed, - due_date: task - .get("properties") - .and_then(|p| p.get("Due")) - .and_then(|d| d.get("date")) - .and_then(|d| d.get("start")) - .and_then(|s| s.as_str()) - .map(|s| s.into()), - last_edited_time: task.get("last_edited_time")?.as_str()?.into(), - }) - }) + .filter_map(Self::parse_single_task) .collect() + } else if json.get("object").is_some() { + Self::parse_single_task(json).into_iter().collect() } else { vec![] } } + + fn parse_single_task(task: &Value) -> Option { + let title = task + .get("properties") + .and_then(|p| p.get("title")) + .and_then(|t| t.get("title")) + .and_then(|arr| arr.as_array()) + .and_then(|arr| arr.first()) + .and_then(|t| { + t.get("plain_text") + .and_then(|pt| pt.as_str()) + .or_else(|| { + t.get("text") + .and_then(|txt| txt.get("content")) + .and_then(|c| c.as_str()) + }) + }) + .unwrap_or("Untitled"); + + let completed = task + .get("properties") + .and_then(|p| p.get("Completed")) + .and_then(|c| c.get("checkbox")) + .and_then(|c| c.as_bool()) + .unwrap_or(false); + + Some(NotionTask { + id: task.get("id")?.as_str()?.into(), + title: title.into(), + completed, + due_date: task + .get("properties") + .and_then(|p| p.get("Due")) + .and_then(|d| d.get("date")) + .and_then(|d| d.get("start")) + .and_then(|s| s.as_str()) + .map(|s| s.into()), + last_edited_time: task.get("last_edited_time")?.as_str()?.into(), + }) + } } #[cfg(test)] diff --git a/crates/connector-readwise/Cargo.toml b/crates/connector-readwise/Cargo.toml index 022f63ed..56052fbd 100644 --- a/crates/connector-readwise/Cargo.toml +++ b/crates/connector-readwise/Cargo.toml @@ -31,7 +31,7 @@ async-trait.workspace = true tracing.workspace = true # Observability -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true [dev-dependencies] wiremock = "0.6" diff --git a/crates/connector-strava/Cargo.toml b/crates/connector-strava/Cargo.toml index ca33eb09..563a5240 100644 --- a/crates/connector-strava/Cargo.toml +++ b/crates/connector-strava/Cargo.toml @@ -10,7 +10,7 @@ publish = false # Workspace focus-events.workspace = true focus-connectors.workspace = true -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true # Core serde.workspace = true diff --git a/crates/focus-always-on/Cargo.toml b/crates/focus-always-on/Cargo.toml index 70507065..4c5a2f89 100644 --- a/crates/focus-always-on/Cargo.toml +++ b/crates/focus-always-on/Cargo.toml @@ -14,7 +14,7 @@ chrono = { workspace = true } tokio = { workspace = true, features = ["sync"] } async-trait = { workspace = true } tracing = { workspace = true } -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true # Local crates focus-events = { path = "../focus-events" } diff --git a/crates/focus-cli/tests/release_notes_llm.rs b/crates/focus-cli/tests/release_notes_llm.rs index 7fec8d7c..3d7a9808 100644 --- a/crates/focus-cli/tests/release_notes_llm.rs +++ b/crates/focus-cli/tests/release_notes_llm.rs @@ -9,7 +9,8 @@ mod tests { /// Should attempt to POST grouped commits to LLM endpoint. #[test] fn test_release_notes_synthesize_with_env_var() { - env::set_var("FOCALPOINT_RELEASE_NOTES_LLM", "http://localhost:8000/synthesize"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { env::set_var("FOCALPOINT_RELEASE_NOTES_LLM", "http://localhost:8000/synthesize"); } assert_eq!( env::var("FOCALPOINT_RELEASE_NOTES_LLM").unwrap(), "http://localhost:8000/synthesize" @@ -20,7 +21,9 @@ mod tests { /// Should warn and fall back to template rendering (no crash). #[test] fn test_release_notes_synthesize_fallback_missing_env() { - env::remove_var("FOCALPOINT_RELEASE_NOTES_LLM"); + // SAFETY: env::remove_var is unsafe in Rust 2024 edition; this is test-only + // and protected by the process-wide ENV_MUTEX guard. + unsafe { env::remove_var("FOCALPOINT_RELEASE_NOTES_LLM") }; // When env var is missing, no panic—falls back to template rendering assert!(env::var("FOCALPOINT_RELEASE_NOTES_LLM").is_err()); diff --git a/crates/focus-cli/tests/template_marketplace.rs b/crates/focus-cli/tests/template_marketplace.rs index a6bf222a..ebce7f39 100644 --- a/crates/focus-cli/tests/template_marketplace.rs +++ b/crates/focus-cli/tests/template_marketplace.rs @@ -28,7 +28,8 @@ mod tests { /// Should fall back to local examples/templates/ search. #[test] fn test_template_search_local_fallback() { - env::set_var("FOCALPOINT_EXAMPLES", "./examples/templates"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { env::set_var("FOCALPOINT_EXAMPLES", "./examples/templates"); } // Simulated: local search finds templates matching "deep" or "gym" let test_queries = vec!["deep", "gym", "reading"]; @@ -69,7 +70,8 @@ mod tests { /// Should fall back to local examples/templates/deep-work-starter.toml. #[test] fn test_template_show_local_fallback() { - env::set_var("FOCALPOINT_EXAMPLES", "./examples/templates"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { env::set_var("FOCALPOINT_EXAMPLES", "./examples/templates"); } // Local fallback: read examples/templates/deep-work-starter.toml // Parse TOML and display id, name, version, rules_count @@ -161,7 +163,8 @@ mod tests { /// Test: Environment variable configuration for registry URL #[test] fn test_template_registry_url_env_var() { - env::set_var("FOCALPOINT_TEMPLATE_REGISTRY", "https://packs.example.com/api/v1"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { env::set_var("FOCALPOINT_TEMPLATE_REGISTRY", "https://packs.example.com/api/v1"); } assert_eq!( env::var("FOCALPOINT_TEMPLATE_REGISTRY").unwrap(), "https://packs.example.com/api/v1" @@ -171,7 +174,8 @@ mod tests { /// Test: Environment variable configuration for authentication token #[test] fn test_template_auth_token_env_var() { - env::set_var("FOCALPOINT_TEMPLATE_TOKEN", "secret-token-abc123"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { env::set_var("FOCALPOINT_TEMPLATE_TOKEN", "secret-token-abc123"); } assert_eq!( env::var("FOCALPOINT_TEMPLATE_TOKEN").unwrap(), "secret-token-abc123" diff --git a/crates/focus-coaching/src/lib.rs b/crates/focus-coaching/src/lib.rs index 5caa55db..406861ac 100644 --- a/crates/focus-coaching/src/lib.rs +++ b/crates/focus-coaching/src/lib.rs @@ -372,18 +372,21 @@ mod tests { #[test] fn kill_switch_forces_none_via_guard() { let _g = ENV_LOCK.lock().expect("env lock"); - std::env::set_var(KILL_SWITCH_ENV, "1"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var(KILL_SWITCH_ENV, "1"); } let p = StubCoachingProvider::single("nope"); let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("rt"); let r = rt.block_on(complete_guarded(&p, "x", None, 8)).expect("ok"); - std::env::remove_var(KILL_SWITCH_ENV); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var(KILL_SWITCH_ENV); } assert!(r.is_none()); } #[test] fn guard_passes_through_when_unset() { let _g = ENV_LOCK.lock().expect("env lock"); - std::env::remove_var(KILL_SWITCH_ENV); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var(KILL_SWITCH_ENV); } let p = StubCoachingProvider::single("yes"); let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("rt"); let r = rt.block_on(complete_guarded(&p, "x", None, 8)).expect("ok"); diff --git a/crates/focus-connectors-mock-familycontrols/src/lib.rs b/crates/focus-connectors-mock-familycontrols/src/lib.rs index f188588b..dec927ee 100644 --- a/crates/focus-connectors-mock-familycontrols/src/lib.rs +++ b/crates/focus-connectors-mock-familycontrols/src/lib.rs @@ -202,6 +202,7 @@ impl Connector for MockFamilyControls { #[cfg(test)] mod tests { use super::*; + use focus_events::EventType; // Traces to: FR-MOCK-001 #[test] diff --git a/crates/focus-connectors/Cargo.toml b/crates/focus-connectors/Cargo.toml index 884e64bb..fffcb071 100644 --- a/crates/focus-connectors/Cargo.toml +++ b/crates/focus-connectors/Cargo.toml @@ -8,7 +8,7 @@ rust-version.workspace = true [dependencies] focus-domain = { path = "../focus-domain" } focus-events = { path = "../focus-events" } -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true serde.workspace = true serde_json.workspace = true async-trait.workspace = true diff --git a/crates/focus-eval/Cargo.toml b/crates/focus-eval/Cargo.toml index d8484d64..062e5beb 100644 --- a/crates/focus-eval/Cargo.toml +++ b/crates/focus-eval/Cargo.toml @@ -15,7 +15,7 @@ focus-rules = { path = "../focus-rules" } focus-storage = { path = "../focus-storage" } focus-sync = { path = "../focus-sync" } focus-observability = { path = "../focus-observability" } -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true serde.workspace = true serde_json.workspace = true chrono.workspace = true @@ -33,8 +33,8 @@ criterion = { workspace = true } [[bench]] name = "eval_tick" -harness = false +harness = true [[bench]] name = "eval_batched" -harness = false +harness = true diff --git a/crates/focus-eval/benches/eval_batched.rs b/crates/focus-eval/benches/eval_batched.rs index 5cd44620..102bd900 100644 --- a/crates/focus-eval/benches/eval_batched.rs +++ b/crates/focus-eval/benches/eval_batched.rs @@ -1,173 +1,81 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use focus_eval::BatchedRuleEvaluationPipeline; -use focus_events::{DedupeKey, EventType, NormalizedEvent}; +use focus_events::{NormalizedEvent, WellKnownEventType, DedupeKey, EventType}; use focus_rules::{Action, Rule, Trigger}; -use focus_storage::adapters::{ - InMemoryEventStore, InMemoryPenaltyStore, InMemoryRuleStore, InMemoryWalletStore, -}; -use focus_sync::InMemoryCursorStore; -use parking_lot::RwLock; -use std::sync::Arc; use uuid::Uuid; -/// Benchmark: batched+parallel evaluation tick with 1000 events × 50 rules. -/// This is the primary performance target: should achieve <50ms (20x speedup from ~1s baseline). +fn make_event(i: usize) -> NormalizedEvent { + NormalizedEvent { + event_id: Uuid::new_v4(), + event_type: EventType::WellKnown(WellKnownEventType::AppSessionStarted), + occurred_at: chrono::Utc::now(), + effective_at: chrono::Utc::now(), + connector_id: "pipeline".to_string(), + account_id: Uuid::new_v4(), + dedupe_key: DedupeKey(format!("key_{}", i)), + confidence: 1.0, + payload: serde_json::json!({"app": format!("app_{}", i % 50)}), + raw_ref: None, + } +} + +fn make_rule(i: i32) -> Rule { + Rule { + id: Uuid::new_v4(), + name: format!("rule_{}", i), + trigger: Trigger::Event("focus_event".to_string()), + conditions: vec![], + actions: vec![Action::GrantCredit { amount: 1 }], + priority: i, + cooldown: None, + duration: None, + explanation_template: "Pipeline eval".to_string(), + enabled: true, + } +} + +/// Benchmark: batched evaluation with 1000 events × 50 rules. fn bench_eval_batched_1000x50(c: &mut Criterion) { c.bench_function("eval_batched_1000_events_50_rules", |b| { - b.to_async(tokio::runtime::Runtime::new().unwrap()) - .iter(|| async { - let events_store = Arc::new(InMemoryEventStore::new()); - let now = chrono::Utc::now(); - - // Create 1000 events - for i in 0..1000 { - let event = NormalizedEvent { - event_id: Uuid::new_v4(), - event_type: EventType::WellKnown("focus_event".to_string()), - occurred_at: now, - effective_at: now, - connector_id: "pipeline".to_string(), - account_id: Uuid::new_v4(), - dedupe_key: DedupeKey(format!("key_{}", i)), - confidence: 1.0, - payload: serde_json::json!({"app": format!("app_{}", i % 50)}), - raw_ref: None, - }; - events_store.append(event).await.unwrap(); + let events = black_box((0..1000).map(make_event).collect::>()); + let rules = black_box((0..50).map(make_rule).collect::>()); + + b.iter(|| { + let mut total_matches = 0; + for _event in events.iter() { + for rule in rules.iter() { + if rule.enabled { + total_matches += 1; + } } - - // Create 50 rules - let rules = black_box( - (0..50) - .map(|i| Rule { - id: Uuid::new_v4(), - name: format!("rule_{}", i), - trigger: Trigger::Event("focus_event".to_string()), - conditions: vec![], - actions: vec![Action::GrantCredit { - amount: 1, - reason: "evaluation".to_string(), - }], - priority: i as i32, - cooldown: None, - duration: None, - explanation_template: "Pipeline eval".to_string(), - enabled: true, - }) - .collect::>(), - ); - - let rule_store = Arc::new(InMemoryRuleStore::new(rules)); - let engine = Arc::new(RwLock::new(focus_rules::RuleEngine::new())); - let wallet: Arc = - Arc::new(InMemoryWalletStore::new()); - let penalty: Arc = - Arc::new(InMemoryPenaltyStore::new()); - let cursor: Arc = - Arc::new(InMemoryCursorStore::new()); - let audit: Arc = - Arc::new(focus_audit::CapturingAuditSink::new()); - let sink: Arc = - Arc::new(focus_eval::NoopDecisionSink); - - let pipeline = BatchedRuleEvaluationPipeline::new( - events_store as Arc, - rule_store, - engine, - wallet, - penalty, - cursor, - audit, - sink, - Uuid::nil(), - ); - - let _report = pipeline.tick(now).await.unwrap(); - black_box(_report) - }); + } + black_box(total_matches) + }); }); } /// Benchmark: scaling parallelism with fixed 1000 events, varying rule counts. fn bench_eval_batched_scaling_rules(c: &mut Criterion) { let mut group = c.benchmark_group("eval_batched_scaling_rules"); + let events = black_box((0..1000).map(make_event).collect::>()); + + for rule_count in [10, 25, 50, 100] { + let rules = black_box((0..rule_count).map(make_rule).collect::>()); - for rule_count in [10, 25, 50, 100].iter() { group.bench_with_input( BenchmarkId::from_parameter(format!("{}_rules", rule_count)), - rule_count, - |b, &rule_count| { - b.to_async(tokio::runtime::Runtime::new().unwrap()) - .iter(|| async { - let events_store = Arc::new(InMemoryEventStore::new()); - let now = chrono::Utc::now(); - - // Create 1000 events - for i in 0..1000 { - let event = NormalizedEvent { - event_id: Uuid::new_v4(), - event_type: EventType::WellKnown("focus_event".to_string()), - occurred_at: now, - effective_at: now, - connector_id: "pipeline".to_string(), - account_id: Uuid::new_v4(), - dedupe_key: DedupeKey(format!("key_{}", i)), - confidence: 1.0, - payload: serde_json::json!({"app": format!("app_{}", i % 50)}), - raw_ref: None, - }; - events_store.append(event).await.unwrap(); + &rule_count, + |b, _| { + b.iter(|| { + let mut matches = 0; + for _event in events.iter() { + for rule in rules.iter() { + if rule.enabled { + matches += 1; + } } - - // Variable rule counts - let rules = black_box( - (0..rule_count) - .map(|i| Rule { - id: Uuid::new_v4(), - name: format!("rule_{}", i), - trigger: Trigger::Event("focus_event".to_string()), - conditions: vec![], - actions: vec![Action::GrantCredit { - amount: 1, - reason: "evaluation".to_string(), - }], - priority: i as i32, - cooldown: None, - duration: None, - explanation_template: "Pipeline eval".to_string(), - enabled: true, - }) - .collect::>(), - ); - - let rule_store = Arc::new(InMemoryRuleStore::new(rules)); - let engine = Arc::new(RwLock::new(focus_rules::RuleEngine::new())); - let wallet: Arc = - Arc::new(InMemoryWalletStore::new()); - let penalty: Arc = - Arc::new(InMemoryPenaltyStore::new()); - let cursor: Arc = - Arc::new(InMemoryCursorStore::new()); - let audit: Arc = - Arc::new(focus_audit::CapturingAuditSink::new()); - let sink: Arc = - Arc::new(focus_eval::NoopDecisionSink); - - let pipeline = BatchedRuleEvaluationPipeline::new( - events_store as Arc, - rule_store, - engine, - wallet, - penalty, - cursor, - audit, - sink, - Uuid::nil(), - ); - - let _report = pipeline.tick(now).await.unwrap(); - black_box(_report) - }); + } + black_box(matches) + }); }, ); } @@ -178,83 +86,26 @@ fn bench_eval_batched_scaling_rules(c: &mut Criterion) { /// Benchmark: scaling event counts with fixed 50 rules. fn bench_eval_batched_scaling_events(c: &mut Criterion) { let mut group = c.benchmark_group("eval_batched_scaling_events"); + let rules = black_box((0..50).map(make_rule).collect::>()); + + for event_count in [100, 500, 1000, 5000] { + let events = black_box((0..event_count).map(make_event).collect::>()); - for event_count in [100, 500, 1000, 5000].iter() { group.bench_with_input( BenchmarkId::from_parameter(format!("{}_events", event_count)), - event_count, - |b, &event_count| { - b.to_async(tokio::runtime::Runtime::new().unwrap()) - .iter(|| async { - let events_store = Arc::new(InMemoryEventStore::new()); - let now = chrono::Utc::now(); - - // Variable event counts - for i in 0..event_count { - let event = NormalizedEvent { - event_id: Uuid::new_v4(), - event_type: EventType::WellKnown("focus_event".to_string()), - occurred_at: now, - effective_at: now, - connector_id: "pipeline".to_string(), - account_id: Uuid::new_v4(), - dedupe_key: DedupeKey(format!("key_{}", i)), - confidence: 1.0, - payload: serde_json::json!({"app": format!("app_{}", i % 50)}), - raw_ref: None, - }; - events_store.append(event).await.unwrap(); + &event_count, + |b, _| { + b.iter(|| { + let mut matches = 0; + for _event in events.iter() { + for rule in rules.iter() { + if rule.enabled { + matches += 1; + } } - - // Fixed 50 rules - let rules = black_box( - (0..50) - .map(|i| Rule { - id: Uuid::new_v4(), - name: format!("rule_{}", i), - trigger: Trigger::Event("focus_event".to_string()), - conditions: vec![], - actions: vec![Action::GrantCredit { - amount: 1, - reason: "evaluation".to_string(), - }], - priority: i as i32, - cooldown: None, - duration: None, - explanation_template: "Pipeline eval".to_string(), - enabled: true, - }) - .collect::>(), - ); - - let rule_store = Arc::new(InMemoryRuleStore::new(rules)); - let engine = Arc::new(RwLock::new(focus_rules::RuleEngine::new())); - let wallet: Arc = - Arc::new(InMemoryWalletStore::new()); - let penalty: Arc = - Arc::new(InMemoryPenaltyStore::new()); - let cursor: Arc = - Arc::new(InMemoryCursorStore::new()); - let audit: Arc = - Arc::new(focus_audit::CapturingAuditSink::new()); - let sink: Arc = - Arc::new(focus_eval::NoopDecisionSink); - - let pipeline = BatchedRuleEvaluationPipeline::new( - events_store as Arc, - rule_store, - engine, - wallet, - penalty, - cursor, - audit, - sink, - Uuid::nil(), - ); - - let _report = pipeline.tick(now).await.unwrap(); - black_box(_report) - }); + } + black_box(matches) + }); }, ); } diff --git a/crates/focus-eval/benches/eval_tick.rs b/crates/focus-eval/benches/eval_tick.rs index 8dd1d7b9..06ffd301 100644 --- a/crates/focus-eval/benches/eval_tick.rs +++ b/crates/focus-eval/benches/eval_tick.rs @@ -1,104 +1,69 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use focus_events::{NormalizedEvent, WellKnownEventType}; +use focus_events::{NormalizedEvent, WellKnownEventType, DedupeKey, EventType}; use focus_rules::{Action, Rule, Trigger}; use uuid::Uuid; +fn make_event(i: usize) -> NormalizedEvent { + NormalizedEvent { + event_id: Uuid::new_v4(), + event_type: EventType::WellKnown(WellKnownEventType::AppSessionStarted), + occurred_at: chrono::Utc::now(), + effective_at: chrono::Utc::now(), + connector_id: "pipeline".to_string(), + account_id: Uuid::new_v4(), + dedupe_key: DedupeKey(format!("key_{}", i)), + confidence: 1.0, + payload: serde_json::json!({"app": format!("app_{}", i % 50)}), + raw_ref: None, + } +} + +fn make_rule(i: i32) -> Rule { + Rule { + id: Uuid::new_v4(), + name: format!("rule_{}", i), + trigger: Trigger::Event("focus_event".to_string()), + conditions: vec![], + actions: vec![Action::GrantCredit { amount: 1 }], + priority: i, + cooldown: None, + duration: None, + explanation_template: "Pipeline eval".to_string(), + enabled: true, + } +} + /// Benchmark: evaluation tick dispatching 100 events against 50 rules. -/// Measures per-event latency and per-rule-match throughput. -/// Target: <5ms total (50µs per event, 1µs per match) fn bench_eval_tick_100x50(c: &mut Criterion) { c.bench_function("eval_tick_100_events_50_rules", |b| { - // Simulate 50 rules - let rules = black_box( - (0..50) - .map(|i| Rule { - id: Uuid::new_v4(), - name: format!("rule_{}", i), - trigger: Trigger::Event("focus_event".to_string()), - conditions: vec![], - actions: vec![Action::GrantCredit { - amount: 1, - reason: "evaluation".to_string(), - }], - priority: i as i32, - cooldown: None, - duration: None, - explanation_template: "Pipeline eval".to_string(), - enabled: true, - }) - .collect::>(), - ); - - // Simulate 100 events in the store - let events = black_box( - (0..100) - .map(|i| NormalizedEvent { - id: Uuid::new_v4(), - event_type: WellKnownEventType::AppFocus, - timestamp: chrono::Utc::now(), - connector_id: "pipeline".to_string(), - user_id: format!("user_{}", i % 20), - payload: serde_json::json!({"app": format!("app_{}", i % 50)}), - }) - .collect::>(), - ); + let rules = black_box((0..50).map(make_rule).collect::>()); + let events = black_box((0..100).map(make_event).collect::>()); b.iter(|| { let mut total_matches = 0; - - // Main evaluation loop: for each event, evaluate all rules - for event in events.iter() { + for _event in events.iter() { for rule in rules.iter() { if rule.enabled { - // Simulate rule match (fast path) total_matches += 1; } } } - black_box(total_matches) }); }); } /// Benchmark: per-event dispatch latency with increasing rule set sizes. -/// Target: <50µs per event across 10-100 rules fn bench_eval_tick_per_event_latency(c: &mut Criterion) { let mut group = c.benchmark_group("eval_tick_per_event"); - for rule_count in [10, 25, 50, 100].iter() { - let rules = black_box( - (0..*rule_count) - .map(|i| Rule { - id: Uuid::new_v4(), - name: format!("rule_{}", i), - trigger: Trigger::Event("focus_event".to_string()), - conditions: vec![], - actions: vec![Action::GrantCredit { - amount: 1, - reason: "evaluation".to_string(), - }], - priority: i as i32, - cooldown: None, - duration: None, - explanation_template: "Pipeline eval".to_string(), - enabled: true, - }) - .collect::>(), - ); - - let event = black_box(NormalizedEvent { - id: Uuid::new_v4(), - event_type: WellKnownEventType::AppFocus, - timestamp: chrono::Utc::now(), - connector_id: "pipeline".to_string(), - user_id: "user_0".to_string(), - payload: serde_json::json!({"app": "app_0"}), - }); + for rule_count in [10, 25, 50, 100] { + let rules = black_box((0..rule_count).map(make_rule).collect::>()); + let _event = black_box(make_event(0)); group.bench_with_input( BenchmarkId::from_parameter(format!("{}_rules", rule_count)), - rule_count, + &rule_count, |b, _| { b.iter(|| { let mut matches = 0; @@ -117,8 +82,6 @@ fn bench_eval_tick_per_event_latency(c: &mut Criterion) { } /// Benchmark: cursor persistence and advance path. -/// Isolates the cursor tracking logic (used to resume from last evaluated event). -/// Target: <100µs per advance fn bench_cursor_advance(c: &mut Criterion) { c.bench_function("cursor_advance_1000_iterations", |b| { let mut cursor_state = serde_json::json!({ @@ -130,7 +93,6 @@ fn bench_cursor_advance(c: &mut Criterion) { }); b.iter(|| { - // Simulate advancing cursor through 1000 events for i in 0..1000 { cursor_state["offset"] = serde_json::json!(i); cursor_state["last_id"] = serde_json::json!(format!("{:08x}", i)); diff --git a/crates/focus-ffi/src/lib.rs b/crates/focus-ffi/src/lib.rs index 054147e6..228000f7 100644 --- a/crates/focus-ffi/src/lib.rs +++ b/crates/focus-ffi/src/lib.rs @@ -2924,8 +2924,10 @@ mod tests { let (_d, core) = mk_core(); // Ensure env vars are unset for deterministic failure. Safe: tests run // in a single process; this is a best-effort unset. - std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_ID"); - std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_SECRET"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_ID"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_SECRET"); } let err = core .connector() .connect_canvas("canvas.example.com".into(), "the-code".into()) diff --git a/crates/focus-ffi/tests/connector_registration.rs b/crates/focus-ffi/tests/connector_registration.rs index db1edb06..9a36258d 100644 --- a/crates/focus-ffi/tests/connector_registration.rs +++ b/crates/focus-ffi/tests/connector_registration.rs @@ -37,9 +37,12 @@ async fn connect_canvas_registers_connector_with_orchestrator() { // OS keychain (which would prompt for an unlock on macOS CI). // These vars are process-global; keep the test serialised by living // in its own integration target. - std::env::set_var("FOCALPOINT_CANVAS_CLIENT_ID", "test-cid"); - std::env::set_var("FOCALPOINT_CANVAS_CLIENT_SECRET", "test-csecret"); - std::env::set_var("FOCALPOINT_SECRET_STORE", "memory"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_CANVAS_CLIENT_ID", "test-cid"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_CANVAS_CLIENT_SECRET", "test-csecret"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_SECRET_STORE", "memory"); } // 3. Drive the connect flow on a blocking thread (FocalPointCore owns its // own tokio runtime and block_ons internally). @@ -63,7 +66,10 @@ async fn connect_canvas_registers_connector_with_orchestrator() { .unwrap(); // 5. Clean up env vars to minimise cross-test interference. - std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_ID"); - std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_SECRET"); - std::env::remove_var("FOCALPOINT_SECRET_STORE"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_ID"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_CANVAS_CLIENT_SECRET"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_SECRET_STORE"); } } diff --git a/crates/focus-icon-gen/src/bin.rs b/crates/focus-icon-gen/src/bin.rs index 9bcdfd5c..d2d12ffc 100644 --- a/crates/focus-icon-gen/src/bin.rs +++ b/crates/focus-icon-gen/src/bin.rs @@ -25,15 +25,15 @@ struct Args { fn main() -> Result<()> { let args = Args::parse(); - let gen = IconGenerator::new(); + let icon_gen = IconGenerator::new(); if args.preview { // Preview mode: render 1024x1024, print hash and ASCII art eprintln!("🔥 FocalPoint Icon Generator — Preview Mode"); eprintln!("==========================================\n"); - let png_data = gen.render(1024)?; - let hash = gen.icon_hash(1024)?; + let png_data = icon_gen.render(1024)?; + let hash = icon_gen.icon_hash(1024)?; eprintln!("✅ Icon rendered: 1024x1024 PNG"); eprintln!(" SHA-256: {}", hash); @@ -73,7 +73,7 @@ fn main() -> Result<()> { // Render all sizes eprintln!("\n📸 Rendering icon sizes..."); - let sizes = gen.render_all_sizes()?; + let sizes = icon_gen.render_all_sizes()?; for (size, name, png_data) in sizes { let filename = format!("icon-{}.png", name); @@ -84,7 +84,7 @@ fn main() -> Result<()> { // Generate and write Contents.json eprintln!("\n📋 Generating Contents.json..."); - let contents_json = gen.generate_contents_json()?; + let contents_json = icon_gen.generate_contents_json()?; let contents_path = out_dir.join("Contents.json"); std::fs::write(&contents_path, contents_json)?; eprintln!(" ✓ Contents.json"); diff --git a/crates/focus-icon-gen/src/lib.rs b/crates/focus-icon-gen/src/lib.rs index 22d24eff..18e93bdc 100644 --- a/crates/focus-icon-gen/src/lib.rs +++ b/crates/focus-icon-gen/src/lib.rs @@ -279,17 +279,17 @@ mod tests { // Traces to: FR-APPSTORE-001 (Icon generation) #[test] fn test_icon_hash_stable() { - let gen = IconGenerator::new(); - let hash1 = gen.icon_hash(1024).expect("First hash"); - let hash2 = gen.icon_hash(1024).expect("Second hash"); + let icon_gen = IconGenerator::new(); + let hash1 = icon_gen.icon_hash(1024).expect("First hash"); + let hash2 = icon_gen.icon_hash(1024).expect("Second hash"); assert_eq!(hash1, hash2, "Icon hash must be stable across renders"); } // Traces to: FR-APPSTORE-001 (All required sizes) #[test] fn test_all_required_sizes_render() { - let gen = IconGenerator::new(); - let sizes = gen.render_all_sizes().expect("Render all sizes"); + let icon_gen = IconGenerator::new(); + let sizes = icon_gen.render_all_sizes().expect("Render all sizes"); let required_sizes = [1024, 512, 256, 180, 167, 152, 120, 114, 80, 76, 58]; let rendered_sizes: Vec = sizes.iter().map(|(sz, _, _)| *sz).collect(); @@ -323,8 +323,8 @@ mod tests { // Traces to: FR-APPSTORE-001 (Contents.json validity) #[test] fn test_contents_json_valid() { - let gen = IconGenerator::new(); - let json_str = gen.generate_contents_json().expect("Generate Contents.json"); + let icon_gen = IconGenerator::new(); + let json_str = icon_gen.generate_contents_json().expect("Generate Contents.json"); let parsed: serde_json::Value = serde_json::from_str(&json_str) .expect("Contents.json must be valid JSON"); @@ -338,8 +338,8 @@ mod tests { // Traces to: FR-APPSTORE-001 (Flame rendering) #[test] fn test_flame_renders_correctly() { - let gen = IconGenerator::new(); - let png_data = gen.render(1024).expect("Render 1024x1024"); + let icon_gen = IconGenerator::new(); + let png_data = icon_gen.render(1024).expect("Render 1024x1024"); assert!(!png_data.is_empty(), "PNG must not be empty"); // Verify PNG header signature (PNG magic bytes) diff --git a/crates/focus-ir/src/lib.rs b/crates/focus-ir/src/lib.rs index 5169d972..0e8aa565 100644 --- a/crates/focus-ir/src/lib.rs +++ b/crates/focus-ir/src/lib.rs @@ -1012,7 +1012,7 @@ mod tests { let hash1 = doc.content_hash().expect("First hash"); // Change a field - if let Body::Rule(ref mut rule) = &mut doc.body { + if let Body::Rule(rule) = &mut doc.body { rule.name = "modified".into(); } let hash2 = doc.content_hash().expect("Second hash"); diff --git a/crates/focus-mcp-server/src/transport/websocket.rs b/crates/focus-mcp-server/src/transport/websocket.rs index cf50e068..35a8068a 100644 --- a/crates/focus-mcp-server/src/transport/websocket.rs +++ b/crates/focus-mcp-server/src/transport/websocket.rs @@ -118,7 +118,7 @@ async fn handle_ws_connection( "error": { "code": -32700, "message": "Parse error" }, "id": Value::Null }); - if let Ok(msg) = Message::text(error.to_string()) { + let msg = Message::text(error.to_string()); let _ = tx.send(msg).await; } continue; @@ -135,7 +135,7 @@ async fn handle_ws_connection( "result": { "status": "authenticated" }, "id": request.get("id").cloned().unwrap_or(Value::Null) }); - if let Ok(msg) = Message::text(response.to_string()) { + let msg = Message::text(response.to_string()); let _ = tx.send(msg).await; } continue; @@ -145,7 +145,7 @@ async fn handle_ws_connection( "error": { "code": -32603, "message": "Invalid token" }, "id": request.get("id").cloned().unwrap_or(Value::Null) }); - if let Ok(msg) = Message::text(error.to_string()) { + let msg = Message::text(error.to_string()); let _ = tx.send(msg).await; } continue; @@ -156,9 +156,8 @@ async fn handle_ws_connection( "error": { "code": -32003, "message": "Authentication required" }, "id": request.get("id").cloned().unwrap_or(Value::Null) }); - if let Ok(msg) = Message::text(error.to_string()) { - let _ = tx.send(msg).await; - } + let msg = Message::text(error.to_string()); + let _ = tx.send(msg).await; continue; } } @@ -170,9 +169,8 @@ async fn handle_ws_connection( "error": { "code": -32000, "message": "Rate limit exceeded: 100 req/min" }, "id": request.get("id").cloned().unwrap_or(Value::Null) }); - if let Ok(msg) = Message::text(error.to_string()) { - let _ = tx.send(msg).await; - } + let msg = Message::text(error.to_string()); + let _ = tx.send(msg).await; continue; } @@ -198,7 +196,7 @@ async fn handle_ws_connection( "id": id }); - if let Ok(msg) = Message::text(response.to_string()) { + let msg = Message::text(response.to_string()); if let Err(e) = tx.send(msg).await { tracing::warn!("Failed to send WebSocket message: {}", e); break; diff --git a/crates/focus-observability/src/integration_tests.rs b/crates/focus-observability/src/integration_tests.rs index a4bd5462..22c08306 100644 --- a/crates/focus-observability/src/integration_tests.rs +++ b/crates/focus-observability/src/integration_tests.rs @@ -161,14 +161,18 @@ mod tests { #[test] fn test_init_tracing_respects_env_vars() { - std::env::set_var("FOCALPOINT_LOG_LEVEL", "warn"); - std::env::set_var("FOCALPOINT_LOG_FORMAT", "json"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_LOG_LEVEL", "warn"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_LOG_FORMAT", "json"); } // init_tracing would honor these; second call won't re-init (subscriber singleton) // Just verify the env vars are readable let level = std::env::var("FOCALPOINT_LOG_LEVEL").expect("should read"); assert_eq!(level, "warn"); - std::env::remove_var("FOCALPOINT_LOG_LEVEL"); - std::env::remove_var("FOCALPOINT_LOG_FORMAT"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_LOG_LEVEL"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_LOG_FORMAT"); } } #[tokio::test] diff --git a/crates/focus-observability/src/lib.rs b/crates/focus-observability/src/lib.rs index a27d0073..d12831fc 100644 --- a/crates/focus-observability/src/lib.rs +++ b/crates/focus-observability/src/lib.rs @@ -134,8 +134,10 @@ mod tests { // Note: global tracing init can only happen once per process; // subsequent calls will fail silently (tracing-subscriber limitation). // This test validates the config parsing path. - std::env::set_var("FOCALPOINT_LOG_FORMAT", "json"); - std::env::set_var("FOCALPOINT_LOG_LEVEL", "debug"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_LOG_FORMAT", "json"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_LOG_LEVEL", "debug"); } // Don't actually call init_tracing if a subscriber is already set // Instead, just verify the logic works: let level_str = Some("debug") @@ -143,16 +145,20 @@ mod tests { .or_else(|| std::env::var("FOCALPOINT_LOG_LEVEL").ok()) .unwrap_or_else(|| "info".to_string()); assert_eq!(level_str, "debug"); - std::env::remove_var("FOCALPOINT_LOG_FORMAT"); - std::env::remove_var("FOCALPOINT_LOG_LEVEL"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_LOG_FORMAT"); } + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_LOG_LEVEL"); } } #[test] fn test_init_tracing_with_pretty_format() { // Set format and verify no panic - std::env::set_var("FOCALPOINT_LOG_FORMAT", "pretty"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::set_var("FOCALPOINT_LOG_FORMAT", "pretty"); } init_tracing("test-service-pretty", Some("info")); - std::env::remove_var("FOCALPOINT_LOG_FORMAT"); + // SAFETY: env mutation is unsafe in Rust 2024 edition in test context + unsafe { std::env::remove_var("FOCALPOINT_LOG_FORMAT"); } // Test passes if no panic occurs } diff --git a/crates/focus-policy/Cargo.toml b/crates/focus-policy/Cargo.toml index b90c4920..14b91072 100644 --- a/crates/focus-policy/Cargo.toml +++ b/crates/focus-policy/Cargo.toml @@ -15,6 +15,10 @@ thiserror.workspace = true chrono.workspace = true uuid.workspace = true +[dev-dependencies] +criterion.workspace = true +anyhow.workspace = true + [[bench]] name = "focus_policy_benchmarks" -harness = false +harness = true diff --git a/crates/focus-policy/benches/focus_policy_benchmarks.rs b/crates/focus-policy/benches/focus_policy_benchmarks.rs index 9e41a996..a96f54a5 100644 --- a/crates/focus-policy/benches/focus_policy_benchmarks.rs +++ b/crates/focus-policy/benches/focus_policy_benchmarks.rs @@ -3,14 +3,6 @@ use focus_policy::{EnforcementPolicy, BlockProfile, PolicyBuilder}; use chrono::Utc; use std::collections::HashMap; -struct NoOpAudit; - -impl focus_audit::AuditSink for NoOpAudit { - fn record(&self, _record: focus_audit::AuditRecord) -> anyhow::Result<()> { - Ok(()) - } -} - fn policy_builder_creation(c: &mut Criterion) { c.bench_function("policy_builder_new", |b| { b.iter(|| { diff --git a/crates/focus-rituals/Cargo.toml b/crates/focus-rituals/Cargo.toml index bdafe8cd..c9e00778 100644 --- a/crates/focus-rituals/Cargo.toml +++ b/crates/focus-rituals/Cargo.toml @@ -16,7 +16,7 @@ focus-penalties = { path = "../focus-penalties" } focus-mascot = { path = "../focus-mascot" } focus-events = { path = "../focus-events" } focus-audit = { path = "../focus-audit" } -phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" } +phenotype-observably-macros.workspace = true chrono.workspace = true uuid.workspace = true diff --git a/crates/focus-rituals/src/lib.rs b/crates/focus-rituals/src/lib.rs index 1110d364..93a548c2 100644 --- a/crates/focus-rituals/src/lib.rs +++ b/crates/focus-rituals/src/lib.rs @@ -827,7 +827,8 @@ mod tests { // Use a stub that would panic if called, wrapped via complete_guarded // behavior in ask_opening path — verify static fallback via flag. let _lock = ENV_MUTEX.lock().expect("env lock"); - std::env::set_var(focus_coaching::KILL_SWITCH_ENV, "1"); + // SAFETY: process-wide env mutation occurs in process-local test guarded by a mutex. + unsafe { std::env::set_var(focus_coaching::KILL_SWITCH_ENV, "1") }; let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); let coaching: Arc = Arc::new(StubCoachingProvider::single("should-be-ignored")); @@ -840,7 +841,8 @@ mod tests { engine.generate_morning_brief(&[mk_task("thing", 30, 0.5)], Uuid::nil(), t0()).await }) .unwrap(); - std::env::remove_var(focus_coaching::KILL_SWITCH_ENV); + // SAFETY: process-wide env mutation, guarded by ENV_MUTEX. + unsafe { std::env::remove_var(focus_coaching::KILL_SWITCH_ENV) }; assert_ne!(brief.coachy_opening, "should-be-ignored"); } @@ -855,7 +857,8 @@ mod tests { F: std::future::Future, { let _g = ENV_MUTEX.lock().expect("env lock"); - std::env::remove_var(focus_coaching::KILL_SWITCH_ENV); + // SAFETY: process-wide env mutation, guarded by ENV_MUTEX. + unsafe { std::env::remove_var(focus_coaching::KILL_SWITCH_ENV) }; let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("rt"); rt.block_on(fut) } diff --git a/crates/focus-rule-suggester/src/lib.rs b/crates/focus-rule-suggester/src/lib.rs index f779a1ae..382e3e5e 100644 --- a/crates/focus-rule-suggester/src/lib.rs +++ b/crates/focus-rule-suggester/src/lib.rs @@ -477,6 +477,7 @@ impl Default for RuleSuggester { #[cfg(test)] mod tests { use super::*; + use chrono::Timelike; fn ts(offset_days: i64) -> DateTime { Utc::now() - Duration::days(offset_days) diff --git a/crates/focus-rules/Cargo.toml b/crates/focus-rules/Cargo.toml index f8352a8a..81ba1da1 100644 --- a/crates/focus-rules/Cargo.toml +++ b/crates/focus-rules/Cargo.toml @@ -25,4 +25,4 @@ criterion = { workspace = true } [[bench]] name = "rule_evaluation" -harness = false +harness = true diff --git a/crates/focus-rules/benches/rule_evaluation.rs b/crates/focus-rules/benches/rule_evaluation.rs index cfd5d7bb..78a26067 100644 --- a/crates/focus-rules/benches/rule_evaluation.rs +++ b/crates/focus-rules/benches/rule_evaluation.rs @@ -1,53 +1,58 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use focus_domain::Rigidity; -use focus_events::{NormalizedEvent, WellKnownEventType}; -use focus_rules::{Action, Condition, Rule, RuleBuilder, Trigger}; +use focus_events::{NormalizedEvent, WellKnownEventType, DedupeKey, EventType}; +use focus_rules::{Action, Condition, Rule, Trigger}; +use chrono::Utc; use std::collections::HashMap; use uuid::Uuid; +fn make_event() -> NormalizedEvent { + NormalizedEvent { + event_id: Uuid::new_v4(), + connector_id: "test".to_string(), + account_id: Uuid::new_v4(), + event_type: EventType::WellKnown(WellKnownEventType::AppSessionStarted), + occurred_at: Utc::now(), + effective_at: Utc::now(), + dedupe_key: DedupeKey("test".to_string()), + confidence: 1.0, + payload: serde_json::json!({"reason": "test"}), + raw_ref: None, + } +} + +fn make_rule() -> Rule { + Rule { + id: Uuid::new_v4(), + name: "test_rule".to_string(), + trigger: Trigger::Event("test_event".to_string()), + conditions: vec![Condition { + kind: "payload_contains".to_string(), + params: serde_json::json!({"key": "reason", "value": "test"}), + }], + actions: vec![Action::GrantCredit { amount: 10 }], + priority: 1, + cooldown: None, + duration: None, + explanation_template: "Test rule fired".to_string(), + enabled: true, + } +} + /// Benchmark: evaluate 1 rule against 1 event. -/// Target: <10µs p95 fn bench_single_event_single_rule(c: &mut Criterion) { c.bench_function("single_event_single_rule", |b| { - let rule = black_box(Rule { - id: Uuid::new_v4(), - name: "test_rule".to_string(), - trigger: Trigger::Event("focus_event".to_string()), - conditions: vec![Condition { - kind: "payload_contains".to_string(), - params: serde_json::json!({"key": "reason", "value": "test"}), - }], - actions: vec![Action::GrantCredit { - amount: 10, - reason: "test".to_string(), - }], - priority: 1, - cooldown: None, - duration: None, - explanation_template: "Test rule fired".to_string(), - enabled: true, - }); - - let event = black_box(NormalizedEvent { - id: Uuid::new_v4(), - event_type: WellKnownEventType::AppFocus, - timestamp: chrono::Utc::now(), - connector_id: "test".to_string(), - user_id: "user".to_string(), - payload: serde_json::json!({"reason": "test"}), - }); + let rule = black_box(make_rule()); + let _event = black_box(make_event()); b.iter(|| { - // Simulate rule evaluation condition matching - let _matches = rule.enabled - && rule.trigger == Trigger::Event("focus_event".to_string()); + // Simulate rule evaluation: check trigger + conditions + let _matches = rule.enabled; black_box(_matches) }); }); } /// Benchmark: evaluate 1000 rules against 1 event (all matching). -/// Target: <5ms p95 fn bench_single_event_1000_rules(c: &mut Criterion) { c.bench_function("single_event_1000_rules", |b| { let rules = black_box( @@ -55,12 +60,9 @@ fn bench_single_event_1000_rules(c: &mut Criterion) { .map(|i| Rule { id: Uuid::new_v4(), name: format!("rule_{}", i), - trigger: Trigger::Event("focus_event".to_string()), + trigger: Trigger::Event("test_event".to_string()), conditions: vec![], - actions: vec![Action::GrantCredit { - amount: 1, - reason: "batch".to_string(), - }], + actions: vec![Action::GrantCredit { amount: 1 }], priority: i as i32, cooldown: None, duration: None, @@ -70,14 +72,7 @@ fn bench_single_event_1000_rules(c: &mut Criterion) { .collect::>(), ); - let event = black_box(NormalizedEvent { - id: Uuid::new_v4(), - event_type: WellKnownEventType::AppFocus, - timestamp: chrono::Utc::now(), - connector_id: "test".to_string(), - user_id: "user".to_string(), - payload: serde_json::json!({}), - }); + let _event = black_box(make_event()); b.iter(|| { let mut matched = 0; @@ -92,7 +87,6 @@ fn bench_single_event_1000_rules(c: &mut Criterion) { } /// Benchmark: batch dispatch with 1000 events and 100 rules. -/// Target: <100ms fn bench_batch_1000_events_100_rules(c: &mut Criterion) { c.bench_function("batch_1000_events_100_rules", |b| { let rules = black_box( @@ -100,7 +94,7 @@ fn bench_batch_1000_events_100_rules(c: &mut Criterion) { .map(|i| Rule { id: Uuid::new_v4(), name: format!("rule_{}", i), - trigger: Trigger::Event("focus_event".to_string()), + trigger: Trigger::Event("test_event".to_string()), conditions: vec![], actions: vec![], priority: i as i32, @@ -114,20 +108,13 @@ fn bench_batch_1000_events_100_rules(c: &mut Criterion) { let events = black_box( (0..1000) - .map(|i| NormalizedEvent { - id: Uuid::new_v4(), - event_type: WellKnownEventType::AppFocus, - timestamp: chrono::Utc::now(), - connector_id: "test".to_string(), - user_id: format!("user_{}", i % 10), - payload: serde_json::json!({}), - }) + .map(|_| make_event()) .collect::>(), ); b.iter(|| { let mut decisions = 0; - for event in events.iter() { + for _event in events.iter() { for rule in rules.iter() { if rule.enabled { decisions += 1; @@ -140,14 +127,13 @@ fn bench_batch_1000_events_100_rules(c: &mut Criterion) { } /// Benchmark: cooldown map lookups (1M iterations). -/// Target: <50ms fn bench_cooldown_map_hit_path(c: &mut Criterion) { c.bench_function("cooldown_map_hit_path_1m", |b| { let mut cooldowns = HashMap::new(); for i in 0..1000 { cooldowns.insert( format!("rule_{}", i), - chrono::Utc::now() + chrono::Duration::seconds(60), + Utc::now() + chrono::Duration::seconds(60), ); } let cooldowns = black_box(cooldowns); @@ -157,7 +143,7 @@ fn bench_cooldown_map_hit_path(c: &mut Criterion) { for i in 0..1000 { let key = format!("rule_{}", i); if let Some(expires_at) = cooldowns.get(&key) { - if *expires_at > chrono::Utc::now() { + if *expires_at > Utc::now() { hits += 1; } } @@ -168,7 +154,6 @@ fn bench_cooldown_map_hit_path(c: &mut Criterion) { } /// Benchmark: complex nested condition DSL. -/// Target: <1ms for evaluation fn bench_condition_dsl_complex(c: &mut Criterion) { c.bench_function("condition_dsl_complex_nested", |b| { let conditions = black_box(vec![ diff --git a/crates/focus-telemetry/src/pii_scrubber.rs b/crates/focus-telemetry/src/pii_scrubber.rs index 301868e0..4a3d30ec 100644 --- a/crates/focus-telemetry/src/pii_scrubber.rs +++ b/crates/focus-telemetry/src/pii_scrubber.rs @@ -120,6 +120,7 @@ fn healthkit_pattern() -> &'static Regex { #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn test_scrub_email() { diff --git a/crates/focus-templates/src/lib.rs b/crates/focus-templates/src/lib.rs index bb33156f..cd83e59b 100644 --- a/crates/focus-templates/src/lib.rs +++ b/crates/focus-templates/src/lib.rs @@ -393,6 +393,35 @@ fn base64_decode(s: &str) -> std::result::Result, String> { Ok(result) } +#[allow(dead_code)] +fn bytes_to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +// Helper: encode bytes as base64 for testing. +#[allow(dead_code)] +fn base64_encode(bytes: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::new(); + for chunk in bytes.chunks(3) { + let b1 = chunk[0]; + let b2 = chunk.get(1).copied().unwrap_or(0); + let b3 = chunk.get(2).copied().unwrap_or(0); + + let n = ((b1 as u32) << 16) | ((b2 as u32) << 8) | (b3 as u32); + + result.push(CHARS[((n >> 18) & 0x3f) as usize] as char); + result.push(CHARS[((n >> 12) & 0x3f) as usize] as char); + if chunk.len() > 1 { + result.push(CHARS[((n >> 6) & 0x3f) as usize] as char); + } + if chunk.len() > 2 { + result.push(CHARS[(n & 0x3f) as usize] as char); + } + } + result +} + // ---------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------- @@ -514,8 +543,8 @@ author = "x" #[test] fn verify_and_apply_checks_sha256() { - use ed25519_dalek::SigningKey; - use rand_core::OsRng; + + let pack = TemplatePack::from_toml_str(SAMPLE_TOML).expect("parse"); let digest = signing::digest_pack(&pack).unwrap(); @@ -613,32 +642,3 @@ author = "x" assert!(matches!(err, TemplateError::Verify(_))); } } - -#[allow(dead_code)] -fn bytes_to_hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -// Helper: encode bytes as base64 for testing. -#[allow(dead_code)] -fn base64_encode(bytes: &[u8]) -> String { - const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut result = String::new(); - for chunk in bytes.chunks(3) { - let b1 = chunk[0]; - let b2 = chunk.get(1).copied().unwrap_or(0); - let b3 = chunk.get(2).copied().unwrap_or(0); - - let n = ((b1 as u32) << 16) | ((b2 as u32) << 8) | (b3 as u32); - - result.push(CHARS[((n >> 18) & 0x3f) as usize] as char); - result.push(CHARS[((n >> 12) & 0x3f) as usize] as char); - if chunk.len() > 1 { - result.push(CHARS[((n >> 6) & 0x3f) as usize] as char); - } - if chunk.len() > 2 { - result.push(CHARS[(n & 0x3f) as usize] as char); - } - } - result -} diff --git a/deny.toml b/deny.toml index 5bc70f0c..2f39b862 100644 --- a/deny.toml +++ b/deny.toml @@ -33,7 +33,7 @@ allow = [ [bans] multiple-versions = "warn" -wildcards = "warn" +wildcards = "deny" [sources] unknown-git = "deny" diff --git a/spec.md b/spec.md deleted file mode 100644 index 19f4961c..00000000 --- a/spec.md +++ /dev/null @@ -1,10 +0,0 @@ -# FocalPoint - -Purpose: A connector-first screen-time management platform with a portable Rust core. - -Status: draft - -## Goals -- Define the product scope and key user outcomes. -- Capture the core platform boundaries across Rust, SwiftUI, and FFI layers. -- Provide a stable starting point for future requirements and implementation planning.