diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f7b26..cc0d3a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,4 +86,4 @@ jobs: - name: Smoke test import run: | cd /tmp - python -c "import faff_core; from faff_core.models import Intent, Log; from faff_core.plugins import PlanSource; print('✓ All imports successful')" + python -c "import faff_core; from faff_core.models import Log, Session, Plan; from faff_core.plugins import PlanSource; print('✓ All imports successful')" diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index dddb9c6..8b78455 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -54,10 +54,10 @@ jobs: - runner: ubuntu-latest os: linux target: aarch64 - - runner: macos-13 # x86_64 + - runner: macos-latest # ARM64, cross-compiles x86_64 os: macos target: x86_64 - - runner: macos-14 # ARM64 + - runner: macos-latest # ARM64 os: macos-arm target: aarch64 - runner: windows-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f64f24..b9469be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,10 +59,10 @@ jobs: - runner: ubuntu-latest os: linux target: aarch64 - - runner: macos-13 # x86_64 + - runner: macos-latest # ARM64, cross-compiles x86_64 os: macos target: x86_64 - - runner: macos-14 # ARM64 + - runner: macos-latest # ARM64 os: macos-arm target: aarch64 - runner: windows-latest @@ -207,7 +207,7 @@ jobs: merge-multiple: true - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: name: Release v${{ needs.prepare-release.outputs.version }} files: dist/* diff --git a/BUSINESS_LOGIC_AUDIT.md b/BUSINESS_LOGIC_AUDIT.md deleted file mode 100644 index c8d8b8f..0000000 --- a/BUSINESS_LOGIC_AUDIT.md +++ /dev/null @@ -1,284 +0,0 @@ -# Business Logic Audit - -Audit date: 2025-11-21 - -This document tracks business logic that needs to be moved from faff-cli (Python) and the Python bindings into the Rust core. - ---- - -## Architectural Note: Functional Core Pattern - -The current architecture has managers that hold storage references and do both IO and logic. The bindings layer ends up doing orchestration (gathering context from workspace before calling manager methods). - -A cleaner pattern would be **functional core, imperative shell**: - -- **Pure functions** (`log_ops`, `plan_ops`, etc.) - Take data, return data. No IO, no async. All validation and transformation logic lives here. Trivially testable. -- **Workspace** - Does all IO and orchestration. Reads state, calls pure functions, writes state. -- **Bindings** - Thin wrappers that just call `workspace.method()`. - -Example: -```rust -// Pure function - no IO -fn start_intent(log: Log, intent: Intent, start: DateTime, now: DateTime, ...) -> Result - -// Workspace does IO + orchestration -impl Workspace { - async fn start_intent(&self, intent: Intent, start_time: Option, ...) -> Result<()> { - let log = self.read_log_or_empty(date).await?; - let trackers = self.read_trackers(date).await?; - let updated = log_ops::start_intent(log, intent, start, now, &trackers)?; - self.write_log(&updated, &trackers).await - } -} -``` - -This is a significant refactor tracked separately from the items below. - ---- - -## Priority Legend - -- **CRITICAL** - Data integrity risk, must fix -- **HIGH** - Significant logic duplication, should fix soon -- **MEDIUM** - Would improve consistency, fix when convenient -- **LOW** - Minor improvement, nice to have - ---- - -## faff-cli (Python CLI) - -### CRITICAL - -#### [x] Timeline Validation (`start.py:114-155`) - DONE -The `--since` flag implementation contains overlap detection, future-time checks, and conflict validation. This is critical for data integrity - if validation only exists in CLI, other tools could corrupt the timeline. - -**Moved to Rust:** `LogManager::start_intent()` now validates: -- Start time not in future -- No conflict with active session (must be after its start) -- No conflict with completed session (must be after its end) -- Auto-stops active session when starting new one - -**Note:** Bindings still gather context (now, trackers) from workspace - see architectural note above. - ---- - -### HIGH - -#### [x] Duration Aggregations (`log.py:276-332`) - DONE -The `faff log summary` command calculates: -- Total recorded time -- Time aggregated by intent -- Time aggregated by tracker and tracker source -- Weighted mean reflection score - -**Moved to Rust:** Added `Log::summary()` method that returns `LogSummary`: -- `total_minutes: i64` - Total time in minutes -- `by_intent: HashMap` - Minutes per intent alias -- `by_tracker: HashMap` - Minutes per tracker -- `by_tracker_source: HashMap` - Minutes per tracker source prefix -- `mean_reflection_score: Option` - Weighted mean (by duration) - -CLI now just formats the summary dict from Rust. - ---- - -#### [ ] Session Statistics (`intent.py:269-294`, `field.py:76-127`) -Intent and field list commands parse raw TOML files to build usage statistics: -- Counting sessions per intent ID -- Counting unique logs per intent -- Building field data with usage counts - -**Target:** Add `LogManager::get_intent_usage_stats()` and enhance `get_field_usage_stats()` in Rust - ---- - -#### [x] Active Session Duration (`session.py:106-111`, `main.py:334-342`) - DONE -Multiple places calculate active session duration by checking if end_time is None and using current time. - -**Moved to Rust:** Added `Session::elapsed(now)` method for open sessions. -- `duration` property: for closed sessions (raises error if open) -- `elapsed(now)` method: for open sessions (raises error if closed) - -CLI now uses the appropriate method based on session state. - ---- - -#### [x] Stale Timesheet Detection (`main.py:385-404`) - ALREADY IN RUST -The status command finds timesheets where logs changed after compilation. - -**Status:** Already implemented in Rust (`TimesheetManager::find_stale_timesheets`) and exposed in Python/WASM bindings. CLI already uses it. - ---- - -#### [x] Filter Operators (`filtering.py:222-249`) - ALREADY IN RUST -Filter matching logic for session queries: -- Exact match (`=`) with string comparison -- Contains match (`~`) with case-insensitive substring -- Not equal (`!=`) operator - -**Status:** Core filtering is in Rust (`query::Filter::matches()` on Session objects). The CLI's `matches_filter()` in `filtering.py` operates on display dicts (for log/session listings), which is appropriate display-layer logic. The Rust `query_sessions()` function handles session filtering properly. - ---- - -### MEDIUM - -#### [ ] Compilation Validation (`main.py:201-244`) -Before compiling timesheets, CLI checks: -- Whether log has unclosed sessions -- Which logs need compilation (batch mode) -- Building timesheet existence tuples - -**Target:** Add `LogManager::can_compile()` or validation in `TimesheetManager::compile()` in Rust - ---- - -#### [ ] Intent Deduplication (`start.py:187-211`) -When presenting intents to user, CLI deduplicates by alias and adds ID suffix for disambiguation. - -**Target:** This might be acceptable as display logic, but could add `PlanManager::get_intents_for_display()` that returns pre-deduplicated list with disambiguation info. - ---- - -#### [ ] Session Data Transformation (`session.py:96-128`) -Converting sessions to display dictionaries, extracting metadata, handling active session end times. - -**Target:** Most of this is display formatting (acceptable in CLI), but duration calculation should use Rust. - ---- - -#### [ ] Log Status Determination (`log.py:116-141`) -Determining if log is closed, calculating stats for display. - -**Target:** Add `Log::is_closed()`, `Log::total_duration()`, `Log::reflection_stats()` in Rust (some may exist). - ---- - -### LOW - -#### [ ] Plan Validity Display (`plan.py:34-45`) -Determining valid_until display (infinity symbol for None). - -**Assessment:** This is display formatting, acceptable in CLI. - ---- - -#### [ ] Query Duration Conversion (`query.py:33-52`) -Converting duration seconds to timedelta after calling Rust. - -**Assessment:** Acceptable - Rust returns seconds, Python converts to its native type. - ---- - ---- - -## bindings-python (Rust Bindings) - -### HIGH - -#### [ ] LogManager Workspace Context Injection - -Three methods in `log_manager.rs` follow this pattern: -```rust -let workspace = self.workspace.as_ref()?; -let current_date = workspace.today(); -let current_time = workspace.now(); -let trackers = rt.block_on(workspace.plans().get_trackers(current_date))?; -rt.block_on(self.inner.method(...)) -``` - -**Affected methods:** -- `start_intent_now()` (lines 182-214) -- `start_intent_at()` (lines 219-254) -- `stop_current_session()` (lines 257-282) - -**Target:** Add workspace-aware methods to Rust `LogManager`: -- `start_intent_now_with_workspace(&self, workspace: &Workspace, intent: Intent, note: Option)` -- `start_intent_at_with_workspace(&self, workspace: &Workspace, intent: Intent, start_time: DateTime, note: Option)` -- `stop_current_session_with_workspace(&self, workspace: &Workspace)` - -These methods handle fetching today/now/trackers internally. - ---- - -#### [ ] TimesheetManager Workspace Context Injection - -Similar pattern in `timesheet_manager.rs`: - -**Affected methods:** -- `compile()` (lines 114-137) - fetches log_manager -- `sign_timesheet()` (lines 201-224) - fetches identity_manager -- `find_stale_timesheets()` (lines 140-164) - fetches log_manager -- `submit()` (lines 226-241) - fetches plugin_manager -- `audiences()` (lines 81-95) - fetches plugin_manager -- `get_audience()` (lines 97-109) - fetches plugin_manager - -**Target:** Add workspace-aware methods to Rust `TimesheetManager`: -- `compile_with_workspace(&self, workspace: &Workspace, log: &Log, plugin: &dyn Plugin)` -- `sign_with_workspace(&self, workspace: &Workspace, timesheet: &Timesheet, signing_ids: &[String])` -- `find_stale_with_workspace(&self, workspace: &Workspace, date: Option)` -- `submit_with_workspace(&self, workspace: &Workspace, timesheet: &Timesheet)` - ---- - -### MEDIUM - -#### [ ] PlanManager Plugin Access (`plan_manager.rs:268-279`) - -`remotes()` method fetches plugin_manager from workspace. - -**Target:** Either: -1. Add `remotes_with_workspace()` to Rust `PlanManager`, or -2. Accept `plugin_manager` parameter directly - ---- - ---- - -## Implementation Notes - -### Workspace-Aware Pattern - -The recommended pattern for workspace-aware methods in Rust: - -```rust -impl LogManager { - /// Start intent using workspace context for date, time, and trackers - pub async fn start_intent_now_with_workspace( - &self, - workspace: &Workspace, - intent: Intent, - note: Option, - ) -> Result<()> { - let current_date = workspace.today(); - let current_time = workspace.now(); - let trackers = workspace.plans().get_trackers(current_date).await?; - - self.start_intent_now(intent, note, current_date, current_time, &trackers).await - } -} -``` - -This keeps the low-level methods available for testing and flexibility, while providing convenience methods that handle orchestration. - -### Testing Strategy - -1. Rust unit tests for new methods -2. Integration tests using mock workspace -3. Python binding tests remain thin (just verify delegation works) - ---- - -## Progress Tracking - -| Item | Status | PR/Commit | -|------|--------|-----------| -| Timeline validation | Done | | -| Duration aggregations | Done | | -| Session statistics | Not started | | -| Active session duration | Done | | -| Stale timesheet detection | Already in Rust | | -| Filter operators | Already in Rust | | -| Compilation validation | Not started | | -| LogManager workspace injection | Not started | | -| TimesheetManager workspace injection | Not started | | -| PlanManager plugin access | Not started | | diff --git a/Cargo.lock b/Cargo.lock index e32cdb2..b4fca10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,6 +62,48 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -91,6 +133,15 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -141,6 +192,38 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "cc" version = "1.2.28" @@ -488,7 +571,7 @@ dependencies = [ [[package]] name = "faff-core" -version = "0.1.15" +version = "0.1.17" dependencies = [ "anyhow", "async-trait", @@ -520,7 +603,7 @@ dependencies = [ [[package]] name = "faff-core-python" -version = "0.1.15" +version = "0.1.17" dependencies = [ "anyhow", "async-trait", @@ -534,9 +617,22 @@ dependencies = [ "toml", ] +[[package]] +name = "faff-core-uniffi" +version = "0.1.17" +dependencies = [ + "anyhow", + "chrono", + "chrono-tz", + "faff-core", + "thiserror 2.0.17", + "tokio", + "uniffi", +] + [[package]] name = "faff-core-wasm" -version = "0.1.15" +version = "0.1.17" dependencies = [ "anyhow", "async-trait", @@ -630,6 +726,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1492,6 +1597,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "hash32" version = "0.3.1" @@ -1711,6 +1827,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -1935,6 +2053,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1957,6 +2081,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "7.0.0" @@ -2113,6 +2247,12 @@ dependencies = [ "spki", ] +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -2336,6 +2476,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2404,18 +2550,43 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -2442,11 +2613,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2574,6 +2754,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "spki" version = "0.7.3" @@ -2649,6 +2835,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2837,6 +3032,137 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "uniffi" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c6dec3fc6645f71a16a3fa9ff57991028153bd194ca97f4b55e610c73ce66a" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ed0150801958d4825da56a41c71f000a457ac3a4613fa9647df78ac4b6b6881" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b78fd9271a4c2e85bd2c266c5a9ede1fac676eb39fd77f636c27eaf67426fd5f" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_core" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0ef62e69762fbb9386dcb6c87cd3dd05d525fa8a3a579a290892e60ddbda47e" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f51ebca0d9a4b2aa6c644d5ede45c56f73906b96403c08a1985e75ccb64a01" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9d12529f1223d014fd501e5f29ca0884d15d6ed5ddddd9f506e55350327dc3" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df6d413db2827c68588f8149d30d49b71d540d46539e435b23a7f7dbd4d4f86" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a806dddc8208f22efd7e95a5cdf88ed43d0f3271e8f63b47e757a8bbdb43b63a" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d1a7339539bf6f6fa3e9b534dece13f778bda2d54b1a6d4e40b4d6090ac26e7" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "unindent" version = "0.2.4" @@ -3035,6 +3361,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/Cargo.toml b/Cargo.toml index 35fb5a4..182f79e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,11 @@ members = [ "core", "bindings-python", "bindings-wasm", + "bindings-uniffi", ] [workspace.package] -version = "0.1.16" +version = "0.1.17" edition = "2021" authors = ["Thomas Lant "] license = "AGPL-3.0" @@ -45,5 +46,8 @@ notify = "7.0" pyo3 = "0.26" pythonize = "0.26" +# Go (and other foreign) bindings via UniFFI +uniffi = "0.31.0" + # Dev dependencies tempfile = "3.14" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7543074..b3e4631 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -8,10 +8,10 @@ This guide covers local development workflows for faff-core. faff-core/ ├── core/ # Rust core library │ ├── src/ -│ │ ├── models/ # Data models (Intent, Session, Log, Plan, Timesheet) +│ │ ├── models/ # Data models (Session, Log, Plan, Timesheet, ...) │ │ ├── managers/ # Business logic managers │ │ ├── storage/ # File I/O abstraction -│ │ └── plugins.rs # Python plugin system +│ │ └── plugins/ # Python plugin system │ └── Cargo.toml ├── bindings-python/ # Python bindings (PyO3) │ ├── python/ @@ -22,13 +22,16 @@ faff-core/ │ ├── src/ │ │ └── python/ # PyO3 wrapper code │ └── Cargo.toml +├── bindings-wasm/ # WebAssembly bindings +│ └── Cargo.toml ├── Cargo.toml # Workspace root └── docs/ # Documentation ``` -This is a **Cargo workspace** with two crates: +This is a **Cargo workspace** with three crates: - `core`: Pure Rust library - `bindings-python`: Python bindings that depend on `core` +- `bindings-wasm`: WebAssembly bindings that depend on `core` ## Prerequisites @@ -54,7 +57,7 @@ You can now import and use it: ```python import faff_core -from faff_core.models import Intent, Session, Log, Plan +from faff_core.models import Session, Log, Plan ``` ### Alternative: From Workspace Root diff --git a/GPT_PRODUCTION_THOUGHTS.md b/GPT_PRODUCTION_THOUGHTS.md deleted file mode 100644 index c245cd0..0000000 --- a/GPT_PRODUCTION_THOUGHTS.md +++ /dev/null @@ -1,15 +0,0 @@ -Production Readiness - - - Strengths: the separation between storage, managers, and bindings makes the core portable (native CLI, WASM, Python), and the models deliberately track - hashes/signatures to detect tampering (core/src/models/log.rs:44, core/src/models/timesheet.rs:95). Integration tests plus the documented release train - reduce regression risk for library consumers (core/tests/integration_tests.rs:1, RELEASE_PROCESS.md:5). - - Gaps/Risks: private keys are written as base64 text without enforcing OS permissions or encryption, which is risky for production timekeeping that depends - on signed submissions (core/src/managers/identity_manager.rs:35, core/src/managers/identity_manager.rs:77). The plugin loader executes arbitrary Python - from the user’s .faff/plugins directory with no sandboxing or signature checks—great for extensibility but a supply-chain hazard in shared environments - (core/src/managers/plugin_manager.rs:66, core/src/managers/plugin_manager.rs:103). File-based storage lacks locking or transactional semantics, so - concurrent CLI/plugin writes could race or corrupt logs/timesheets; nothing in FileSystemStorage or the managers serializes access beyond “last writer - wins” (core/src/file_system_storage.rs:138, core/src/managers/log_manager.rs:147). Observability is minimal (almost no logging/metrics), and the - public README gives little operational guidance beyond “it’s a Rust rewrite,” so adopting teams would need to reverse engineer much of the behavior - (README.md:1). In short, the architecture is clean and modular for individual power users or small teams, but reaching “production” for a regulated or - multi-user deployment would require hardening the key management, plugin trust, storage concurrency, and documentation story. - diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 9004e22..a1ae156 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -29,12 +29,15 @@ pip install faff-core==0.1.0.dev5 ## Stable Releases (Manual) -When you're ready to release a stable version, create and push a git tag: +When you're ready to release a stable version, tag the version number that is currently in `Cargo.toml` — that is always the correct next release version: ```bash -# Release version 0.1.0 -git tag v0.1.0 -git push origin v0.1.0 +# Check the version +grep '^version' Cargo.toml + +# Tag and push +git tag v$(grep '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1/') +git push origin --tags ``` **What happens:** @@ -44,8 +47,9 @@ git push origin v0.1.0 4. Commits version update to `main` 5. Builds wheels for all platforms 6. Publishes to PyPI (no `.dev` suffix) -7. Creates GitHub Release with artifacts -8. Bumps `Cargo.toml` to next patch version `0.1.1` for future dev builds +7. Builds WASM package via `wasm-pack` and publishes to npm +8. Creates GitHub Release with artifacts +9. Bumps `Cargo.toml` to next patch version `0.1.1` for future dev builds **Installation:** ```bash @@ -60,6 +64,7 @@ pip install faff-core==0.1.0 The following GitHub secrets must be set: - `PYPI_API_TOKEN`: PyPI API token with permission to publish `faff-core` +- `NPM_TOKEN`: npm access token with permission to publish the WASM package ## Notes diff --git a/bindings-python/python/faff_core/faff_core.pyi b/bindings-python/python/faff_core/faff_core.pyi index d231eae..8791c16 100644 --- a/bindings-python/python/faff_core/faff_core.pyi +++ b/bindings-python/python/faff_core/faff_core.pyi @@ -17,47 +17,18 @@ def version() -> str: class models: """Core data models for time tracking.""" - class Intent: + class Session: """ - Intent represents what you're doing, classified semantically. + A work session with start/end times and semantic classification. - Most fields are optional except trackers which defaults to empty list. - If alias is not provided, it's auto-generated. - If intent_id is not provided, it's auto-generated with the current date. + Sessions are immutable - operations return new instances. """ - intent_id: str - alias: Optional[str] + title: Optional[str] role: Optional[str] - objective: Optional[str] - action: Optional[str] + impact: Optional[str] + mode: Optional[str] subject: Optional[str] trackers: List[str] - - def __init__( - self, - alias: Optional[str] = None, - role: Optional[str] = None, - objective: Optional[str] = None, - action: Optional[str] = None, - subject: Optional[str] = None, - trackers: List[str] = [], - intent_id: Optional[str] = None - ) -> None: ... - - def as_dict(self) -> Dict: ... - def __hash__(self) -> int: ... - def __eq__(self, other: object) -> bool: ... - def __ne__(self, other: object) -> bool: ... - def __repr__(self) -> str: ... - def __str__(self) -> str: ... - - class Session: - """ - A work session with start/end times and intent classification. - - Sessions are immutable - operations return new instances. - """ - intent: models.Intent start: datetime.datetime end: Optional[datetime.datetime] note: Optional[str] @@ -66,8 +37,13 @@ class models: def __init__( self, - intent: models.Intent, start: datetime.datetime, + title: Optional[str] = None, + role: Optional[str] = None, + impact: Optional[str] = None, + mode: Optional[str] = None, + subject: Optional[str] = None, + trackers: List[str] = [], end: Optional[datetime.datetime] = None, note: Optional[str] = None ) -> None: ... @@ -200,11 +176,10 @@ class models: valid_from: datetime.date valid_until: Optional[datetime.date] roles: List[str] - actions: List[str] - objectives: List[str] + modes: List[str] + impacts: List[str] subjects: List[str] trackers: Dict[str, str] - intents: List[models.Intent] def __init__( self, @@ -212,11 +187,10 @@ class models: valid_from: datetime.date, valid_until: Optional[datetime.date] = None, roles: Optional[List[str]] = None, - actions: Optional[List[str]] = None, - objectives: Optional[List[str]] = None, + modes: Optional[List[str]] = None, + impacts: Optional[List[str]] = None, subjects: Optional[List[str]] = None, - trackers: Optional[Dict[str, str]] = None, - intents: Optional[List[models.Intent]] = None + trackers: Optional[Dict[str, str]] = None ) -> None: ... @classmethod @@ -228,15 +202,6 @@ class models: """Generate a slug ID from the source.""" ... - def add_intent(self, intent: models.Intent) -> models.Plan: - """ - Add an intent to the plan (deduplicating if already present). - - Returns: - New Plan instance with the intent added. - """ - ... - def as_dict(self) -> Dict: """Convert to dictionary representation.""" ... @@ -437,16 +402,31 @@ class LogManager: """Write a log to storage.""" ... - def start_intent(self, intent: models.Intent, start_time: Optional[datetime.datetime] = None, note: Optional[str] = None) -> None: - """ - Start a new session with the given intent. + def start_session( + self, + title: Optional[str] = None, + role: Optional[str] = None, + impact: Optional[str] = None, + mode: Optional[str] = None, + subject: Optional[str] = None, + trackers: List[str] = [], + start_time: Optional[datetime.datetime] = None, + note: Optional[str] = None + ) -> None: + """ + Start a new session. If there's an active session, it will be stopped at the start time. Validates that start_time is not in the future and doesn't conflict with existing sessions. Args: - intent: The intent to start + title: Optional session title + role: Optional role + impact: Optional impact + mode: Optional mode + subject: Optional subject + trackers: List of tracker IDs start_time: When to start the session (defaults to now) note: Optional note for the session @@ -474,10 +454,6 @@ class PlanManager: """ ... - def get_intents(self, date: datetime.date) -> List[models.Intent]: - """Get all intents from plans valid for a given date.""" - ... - def get_roles(self, date: datetime.date) -> List[str]: """ Get all roles from plans valid for a given date. @@ -486,12 +462,12 @@ class PlanManager: """ ... - def get_objectives(self, date: datetime.date) -> List[str]: - """Get all objectives from plans valid for a given date.""" + def get_impacts(self, date: datetime.date) -> List[str]: + """Get all impacts from plans valid for a given date.""" ... - def get_actions(self, date: datetime.date) -> List[str]: - """Get all actions from plans valid for a given date.""" + def get_modes(self, date: datetime.date) -> List[str]: + """Get all modes from plans valid for a given date.""" ... def get_subjects(self, date: datetime.date) -> List[str]: @@ -508,6 +484,42 @@ class PlanManager: """ ... + def get_session_hints(self, date: datetime.date) -> List[Dict]: + """ + Get session hints from plans valid for a given date. + + Hints are generated by plugins (e.g. one per POC/Support tracker) and + drive title suggestions and field pre-weighting at session start. + + Returns: + List of dicts with keys: + title (str): Hint title (e.g. tracker name) + role (Optional[str]): Suggested role (source-prefixed) + subject (Optional[str]): Suggested subject (source-prefixed) + impact (Optional[str]): Suggested impact (source-prefixed) + mode (Optional[str]): Suggested mode (source-prefixed) + trackers (list[str]): Associated tracker IDs + """ + ... + + def get_tracker_mappings(self, date: datetime.date) -> List[Dict]: + """ + Get tracker mappings derived from vocabulary mapping rules for a given date. + + Each entry maps specific session field values back to a tracker, enabling + auto-derivation of trackers from session fields at start time. + + Returns: + List of dicts with keys: + tracker_id (str): Prefixed tracker ID (e.g., "element:1231232") + tracker_name (str): Human-readable tracker name + role (Optional[str]): Required role value, or None if unconstrained + subject (Optional[str]): Required subject value, or None if unconstrained + impact (Optional[str]): Required impact value, or None if unconstrained + mode (Optional[str]): Required mode value, or None if unconstrained + """ + ... + def get_plan_by_tracker_id( self, tracker_id: str, diff --git a/bindings-python/python/faff_core/plugins.py b/bindings-python/python/faff_core/plugins.py index 2f195ca..a65edc4 100644 --- a/bindings-python/python/faff_core/plugins.py +++ b/bindings-python/python/faff_core/plugins.py @@ -64,7 +64,7 @@ def pull_plan(self, date: datetime.date) -> Plan: date: The date for which to fetch the plan Returns: - A Plan object containing roles, objectives, actions, subjects, and trackers + A Plan object containing roles, impacts, modes, subjects, and trackers """ pass diff --git a/bindings-python/src/python/managers/identity_manager.rs b/bindings-python/src/python/managers/identity_manager.rs index 489db97..5951841 100644 --- a/bindings-python/src/python/managers/identity_manager.rs +++ b/bindings-python/src/python/managers/identity_manager.rs @@ -1,3 +1,4 @@ +use crate::python::runtime::runtime; use crate::python::storage::PyStorage; use faff_core::managers::IdentityManager as RustIdentityManager; use pyo3::prelude::*; @@ -38,8 +39,7 @@ impl PyIdentityManager { name: &str, overwrite: bool, ) -> PyResult>> { - let signing_key = tokio::runtime::Runtime::new() - .unwrap() + let signing_key = runtime() .block_on(self.manager.create_identity(name, overwrite)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -68,8 +68,7 @@ impl PyIdentityManager { py: Python<'py>, name: &str, ) -> PyResult>> { - let signing_key = tokio::runtime::Runtime::new() - .unwrap() + let signing_key = runtime() .block_on(self.manager.get_identity(name)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -84,8 +83,7 @@ impl PyIdentityManager { &self, py: Python<'py>, ) -> PyResult>> { - let identities = tokio::runtime::Runtime::new() - .unwrap() + let identities = runtime() .block_on(self.manager.list_identities()) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -113,8 +111,7 @@ impl PyIdentityManager { /// Args: /// name: Identity name pub fn delete_identity(&self, name: &str) -> PyResult<()> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.delete_identity(name)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } diff --git a/bindings-python/src/python/managers/log_manager.rs b/bindings-python/src/python/managers/log_manager.rs index a7b1e12..7e2da9c 100644 --- a/bindings-python/src/python/managers/log_manager.rs +++ b/bindings-python/src/python/managers/log_manager.rs @@ -4,6 +4,7 @@ use pyo3::prelude::*; use pyo3::types::{PyDate, PyDateTime}; use std::sync::Arc; +use crate::python::runtime::runtime; use faff_core::managers::LogManager as RustLogManager; use faff_core::utils::type_mapping::{date_py_to_rust, date_rust_to_py, datetime_py_to_rust}; use faff_core::workspace::Workspace as RustWorkspace; @@ -31,32 +32,6 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { #[pymethods] impl PyLogManager { - // NOTE: Standalone construction is no longer supported. LogManager must be - // created through Workspace using the from_rust() method. The LogManager - // requires a workspace reference to function properly (for operations like - // start_intent and stop_current_session that need workspace context). - // - // #[new] - // fn py_new(storage: Py, timezone: &Bound<'_, PyAny>) -> PyResult { - // // Convert timezone - // let tz_str: String = timezone.call_method0("__str__")?.extract()?; - // let tz: Tz = tz_str - // .parse() - // .map_err(|e| PyValueError::new_err(format!("Invalid timezone: {e}")))?; - // - // // Wrap the Python storage object - // let py_storage = PyStorage::new(storage); - // let storage: Arc = Arc::new(py_storage); - // - // // Create a PlanManager for the LogManager to use - // let plan_manager = Arc::new(PlanManager::new(storage.clone())); - // - // Ok(Self { - // inner: RustLogManager::new(storage, tz, plan_manager), - // workspace: None, // Standalone construction doesn't have workspace reference - // }) - // } - /// Check if a log exists for the given date fn log_exists(&self, date: Bound<'_, PyDate>) -> PyResult { let naive_date = date_py_to_rust(date)?; @@ -66,8 +41,7 @@ impl PyLogManager { /// Read raw log file contents fn read_log_raw(&self, date: Bound<'_, PyDate>) -> PyResult { let naive_date = date_py_to_rust(date)?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.inner.read_log_raw(naive_date)) .map_err(|e| PyFileNotFoundError::new_err(e.to_string())) } @@ -75,8 +49,7 @@ impl PyLogManager { /// Write raw log file contents fn write_log_raw(&self, date: Bound<'_, PyDate>, contents: &str) -> PyResult<()> { let naive_date = date_py_to_rust(date)?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.inner.write_log_raw(naive_date, contents)) .map_err(|e| PyValueError::new_err(e.to_string())) } @@ -93,8 +66,7 @@ impl PyLogManager { /// List all log dates fn list_log_dates<'py>(&self, py: Python<'py>) -> PyResult>> { - let dates = tokio::runtime::Runtime::new() - .unwrap() + let dates = runtime() .block_on(self.inner.list_logs()) .map_err(|e| PyValueError::new_err(e.to_string()))?; @@ -106,29 +78,54 @@ impl PyLogManager { /// List all logs (returns Log objects) fn list_logs(&self, _py: Python<'_>) -> PyResult> { - let dates = tokio::runtime::Runtime::new() - .unwrap() - .block_on(self.inner.list_logs()) - .map_err(|e| PyValueError::new_err(e.to_string()))?; - - let rt = tokio::runtime::Runtime::new().unwrap(); - let mut logs = Vec::new(); - for date in dates { - let log = rt - .block_on(self.inner.get_log(date)) + let inner = &self.inner; + runtime().block_on(async move { + let dates = inner + .list_logs() + .await .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut logs = Vec::with_capacity(dates.len()); + for date in dates { + let log = inner + .get_log(date) + .await + .map_err(|e| PyValueError::new_err(e.to_string()))?; + logs.push(faff_core::plugins::models::log::PyLog { inner: log }); + } + Ok(logs) + }) + } - logs.push(faff_core::plugins::models::log::PyLog { inner: log }); - } - - Ok(logs) + /// List the N most recent logs (returns Log objects, sorted oldest-first) + fn list_logs_recent( + &self, + _py: Python<'_>, + n: usize, + ) -> PyResult> { + let inner = &self.inner; + runtime().block_on(async move { + let mut dates = inner + .list_logs() + .await + .map_err(|e| PyValueError::new_err(e.to_string()))?; + dates.sort(); + let recent: Vec<_> = dates.into_iter().rev().take(n).collect(); + let mut logs = Vec::with_capacity(recent.len()); + for date in recent.into_iter().rev() { + let log = inner + .get_log(date) + .await + .map_err(|e| PyValueError::new_err(e.to_string()))?; + logs.push(faff_core::plugins::models::log::PyLog { inner: log }); + } + Ok(logs) + }) } /// Delete a log for a given date fn delete_log(&self, date: Bound<'_, PyDate>) -> PyResult<()> { let naive_date = date_py_to_rust(date)?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.inner.delete_log(naive_date)) .map_err(|e| PyFileNotFoundError::new_err(e.to_string())) } @@ -143,8 +140,7 @@ impl PyLogManager { /// Get a log for a given date (returns empty log if file doesn't exist) fn get_log(&self, date: Bound<'_, PyDate>) -> PyResult { let naive_date = date_py_to_rust(date)?; - let log = tokio::runtime::Runtime::new() - .unwrap() + let log = runtime() .block_on(self.inner.get_log(naive_date)) .map_err(|e| PyValueError::new_err(e.to_string()))?; @@ -157,32 +153,37 @@ impl PyLogManager { log: &faff_core::plugins::models::log::PyLog, trackers: std::collections::HashMap, ) -> PyResult<()> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.inner.write_log(&log.inner, &trackers)) .map_err(|e| PyValueError::new_err(e.to_string())) } - /// Start a new session with the given intent + /// Start a new session /// /// Args: - /// intent: The intent for the session + /// title: Optional session title + /// role: Optional role + /// impact: Optional impact + /// mode: Optional mode + /// subject: Optional subject + /// trackers: List of tracker IDs /// start_time: Optional start time (defaults to now) /// note: Optional note for the session /// /// If there's an active session, it will be stopped at the start time. /// Validates that start_time is not in the future and doesn't conflict /// with existing sessions. - /// - /// FIXME: This method gathers context (now, trackers) from workspace before - /// calling the Rust core. This orchestration logic should live in Rust, not - /// in the bindings. See BUSINESS_LOGIC_AUDIT.md for the proposed functional - /// core pattern that would eliminate this. - #[pyo3(signature = (intent, start_time=None, note=None))] - fn start_intent( + #[pyo3(signature = (title=None, role=None, impact=None, mode=None, subject=None, trackers=vec![], start_time=None, note=None))] + #[allow(clippy::too_many_arguments)] + fn start_session( &self, _py: Python<'_>, - intent: &faff_core::plugins::models::intent::PyIntent, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, start_time: Option>, note: Option, ) -> PyResult<()> { @@ -197,9 +198,11 @@ impl PyLogManager { None => workspace.now(), }; - let rt = tokio::runtime::Runtime::new().unwrap(); - - rt.block_on(self.inner.start_intent(intent.inner.clone(), start, note)) + runtime() + .block_on( + self.inner + .start_session(title, role, impact, mode, subject, trackers, start, note), + ) .map_err(|e| PyValueError::new_err(e.to_string())) } @@ -207,50 +210,8 @@ impl PyLogManager { /// /// Gets current date, time, and trackers from workspace internally. fn stop_current_session(&self, _py: Python<'_>) -> PyResult<()> { - let rt = tokio::runtime::Runtime::new().unwrap(); - - rt.block_on(self.inner.stop_current_session()) - .map_err(|e| PyValueError::new_err(e.to_string())) - } - - /// Find all logs that contain sessions using the given intent - /// - /// Returns: list of tuples (date, session_count) - fn find_logs_with_intent<'py>( - &self, - py: Python<'py>, - intent_id: &str, - ) -> PyResult, usize)>> { - let results = tokio::runtime::Runtime::new() - .unwrap() - .block_on(self.inner.find_logs_with_intent(intent_id)) - .map_err(|e| PyValueError::new_err(e.to_string()))?; - - results - .into_iter() - .map(|(date, count)| { - let py_date = date_rust_to_py(py, &date)?; - Ok((py_date, count)) - }) - .collect() - } - - /// Update an intent across all log files - /// - /// Returns: total number of sessions updated - fn update_intent_in_logs( - &self, - intent_id: &str, - updated_intent: &faff_core::plugins::models::intent::PyIntent, - trackers: std::collections::HashMap, - ) -> PyResult { - tokio::runtime::Runtime::new() - .unwrap() - .block_on(self.inner.update_intent_in_logs( - intent_id, - updated_intent.inner.clone(), - &trackers, - )) + runtime() + .block_on(self.inner.stop_current_session()) .map_err(|e| PyValueError::new_err(e.to_string())) } @@ -264,8 +225,7 @@ impl PyLogManager { new_value: &str, trackers: std::collections::HashMap, ) -> PyResult<(usize, usize)> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on( self.inner .replace_field_in_all_logs(field, old_value, new_value, &trackers), @@ -285,8 +245,7 @@ impl PyLogManager { ) -> PyResult<(Py, Py)> { use pyo3::types::{PyDate, PyDict, PyList}; - let (session_count, log_dates) = tokio::runtime::Runtime::new() - .unwrap() + let (session_count, log_dates) = runtime() .block_on(self.inner.get_field_usage_stats(field)) .map_err(|e| PyValueError::new_err(e.to_string()))?; diff --git a/bindings-python/src/python/managers/plan_manager.rs b/bindings-python/src/python/managers/plan_manager.rs index f42baf9..e5114f7 100644 --- a/bindings-python/src/python/managers/plan_manager.rs +++ b/bindings-python/src/python/managers/plan_manager.rs @@ -2,9 +2,9 @@ use pyo3::prelude::*; use pyo3::types::{PyDate, PyDict, PyList}; use std::sync::Arc; +use crate::python::runtime::runtime; use crate::python::storage::PyStorage; use faff_core::managers::plan_manager::PlanManager as RustPlanManager; -use faff_core::plugins::models::intent::PyIntent; use faff_core::plugins::models::plan::PyPlan; use faff_core::utils::type_mapping::date_py_to_rust; use faff_core::workspace::Workspace as RustWorkspace; @@ -52,8 +52,7 @@ impl PyPlanManager { /// Returns: dict[str, Plan] - mapping of source names to Plans pub fn get_plans(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let plans = tokio::runtime::Runtime::new() - .unwrap() + let plans = runtime() .block_on(self.manager.get_plans(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -66,32 +65,12 @@ impl PyPlanManager { Ok(dict.into()) } - /// Get all intents from plans valid for a given date - /// - /// Returns: list[Intent] - pub fn get_intents(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { - let naive_date = date_py_to_rust(date)?; - let intents = tokio::runtime::Runtime::new() - .unwrap() - .block_on(self.manager.get_intents(naive_date)) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - - let list = PyList::empty(py); - for intent in intents { - let py_intent = PyIntent { inner: intent }; - list.append(py_intent)?; - } - - Ok(list.into()) - } - /// Get all roles from plans valid for a given date /// /// Returns: list[str] pub fn get_roles(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let roles = tokio::runtime::Runtime::new() - .unwrap() + let roles = runtime() .block_on(self.manager.get_roles(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -103,37 +82,35 @@ impl PyPlanManager { Ok(list.into()) } - /// Get all objectives from plans valid for a given date + /// Get all impacts from plans valid for a given date /// /// Returns: list[str] - pub fn get_objectives(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { + pub fn get_impacts(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let objectives = tokio::runtime::Runtime::new() - .unwrap() - .block_on(self.manager.get_objectives(naive_date)) + let impacts = runtime() + .block_on(self.manager.get_impacts(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; let list = PyList::empty(py); - for objective in objectives { - list.append(objective)?; + for impact in impacts { + list.append(impact)?; } Ok(list.into()) } - /// Get all actions from plans valid for a given date + /// Get all modes from plans valid for a given date /// /// Returns: list[str] - pub fn get_actions(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { + pub fn get_modes(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let actions = tokio::runtime::Runtime::new() - .unwrap() - .block_on(self.manager.get_actions(naive_date)) + let modes = runtime() + .block_on(self.manager.get_modes(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; let list = PyList::empty(py); - for action in actions { - list.append(action)?; + for mode in modes { + list.append(mode)?; } Ok(list.into()) @@ -144,8 +121,7 @@ impl PyPlanManager { /// Returns: list[str] pub fn get_subjects(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let subjects = tokio::runtime::Runtime::new() - .unwrap() + let subjects = runtime() .block_on(self.manager.get_subjects(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -162,8 +138,7 @@ impl PyPlanManager { /// Returns: dict[str, str] - mapping of tracker IDs to names pub fn get_trackers(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let trackers = tokio::runtime::Runtime::new() - .unwrap() + let trackers = runtime() .block_on(self.manager.get_trackers(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -181,8 +156,7 @@ impl PyPlanManager { date: Bound<'_, PyDate>, ) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let plan = tokio::runtime::Runtime::new() - .unwrap() + let plan = runtime() .block_on(self.manager.get_plan_by_tracker_id(tracker_id, naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -194,8 +168,7 @@ impl PyPlanManager { /// Returns: Plan or None pub fn get_local_plan(&self, date: Bound<'_, PyDate>) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let plan = tokio::runtime::Runtime::new() - .unwrap() + let plan = runtime() .block_on(self.manager.get_local_plan(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -207,8 +180,7 @@ impl PyPlanManager { /// Returns: Plan pub fn get_local_plan_or_create(&self, date: Bound<'_, PyDate>) -> PyResult { let naive_date = date_py_to_rust(date)?; - let plan = tokio::runtime::Runtime::new() - .unwrap() + let plan = runtime() .block_on(self.manager.get_local_plan_or_create(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -217,51 +189,11 @@ impl PyPlanManager { /// Write a plan to storage pub fn write_plan(&self, plan: &PyPlan) -> PyResult<()> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.write_plan(&plan.inner)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } - /// Find an intent by ID across all plan files - /// - /// Returns: tuple (source, Intent, plan_file_path) or None if not found - pub fn find_intent_by_id(&self, py: Python, intent_id: &str) -> PyResult>> { - let result = tokio::runtime::Runtime::new() - .unwrap() - .block_on(self.manager.find_intent_by_id(intent_id)) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - - match result { - Some((source, intent, path)) => { - let py_intent = PyIntent { inner: intent }; - let path_str = path.to_string_lossy().to_string(); - let tuple = (source, py_intent, path_str).into_pyobject(py)?; - Ok(Some(tuple.unbind().into())) - } - None => Ok(None), - } - } - - /// Update an intent by ID across all plan files - /// - /// Returns: updated Plan or None if intent not found - pub fn update_intent_by_id( - &self, - intent_id: &str, - updated_intent: &PyIntent, - ) -> PyResult> { - let result = tokio::runtime::Runtime::new() - .unwrap() - .block_on( - self.manager - .update_intent_by_id(intent_id, updated_intent.inner.clone()), - ) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - - Ok(result.map(|inner| PyPlan { inner })) - } - /// Get plan remote plugin instances /// /// This delegates to the Rust PlanManager's remotes() method. @@ -272,23 +204,21 @@ impl PyPlanManager { ) })?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.remotes(workspace.plugins())) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } /// Replace a field value across all plans /// - /// Returns tuple of (plans_updated, intents_updated) + /// Returns number of plans updated pub fn replace_field_in_all_plans( &self, field: &str, old_value: &str, new_value: &str, - ) -> PyResult<(usize, usize)> { - tokio::runtime::Runtime::new() - .unwrap() + ) -> PyResult { + runtime() .block_on( self.manager .replace_field_in_all_plans(field, old_value, new_value), @@ -296,12 +226,116 @@ impl PyPlanManager { .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } + /// Get session hints from plans valid for a given date + /// + /// Returns: list[dict] with keys: title, role, subject, impact, mode (Optional[str]), + /// trackers (list[str]) + pub fn get_session_hints(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { + let naive_date = date_py_to_rust(date)?; + let hints = runtime() + .block_on(self.manager.get_session_hints(naive_date)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + + let list = PyList::empty(py); + for hint in hints { + let dict = PyDict::new(py); + dict.set_item("title", &hint.title)?; + dict.set_item("role", hint.role.as_deref().into_pyobject(py)?)?; + dict.set_item("subject", hint.subject.as_deref().into_pyobject(py)?)?; + dict.set_item("impact", hint.impact.as_deref().into_pyobject(py)?)?; + dict.set_item("mode", hint.mode.as_deref().into_pyobject(py)?)?; + dict.set_item("trackers", PyList::new(py, &hint.trackers)?)?; + list.append(dict)?; + } + Ok(list.into()) + } + + /// Get tracker mappings for plans valid for a given date + /// + /// Returns: list[dict] with keys: tracker_id, tracker_name, hint_title, role, subject, impact, mode + /// Fields role/subject/impact/mode are None when not constrained by the mapping. + pub fn get_tracker_mappings(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { + let naive_date = date_py_to_rust(date)?; + let mappings = runtime() + .block_on(self.manager.get_tracker_mappings(naive_date)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + + let list = PyList::empty(py); + for mapping in mappings { + let dict = PyDict::new(py); + dict.set_item("tracker_id", &mapping.tracker_id)?; + dict.set_item("tracker_name", &mapping.tracker_name)?; + dict.set_item("hint_title", &mapping.hint_title)?; + dict.set_item("role", mapping.role.as_deref().into_pyobject(py)?)?; + dict.set_item("subject", mapping.subject.as_deref().into_pyobject(py)?)?; + dict.set_item("impact", mapping.impact.as_deref().into_pyobject(py)?)?; + dict.set_item("mode", mapping.mode.as_deref().into_pyobject(py)?)?; + list.append(dict)?; + } + + Ok(list.into()) + } + + /// Get all plan-derived data needed at session start time in a single call. + /// + /// Returns a dict with keys: roles, impacts, modes, subjects, trackers, + /// hints (list[dict]), tracker_mappings (list[dict]) + pub fn get_start_data(&self, py: Python, date: Bound<'_, PyDate>) -> PyResult> { + let naive_date = date_py_to_rust(date)?; + let data = runtime() + .block_on(self.manager.get_start_data(naive_date)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + + let dict = PyDict::new(py); + + // Simple lists + dict.set_item("roles", data.roles)?; + dict.set_item("impacts", data.impacts)?; + dict.set_item("modes", data.modes)?; + dict.set_item("subjects", data.subjects)?; + + // Trackers dict + let bound = pythonize::pythonize(py, &data.trackers) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + dict.set_item("trackers", bound)?; + + // Hints + let hints_list = PyList::empty(py); + for hint in data.hints { + let h = PyDict::new(py); + h.set_item("title", &hint.title)?; + h.set_item("role", hint.role.as_deref().into_pyobject(py)?)?; + h.set_item("subject", hint.subject.as_deref().into_pyobject(py)?)?; + h.set_item("impact", hint.impact.as_deref().into_pyobject(py)?)?; + h.set_item("mode", hint.mode.as_deref().into_pyobject(py)?)?; + h.set_item("trackers", PyList::new(py, &hint.trackers)?)?; + hints_list.append(h)?; + } + dict.set_item("hints", hints_list)?; + + // Tracker mappings + let mappings_list = PyList::empty(py); + for mapping in data.tracker_mappings { + let m = PyDict::new(py); + m.set_item("tracker_id", &mapping.tracker_id)?; + m.set_item("tracker_name", &mapping.tracker_name)?; + m.set_item("hint_title", &mapping.hint_title)?; + m.set_item("role", mapping.role.as_deref().into_pyobject(py)?)?; + m.set_item("subject", mapping.subject.as_deref().into_pyobject(py)?)?; + m.set_item("impact", mapping.impact.as_deref().into_pyobject(py)?)?; + m.set_item("mode", mapping.mode.as_deref().into_pyobject(py)?)?; + mappings_list.append(m)?; + } + dict.set_item("tracker_mappings", mappings_list)?; + + Ok(dict.into()) + } + /// Get usage statistics for a field across all plans /// - /// Returns dict of field value -> intent count + /// Returns dict of field value -> count pub fn get_field_usage_stats(&self, field: &str, py: Python<'_>) -> PyResult> { - let stats = tokio::runtime::Runtime::new() - .unwrap() + let stats = runtime() .block_on(self.manager.get_field_usage_stats(field)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; diff --git a/bindings-python/src/python/managers/plugin_manager.rs b/bindings-python/src/python/managers/plugin_manager.rs index 569c512..0a65bbb 100644 --- a/bindings-python/src/python/managers/plugin_manager.rs +++ b/bindings-python/src/python/managers/plugin_manager.rs @@ -1,3 +1,4 @@ +use crate::python::runtime::runtime; use crate::python::storage::PyStorage; use faff_core::managers::PluginManager as RustPluginManager; use pyo3::prelude::*; @@ -38,8 +39,7 @@ impl PyPluginManager { .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; // Ensure plugins are loaded - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(manager.load_plugins()) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -87,8 +87,7 @@ impl PyPluginManager { pyo3::exceptions::PyValueError::new_err(format!("Invalid defaults: {e}")) })?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(manager.instantiate_plugin( plugin_name, instance_name, @@ -111,8 +110,7 @@ impl PyPluginManager { .lock() .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - let configs = tokio::runtime::Runtime::new() - .unwrap() + let configs = runtime() .block_on(manager.get_remote_configs()) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -137,8 +135,7 @@ impl PyPluginManager { .lock() .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(manager.plan_remotes()) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } @@ -153,8 +150,7 @@ impl PyPluginManager { .lock() .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(manager.audiences()) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } @@ -176,8 +172,7 @@ impl PyPluginManager { .lock() .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(manager.get_audience_by_id(audience_id)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } diff --git a/bindings-python/src/python/managers/timesheet_manager.rs b/bindings-python/src/python/managers/timesheet_manager.rs index 89f7140..2490e85 100644 --- a/bindings-python/src/python/managers/timesheet_manager.rs +++ b/bindings-python/src/python/managers/timesheet_manager.rs @@ -1,3 +1,4 @@ +use crate::python::runtime::runtime; use faff_core::managers::TimesheetManager as RustTimesheetManager; use faff_core::plugins::models::timesheet::PyTimesheet; use faff_core::utils::type_mapping::date_py_to_rust; @@ -32,8 +33,7 @@ impl PyTimesheetManager { /// Write a timesheet to storage pub fn write_timesheet(&self, timesheet: &PyTimesheet) -> PyResult<()> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.write_timesheet(×heet.inner)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } @@ -45,8 +45,7 @@ impl PyTimesheetManager { date: Bound<'_, PyDate>, ) -> PyResult> { let naive_date = date_py_to_rust(date)?; - let timesheet = tokio::runtime::Runtime::new() - .unwrap() + let timesheet = runtime() .block_on(self.manager.get_timesheet(audience_id, naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -58,8 +57,7 @@ impl PyTimesheetManager { pub fn list_timesheets(&self, date: Option>) -> PyResult> { let naive_date = date.map(date_py_to_rust).transpose()?; - let timesheets = tokio::runtime::Runtime::new() - .unwrap() + let timesheets = runtime() .block_on(self.manager.list_timesheets(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -72,8 +70,7 @@ impl PyTimesheetManager { /// Delete a timesheet for a specific audience and date pub fn delete_timesheet(&self, audience_id: &str, date: Bound<'_, PyDate>) -> PyResult<()> { let naive_date = date_py_to_rust(date)?; - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.delete_timesheet(audience_id, naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } @@ -82,8 +79,7 @@ impl PyTimesheetManager { /// /// Gets workspace context internally. pub fn audiences(&self, _py: Python<'_>) -> PyResult>> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.audiences()) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } @@ -92,8 +88,7 @@ impl PyTimesheetManager { /// /// Gets workspace context internally. pub fn get_audience(&self, _py: Python<'_>, audience_id: &str) -> PyResult>> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.get_audience(audience_id)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } @@ -111,8 +106,7 @@ impl PyTimesheetManager { // Convert Bound to Py for the Rust API let plugin_py: Py = plugin.unbind(); - let timesheet = tokio::runtime::Runtime::new() - .unwrap() + let timesheet = runtime() .block_on(self.manager.compile(&log.inner, &plugin_py)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -130,8 +124,7 @@ impl PyTimesheetManager { ) -> PyResult> { let naive_date = date.map(date_py_to_rust).transpose()?; - let stale = tokio::runtime::Runtime::new() - .unwrap() + let stale = runtime() .block_on(self.manager.find_stale_timesheets(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -150,8 +143,7 @@ impl PyTimesheetManager { ) -> PyResult> { let naive_date = date.map(date_py_to_rust).transpose()?; - let failed = tokio::runtime::Runtime::new() - .unwrap() + let failed = runtime() .block_on(self.manager.find_failed_submissions(naive_date)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -184,8 +176,7 @@ impl PyTimesheetManager { timesheet: &PyTimesheet, signing_ids: Vec, ) -> PyResult { - let signed = tokio::runtime::Runtime::new() - .unwrap() + let signed = runtime() .block_on(self.manager.sign_timesheet(×heet.inner, &signing_ids)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; @@ -196,8 +187,7 @@ impl PyTimesheetManager { /// /// Gets workspace context internally. pub fn submit(&self, _py: Python<'_>, timesheet: &PyTimesheet) -> PyResult<()> { - tokio::runtime::Runtime::new() - .unwrap() + runtime() .block_on(self.manager.submit(×heet.inner)) .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } diff --git a/bindings-python/src/python/mod.rs b/bindings-python/src/python/mod.rs index bd1fff2..f06259e 100644 --- a/bindings-python/src/python/mod.rs +++ b/bindings-python/src/python/mod.rs @@ -5,13 +5,13 @@ pub mod events; pub mod exceptions; pub mod managers; pub mod query; +pub mod runtime; pub mod storage; pub mod workspace; pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { let models_mod = PyModule::new(m.py(), "models")?; faff_core::plugins::models::config::register(&models_mod)?; - faff_core::plugins::models::intent::register(&models_mod)?; faff_core::plugins::models::session::register(&models_mod)?; faff_core::plugins::models::log::register(&models_mod)?; faff_core::plugins::models::plan::register(&models_mod)?; diff --git a/bindings-python/src/python/query.rs b/bindings-python/src/python/query.rs index 84742e7..e9cc61b 100644 --- a/bindings-python/src/python/query.rs +++ b/bindings-python/src/python/query.rs @@ -17,7 +17,7 @@ impl PyFilter { /// /// Format: key=value, key~value, or key!=value /// - /// Supported keys: alias, role, objective, action, subject, note + /// Supported keys: title, role, impact, mode, subject, note /// Operators: /// - = : equals /// - ~ : contains (case-insensitive) @@ -25,7 +25,7 @@ impl PyFilter { /// /// Examples: /// - "role=engineer" - /// - "objective~planning" + /// - "impact~planning" /// - "note!=standup" #[staticmethod] fn parse(filter_str: &str) -> PyResult { @@ -38,10 +38,10 @@ impl PyFilter { /// Get the field name fn field(&self) -> String { match self.inner.field { - FilterField::Alias => "alias".to_string(), + FilterField::Title => "title".to_string(), FilterField::Role => "role".to_string(), - FilterField::Objective => "objective".to_string(), - FilterField::Action => "action".to_string(), + FilterField::Impact => "impact".to_string(), + FilterField::Mode => "mode".to_string(), FilterField::Subject => "subject".to_string(), FilterField::Note => "note".to_string(), } diff --git a/bindings-python/src/python/runtime.rs b/bindings-python/src/python/runtime.rs new file mode 100644 index 0000000..0b1e5e2 --- /dev/null +++ b/bindings-python/src/python/runtime.rs @@ -0,0 +1,12 @@ +use std::sync::OnceLock; +use tokio::runtime::Runtime; + +static RUNTIME: OnceLock = OnceLock::new(); + +/// Returns the shared Tokio runtime for all Python binding methods. +/// +/// Creating a new Runtime for every method call is expensive (thread spawn etc.). +/// This global instance is initialised once and reused across all manager calls. +pub fn runtime() -> &'static Runtime { + RUNTIME.get_or_init(|| Runtime::new().expect("Failed to create Tokio runtime")) +} diff --git a/bindings-python/tests/test_python_bindings.py b/bindings-python/tests/test_python_bindings.py index e80fe23..f958784 100644 --- a/bindings-python/tests/test_python_bindings.py +++ b/bindings-python/tests/test_python_bindings.py @@ -12,8 +12,8 @@ import tempfile import faff_core -from faff_core import Workspace -from faff_core.models import Log, Session, Intent, Plan, Timesheet +from faff_core import Workspace, UninitializedLedgerError +from faff_core.models import Log, Session, Plan, Timesheet class TestDateTimeConversions: @@ -46,28 +46,27 @@ def test_session_datetime_roundtrip(self): start_dt = datetime.datetime(2025, 3, 15, 9, 0, 0, tzinfo=tz) end_dt = datetime.datetime(2025, 3, 15, 10, 30, 0, tzinfo=tz) - intent = Intent( - alias="work", + session = Session( + start=start_dt, + end=end_dt, + title="work", role="engineer", - objective="development", - action="coding", + impact="development", + mode="coding", subject="tests", - trackers=[] + trackers=[], ) - session = Session(intent, start_dt, end_dt, None) - assert session.start == start_dt assert session.end == end_dt - assert session.intent.alias == "work" + assert session.title == "work" def test_session_with_microseconds(self): """Test that microseconds are preserved in datetime conversion""" tz = ZoneInfo("UTC") start_dt = datetime.datetime(2025, 3, 15, 9, 0, 0, 123456, tzinfo=tz) - intent = Intent(alias="test", role=None, objective=None, action=None, subject=None, trackers=[]) - session = Session(intent, start_dt, None, None) + session = Session(start=start_dt, title="test") assert session.start.microsecond == 123456 @@ -75,10 +74,8 @@ def test_naive_datetime_raises_error(self): """Test that naive (timezone-unaware) datetime raises an error""" naive_dt = datetime.datetime(2025, 3, 15, 9, 0, 0) # No tzinfo - intent = Intent(alias="test", role=None, objective=None, action=None, subject=None, trackers=[]) - with pytest.raises(ValueError, match="timezone"): - Session(intent, naive_dt, None, None) + Session(naive_dt, title="test") class TestExceptionMapping: @@ -139,13 +136,12 @@ def test_log_with_open_session(self): tz = ZoneInfo("UTC") start = datetime.datetime(2025, 3, 15, 9, 0, 0, tzinfo=tz) - intent = Intent(alias="work", role=None, objective=None, action=None, subject=None, trackers=[]) - session = Session(intent, start, None, None) + session = Session(start, title="work") log = Log(date, tz, [session]) assert not log.is_closed() assert log.active_session() is not None - assert log.active_session().intent.alias == "work" + assert log.active_session().title == "work" def test_append_session(self): """Test appending a session to a log""" @@ -156,13 +152,12 @@ def test_append_session(self): start = datetime.datetime(2025, 3, 15, 9, 0, 0, tzinfo=tz) end = datetime.datetime(2025, 3, 15, 10, 0, 0, tzinfo=tz) - intent = Intent(alias="work", role=None, objective=None, action=None, subject=None, trackers=[]) - session = Session(intent, start, end, None) + session = Session(start, title="work", end=end) new_log = log.append_session(session) assert len(new_log.timeline) == 1 - assert new_log.timeline[0].intent.alias == "work" + assert new_log.timeline[0].title == "work" # Original log should be unchanged (immutability) assert len(log.timeline) == 0 @@ -172,8 +167,7 @@ def test_stop_active_session(self): tz = ZoneInfo("UTC") start = datetime.datetime(2025, 3, 15, 9, 0, 0, tzinfo=tz) - intent = Intent(alias="work", role=None, objective=None, action=None, subject=None, trackers=[]) - session = Session(intent, start, None, None) + session = Session(start, title="work") log = Log(date, tz, [session]) stop_time = datetime.datetime(2025, 3, 15, 10, 30, 0, tzinfo=tz) @@ -195,9 +189,8 @@ def test_total_recorded_time(self): start2 = datetime.datetime(2025, 3, 15, 14, 0, 0, tzinfo=tz) end2 = datetime.datetime(2025, 3, 15, 15, 30, 0, tzinfo=tz) - intent = Intent(alias="work", role=None, objective=None, action=None, subject=None, trackers=[]) - session1 = Session(intent, start1, end1, None) - session2 = Session(intent, start2, end2, None) + session1 = Session(start1, title="work", end=end1) + session2 = Session(start2, title="work", end=end2) log = Log(date, tz, [session1, session2]) @@ -207,42 +200,42 @@ def test_total_recorded_time(self): assert total == datetime.timedelta(hours=2, minutes=30) -class TestIntentModel: - """Test Intent model through Python bindings""" +class TestSessionModel: + """Test Session model through Python bindings""" + + def test_create_full_session(self): + """Test creating a session with all fields""" + tz = ZoneInfo("UTC") + start = datetime.datetime(2025, 3, 15, 9, 0, 0, tzinfo=tz) - def test_create_full_intent(self): - """Test creating an intent with all fields""" - intent = Intent( - alias="work", + session = Session( + start, + title="work", role="engineer", - objective="development", - action="coding", + impact="development", + mode="coding", subject="features", - trackers=["PROJ-123", "PROJ-456"] + trackers=["PROJ-123", "PROJ-456"], ) - assert intent.alias == "work" - assert intent.role == "engineer" - assert intent.objective == "development" - assert intent.action == "coding" - assert intent.subject == "features" - assert len(intent.trackers) == 2 - assert "PROJ-123" in intent.trackers - - def test_create_minimal_intent(self): - """Test creating an intent with minimal fields""" - intent = Intent(alias="minimal", role=None, objective=None, action=None, subject=None, trackers=[]) + assert session.title == "work" + assert session.role == "engineer" + assert session.impact == "development" + assert session.mode == "coding" + assert session.subject == "features" + assert len(session.trackers) == 2 + assert "PROJ-123" in session.trackers - assert intent.alias == "minimal" - assert intent.role is None - assert intent.trackers == [] + def test_create_minimal_session(self): + """Test creating a session with minimal fields""" + tz = ZoneInfo("UTC") + start = datetime.datetime(2025, 3, 15, 9, 0, 0, tzinfo=tz) - def test_intent_equality(self): - """Test that two intents with same values are equal""" - intent1 = Intent(alias="work", role="engineer", objective=None, action=None, subject=None, trackers=[]) - intent2 = Intent(alias="work", role="engineer", objective=None, action=None, subject=None, trackers=[]) + session = Session(start, title="minimal") - assert intent1 == intent2 + assert session.title == "minimal" + assert session.role is None + assert session.trackers == [] class TestPlanModel: @@ -257,11 +250,10 @@ def test_create_plan(self): valid_from=date, valid_until=None, roles=["engineer"], - objectives=["development"], - actions=["coding"], + impacts=["development"], + modes=["coding"], subjects=["features"], trackers={"PROJ-123": "Implement feature X"}, - intents=[] ) assert plan.source == "local" @@ -293,7 +285,7 @@ def test_workspace_default(self): # If we're in a directory with .faff, this should work assert ws is not None assert ws.timezone is not None - except RuntimeError: + except UninitializedLedgerError: # If we're not in a .faff directory, that's expected pass @@ -307,7 +299,7 @@ def test_workspace_managers_accessible(self): assert ws.identities is not None assert ws.timesheets is not None assert ws.plugins is not None - except RuntimeError: + except UninitializedLedgerError: # If we're not in a .faff directory, skip this test pytest.skip("Not in a .faff directory") diff --git a/bindings-uniffi/Cargo.toml b/bindings-uniffi/Cargo.toml new file mode 100644 index 0000000..5af7296 --- /dev/null +++ b/bindings-uniffi/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "faff-core-uniffi" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[lib] +# `name` controls the resulting library file name. uniffi-bindgen-go expects +# the cdylib (libfaff_core_uniffi.so) to be in LD_LIBRARY_PATH at runtime. +name = "faff_core_uniffi" +crate-type = ["cdylib", "staticlib"] + +[dependencies] +faff-core = { path = "../core" } +uniffi = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +chrono-tz = { workspace = true } + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } diff --git a/bindings-uniffi/build.rs b/bindings-uniffi/build.rs new file mode 100644 index 0000000..e8c1870 --- /dev/null +++ b/bindings-uniffi/build.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::generate_scaffolding("./src/faff_core.udl").expect("Failed to generate UniFFI scaffolding"); +} diff --git a/bindings-uniffi/src/faff_core.udl b/bindings-uniffi/src/faff_core.udl new file mode 100644 index 0000000..930cc53 --- /dev/null +++ b/bindings-uniffi/src/faff_core.udl @@ -0,0 +1,163 @@ +namespace faff_core {}; + +[Error] +enum FaffError { + "WorkspaceInit", + "InvalidDate", + "NoActiveSession", + "Other", +}; + +// A single tracked work session, mirroring faff_core::models::Session. +// Time fields are RFC3339 strings so we don't have to teach UniFFI about +// chrono::DateTime. `end` is None for an active (currently running) +// session. +dictionary Session { + string? title; + string? role; + string? impact; + string? mode; + string? subject; + sequence trackers; + string start; + string? end; + string? note; +}; + +// One day's log, mirroring faff_core::models::Log. `date` is YYYY-MM-DD, +// `timezone` is an IANA tz name. +dictionary Log { + string date; + string timezone; + sequence sessions; +}; + +// A tracker known to the workspace's plans for a given date. `id` is the +// source-prefixed identifier (e.g. "element:2744974"); `name` is the human +// label. +dictionary Tracker { + string id; + string name; +}; + +// A plugin-supplied hint that suggests semantic field values for sessions +// related to a particular tracker or activity. Mirrors +// faff_core::models::plan::SessionHint. +dictionary SessionHint { + string title; + string? role; + string? impact; + string? mode; + string? subject; + sequence trackers; +}; + +// A deduplicated past-session shape, used by the TUI as its "intent picker" +// source. Computed by walking recent logs and grouping sessions by their +// (title, role, impact, mode, subject, trackers) tuple. +dictionary SessionPrototype { + string? title; + string? role; + string? impact; + string? mode; + string? subject; + sequence trackers; + // Number of past sessions matching this prototype exactly. + u32 count; + // RFC3339 start time of the most recent matching session. + string last_used; +}; + +interface FaffWorkspace { + [Throws=FaffError] + constructor(); + + // Today's date in the workspace timezone, ISO-8601 (YYYY-MM-DD). + string today(); + + // IANA timezone name (e.g. "Europe/Vienna"). + string timezone(); + + // Read the log for a given date. Returns an empty log if no file exists. + // `date` must be YYYY-MM-DD. + [Throws=FaffError] + Log get_log(string date); + + // Start a new session right now in today's log. If a session is already + // active it will be stopped at "now" first. All semantic fields are + // optional — pass null to leave them empty. `trackers` may be empty. + [Throws=FaffError] + void start_session( + string? title, + string? role, + string? impact, + string? mode, + string? subject, + sequence trackers, + string? note + ); + + // Stop the currently active session in today's log at "now". Errors if + // no session is active. + [Throws=FaffError] + void stop_current_session(); + + // Replace the semantic fields of the currently active session in today's + // log. Passing null clears a field; trackers may be empty. The session's + // start, end, and any reflection are preserved. Errors if no session is + // currently active or if any tracker is unknown to today's plans. + [Throws=FaffError] + void update_active_session( + string? title, + string? role, + string? impact, + string? mode, + string? subject, + sequence trackers, + string? note + ); + + // Replace the semantic fields of a session identified by date + index. + // The session's start, end, and any reflection are preserved. `index` + // is 0-based into the day's timeline. Errors if the index is out of + // range or if any tracker is unknown to the date's plans. + [Throws=FaffError] + void update_session_at( + string date, + u32 index, + string? title, + string? role, + string? impact, + string? mode, + string? subject, + sequence trackers, + string? note + ); + + // Vocabulary queries — return all values valid in plans for the date. + // Each value is source-prefixed (e.g. "local:engineer", "element:42"). + [Throws=FaffError] + sequence get_roles(string date); + [Throws=FaffError] + sequence get_impacts(string date); + [Throws=FaffError] + sequence get_modes(string date); + [Throws=FaffError] + sequence get_subjects(string date); + + // Trackers known to the date's plans, sorted by human name. + [Throws=FaffError] + sequence get_trackers(string date); + + // Plugin-generated session hints for the date. + [Throws=FaffError] + sequence get_session_hints(string date); + + // Walk the most recent `days` days of logs (including today) and return + // a deduplicated list of session prototypes ranked by usage count, with + // ties broken by most-recently-used. The TUI uses these as the "intent + // picker" — the old plan-level Intent concept has been removed from the + // core, so the picker is built from history instead. + [Throws=FaffError] + sequence recent_prototypes(u32 days); +}; diff --git a/bindings-uniffi/src/lib.rs b/bindings-uniffi/src/lib.rs new file mode 100644 index 0000000..0ec3765 --- /dev/null +++ b/bindings-uniffi/src/lib.rs @@ -0,0 +1,376 @@ +//! UniFFI binding layer for faff-core. +//! +//! Mirrors the existing PyO3 bindings (../bindings-python) but exposes the +//! workspace through UniFFI so that uniffi-bindgen-go can generate native +//! Go bindings consumable by faff-tui without shelling out to faff-cli. +//! +//! Stage 3 surface: read the daily log, start/stop sessions, vocabulary +//! queries, and a `recent_prototypes` convenience that replaces the old +//! plan-level "intent" concept with history-derived session shapes. + +// `Arc` is referenced by the scaffolding generated from `faff_core.udl` +// (constructors are wrapped in `Arc` by UniFFI), so it must be in +// scope for the macro expansion even though no source line uses it. +#[allow(unused_imports)] +use std::sync::Arc; + +use std::collections::HashMap; + +use chrono::{DateTime, Duration, NaiveDate}; +use chrono_tz::Tz; +use faff_core::models::plan::SessionHint as RustSessionHint; +use faff_core::models::Session as RustSession; +use faff_core::workspace::Workspace as RustWorkspace; +use tokio::runtime::Runtime; + +#[derive(Debug, thiserror::Error)] +pub enum FaffError { + #[error("workspace init failed: {0}")] + WorkspaceInit(String), + #[error("invalid date '{0}': expected YYYY-MM-DD")] + InvalidDate(String), + #[error("no active session")] + NoActiveSession, + #[error("{0}")] + Other(String), +} + +/// Mirrors the UDL `Session` dictionary. Time fields are RFC3339 strings. +pub struct Session { + pub title: Option, + pub role: Option, + pub impact: Option, + pub mode: Option, + pub subject: Option, + pub trackers: Vec, + pub start: String, + pub end: Option, + pub note: Option, +} + +impl From<&RustSession> for Session { + fn from(s: &RustSession) -> Self { + Self { + title: s.title.clone(), + role: s.role.clone(), + impact: s.impact.clone(), + mode: s.mode.clone(), + subject: s.subject.clone(), + trackers: s.trackers.clone(), + start: s.start.to_rfc3339(), + end: s.end.as_ref().map(|d| d.to_rfc3339()), + note: s.note.clone(), + } + } +} + +/// Mirrors the UDL `Log` dictionary. +pub struct Log { + pub date: String, + pub timezone: String, + pub sessions: Vec, +} + +/// Mirrors the UDL `Tracker` dictionary. +pub struct Tracker { + pub id: String, + pub name: String, +} + +/// Mirrors the UDL `SessionHint` dictionary. +pub struct SessionHint { + pub title: String, + pub role: Option, + pub impact: Option, + pub mode: Option, + pub subject: Option, + pub trackers: Vec, +} + +impl From for SessionHint { + fn from(h: RustSessionHint) -> Self { + Self { + title: h.title, + role: h.role, + impact: h.impact, + mode: h.mode, + subject: h.subject, + trackers: h.trackers, + } + } +} + +/// Mirrors the UDL `SessionPrototype` dictionary. +pub struct SessionPrototype { + pub title: Option, + pub role: Option, + pub impact: Option, + pub mode: Option, + pub subject: Option, + pub trackers: Vec, + pub count: u32, + pub last_used: String, +} + +/// Hash key for grouping sessions into prototypes. `trackers` is sorted +/// before construction so ordering differences don't split groups. +#[derive(Hash, Eq, PartialEq, Clone)] +struct ProtoKey { + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, +} + +/// UniFFI-exported handle around `Arc`. +/// +/// We keep a private tokio runtime alive for the lifetime of the workspace +/// so that every async call doesn't pay the cost of spinning a new one +/// (which is what bindings-python does today). +pub struct FaffWorkspace { + inner: Arc, + rt: Runtime, +} + +impl FaffWorkspace { + pub fn new() -> Result { + let rt = Runtime::new().map_err(|e| FaffError::Other(format!("tokio runtime: {e}")))?; + let inner = rt + .block_on(RustWorkspace::new()) + .map_err(|e| FaffError::WorkspaceInit(e.to_string()))?; + Ok(Self { inner, rt }) + } + + pub fn today(&self) -> String { + self.inner.today().to_string() + } + + pub fn timezone(&self) -> String { + self.inner.timezone().name().to_string() + } + + pub fn get_log(&self, date: String) -> Result { + let parsed = parse_date(&date)?; + let log = self + .rt + .block_on(self.inner.logs().get_log(parsed)) + .map_err(|e| FaffError::Other(e.to_string()))?; + Ok(Log { + date: log.date.to_string(), + timezone: log.timezone.name().to_string(), + sessions: log.timeline.iter().map(Session::from).collect(), + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn start_session( + &self, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, + note: Option, + ) -> Result<(), FaffError> { + let now = self.inner.now(); + self.rt + .block_on(self.inner.logs().start_session( + title, role, impact, mode, subject, trackers, now, note, + )) + .map_err(|e| FaffError::Other(e.to_string())) + } + + pub fn stop_current_session(&self) -> Result<(), FaffError> { + self.rt + .block_on(self.inner.logs().stop_current_session()) + .map_err(|e| FaffError::Other(e.to_string())) + } + + /// Replace the semantic fields of today's currently active session. + /// Mirrors `LogManager::update_active_session`. + /// + /// `anyhow` erases types so we map the "no active session" string back + /// to the typed `NoActiveSession` variant on a best-effort basis. The + /// rest fall through to `Other`. + #[allow(clippy::too_many_arguments)] + pub fn update_active_session( + &self, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, + note: Option, + ) -> Result<(), FaffError> { + self.rt + .block_on(self.inner.logs().update_active_session( + title, role, impact, mode, subject, trackers, note, + )) + .map_err(|e| { + let msg = e.to_string(); + if msg.to_lowercase().contains("active session") { + FaffError::NoActiveSession + } else { + FaffError::Other(msg) + } + }) + } + + /// Replace the semantic fields of a session identified by date + index. + /// Mirrors `LogManager::update_session_at`. + #[allow(clippy::too_many_arguments)] + pub fn update_session_at( + &self, + date: String, + index: u32, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, + note: Option, + ) -> Result<(), FaffError> { + let parsed = parse_date(&date)?; + self.rt + .block_on(self.inner.logs().update_session_at( + parsed, + index as usize, + title, + role, + impact, + mode, + subject, + trackers, + note, + )) + .map_err(|e| FaffError::Other(e.to_string())) + } + + pub fn get_roles(&self, date: String) -> Result, FaffError> { + let parsed = parse_date(&date)?; + self.rt + .block_on(self.inner.plans().get_roles(parsed)) + .map_err(|e| FaffError::Other(e.to_string())) + } + + pub fn get_impacts(&self, date: String) -> Result, FaffError> { + let parsed = parse_date(&date)?; + self.rt + .block_on(self.inner.plans().get_impacts(parsed)) + .map_err(|e| FaffError::Other(e.to_string())) + } + + pub fn get_modes(&self, date: String) -> Result, FaffError> { + let parsed = parse_date(&date)?; + self.rt + .block_on(self.inner.plans().get_modes(parsed)) + .map_err(|e| FaffError::Other(e.to_string())) + } + + pub fn get_subjects(&self, date: String) -> Result, FaffError> { + let parsed = parse_date(&date)?; + self.rt + .block_on(self.inner.plans().get_subjects(parsed)) + .map_err(|e| FaffError::Other(e.to_string())) + } + + pub fn get_trackers(&self, date: String) -> Result, FaffError> { + let parsed = parse_date(&date)?; + let map = self + .rt + .block_on(self.inner.plans().get_trackers(parsed)) + .map_err(|e| FaffError::Other(e.to_string()))?; + let mut out: Vec = map + .into_iter() + .map(|(id, name)| Tracker { id, name }) + .collect(); + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) + } + + pub fn get_session_hints(&self, date: String) -> Result, FaffError> { + let parsed = parse_date(&date)?; + let hints = self + .rt + .block_on(self.inner.plans().get_session_hints(parsed)) + .map_err(|e| FaffError::Other(e.to_string()))?; + Ok(hints.into_iter().map(SessionHint::from).collect()) + } + + pub fn recent_prototypes(&self, days: u32) -> Result, FaffError> { + if days == 0 { + return Ok(Vec::new()); + } + let today = self.inner.today(); + let cutoff = today - Duration::days(i64::from(days) - 1); + + let log_dates = self + .rt + .block_on(self.inner.logs().list_logs()) + .map_err(|e| FaffError::Other(e.to_string()))?; + + // Accumulator: prototype key -> (count, most recent start time). + let mut acc: HashMap)> = HashMap::new(); + + for d in log_dates.iter().rev() { + if *d < cutoff || *d > today { + continue; + } + let log = match self.rt.block_on(self.inner.logs().get_log(*d)) { + Ok(l) => l, + Err(_) => continue, + }; + for s in &log.timeline { + let mut sorted_trackers = s.trackers.clone(); + sorted_trackers.sort(); + let key = ProtoKey { + title: s.title.clone(), + role: s.role.clone(), + impact: s.impact.clone(), + mode: s.mode.clone(), + subject: s.subject.clone(), + trackers: sorted_trackers, + }; + let entry = acc.entry(key).or_insert((0, s.start)); + entry.0 += 1; + if s.start > entry.1 { + entry.1 = s.start; + } + } + } + + let mut prototypes: Vec = acc + .into_iter() + .map(|(k, (count, last_used))| SessionPrototype { + title: k.title, + role: k.role, + impact: k.impact, + mode: k.mode, + subject: k.subject, + trackers: k.trackers, + count, + last_used: last_used.to_rfc3339(), + }) + .collect(); + + // Rank: most-used first, ties broken by most-recently-used. + prototypes.sort_by(|a, b| { + b.count + .cmp(&a.count) + .then_with(|| b.last_used.cmp(&a.last_used)) + }); + + Ok(prototypes) + } +} + +fn parse_date(s: &str) -> Result { + NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| FaffError::InvalidDate(s.to_string())) +} + +uniffi::include_scaffolding!("faff_core"); diff --git a/bindings-wasm/src/wasm/managers/log_manager.rs b/bindings-wasm/src/wasm/managers/log_manager.rs index 44c87ef..fce3bfd 100644 --- a/bindings-wasm/src/wasm/managers/log_manager.rs +++ b/bindings-wasm/src/wasm/managers/log_manager.rs @@ -222,20 +222,30 @@ impl LogManager { }) } - /// Start a new session with the given intent. + /// Start a new session. /// /// If there's an active session, it will be stopped at the start time. /// Validates that start_time is not in the future and doesn't conflict /// with existing sessions. /// - /// intent: Intent object + /// title: optional string title + /// role: optional string role + /// impact: optional string impact + /// mode: optional string mode + /// subject: optional string subject + /// trackers: optional array of tracker IDs /// startTime: optional JS Date object (defaults to now) /// note: optional string note /// Returns Promise. - #[wasm_bindgen(js_name = startIntent)] - pub fn start_intent( + #[wasm_bindgen(js_name = startSession)] + pub fn start_session( &self, - intent: &super::super::models::Intent, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Option>, start_time: Option, note: Option, ) -> js_sys::Promise { @@ -249,7 +259,6 @@ impl LogManager { }; let inner = self.inner.clone(); - let intent_inner = intent.inner.clone(); future_to_promise(async move { let start = match start_time { @@ -258,7 +267,16 @@ impl LogManager { }; inner - .start_intent(intent_inner, start, note) + .start_session( + title, + role, + impact, + mode, + subject, + trackers.unwrap_or_default(), + start, + note, + ) .await .map_err(|e| JsValue::from_str(&format!("Failed to start session: {}", e)))?; @@ -283,79 +301,9 @@ impl LogManager { }) } - /// Find all logs that contain sessions using the given intent. - /// - /// intent_id: string intent ID - /// Returns Promise>. - #[wasm_bindgen(js_name = findLogsWithIntent)] - pub fn find_logs_with_intent(&self, intent_id: &str) -> js_sys::Promise { - let inner = self.inner.clone(); - let intent_id = intent_id.to_string(); - - future_to_promise(async move { - let results = inner - .find_logs_with_intent(&intent_id) - .await - .map_err(|e| JsValue::from_str(&format!("Failed to find logs: {}", e)))?; - - let array = js_sys::Array::new(); - for (date, session_count) in results { - let obj = js_sys::Object::new(); - js_sys::Reflect::set( - &obj, - &JsValue::from_str("date"), - &naive_date_to_js_date(&date), - )?; - js_sys::Reflect::set( - &obj, - &JsValue::from_str("sessionCount"), - &JsValue::from_f64(session_count as f64), - )?; - array.push(&obj); - } - - Ok(JsValue::from(array)) - }) - } - - /// Update an intent across all log files. - /// - /// intent_id: string intent ID - /// updated_intent: Intent object with new values - /// trackers: object (map of tracker keys to field names) - /// Returns Promise - total number of sessions updated. - #[wasm_bindgen(js_name = updateIntentInLogs)] - pub fn update_intent_in_logs( - &self, - intent_id: &str, - updated_intent: &super::super::models::Intent, - trackers: JsValue, - ) -> js_sys::Promise { - let inner = self.inner.clone(); - let intent_id = intent_id.to_string(); - let updated_intent = updated_intent.inner.clone(); - - future_to_promise(async move { - // Convert JsValue to HashMap - let trackers_map: HashMap = if trackers.is_object() { - serde_wasm_bindgen::from_value(trackers) - .map_err(|e| JsValue::from_str(&format!("Invalid trackers object: {}", e)))? - } else { - HashMap::new() - }; - - let count = inner - .update_intent_in_logs(&intent_id, updated_intent, &trackers_map) - .await - .map_err(|e| JsValue::from_str(&format!("Failed to update intent: {}", e)))?; - - Ok(JsValue::from_f64(count as f64)) - }) - } - /// Replace a field value across all log sessions. /// - /// field: string field name (role, objective, action, subject) + /// field: string field name (role, impact, mode, subject) /// old_value: string old value to replace /// new_value: string new value /// trackers: object (map of tracker keys to field names) @@ -405,7 +353,7 @@ impl LogManager { /// Get usage statistics for a field across all logs. /// - /// field: string field name (role, objective, action, subject) + /// field: string field name (role, impact, mode, subject) /// Returns Promise<{sessionCount: Map, logDates: Map}>. #[wasm_bindgen(js_name = getFieldUsageStats)] pub fn get_field_usage_stats(&self, field: &str) -> js_sys::Promise { diff --git a/bindings-wasm/src/wasm/managers/plan_manager.rs b/bindings-wasm/src/wasm/managers/plan_manager.rs index 1db4a54..56440ba 100644 --- a/bindings-wasm/src/wasm/managers/plan_manager.rs +++ b/bindings-wasm/src/wasm/managers/plan_manager.rs @@ -1,4 +1,4 @@ -use super::super::models::{Intent, Plan}; +use super::super::models::Plan; use super::super::storage::JsStorageAdapter; use chrono::Datelike; use faff_core::managers::PlanManager as RustPlanManager; @@ -9,7 +9,7 @@ use wasm_bindgen_futures::future_to_promise; /// PlanManager handles reading and writing work plans and vocabularies. /// -/// Plans define the vocabulary (roles, actions, objectives, subjects) and +/// Plans define the vocabulary (roles, modes, impacts, subjects) and /// predefined intents that can be used in logs. #[wasm_bindgen] pub struct PlanManager { @@ -73,31 +73,6 @@ impl PlanManager { }) } - /// Get all intents from plans valid for a given date. - /// - /// date: JS Date object - /// Returns Promise. - #[wasm_bindgen(js_name = getIntents)] - pub fn get_intents(&self, date: js_sys::Date) -> js_sys::Promise { - let inner = self.inner.clone(); - - future_to_promise(async move { - let naive_date = js_date_to_naive_date(&date)?; - - let intents = inner - .get_intents(naive_date) - .await - .map_err(|e| JsValue::from_str(&format!("Failed to get intents: {}", e)))?; - - let array = js_sys::Array::new(); - for intent in intents { - array.push(&JsValue::from(Intent { inner: intent })); - } - - Ok(JsValue::from(array)) - }) - } - /// Get all roles from plans valid for a given date. /// /// date: JS Date object @@ -123,50 +98,50 @@ impl PlanManager { }) } - /// Get all objectives from plans valid for a given date. + /// Get all impacts from plans valid for a given date. /// /// date: JS Date object /// Returns Promise. - #[wasm_bindgen(js_name = getObjectives)] - pub fn get_objectives(&self, date: js_sys::Date) -> js_sys::Promise { + #[wasm_bindgen(js_name = getImpacts)] + pub fn get_impacts(&self, date: js_sys::Date) -> js_sys::Promise { let inner = self.inner.clone(); future_to_promise(async move { let naive_date = js_date_to_naive_date(&date)?; - let objectives = inner - .get_objectives(naive_date) + let impacts = inner + .get_impacts(naive_date) .await - .map_err(|e| JsValue::from_str(&format!("Failed to get objectives: {}", e)))?; + .map_err(|e| JsValue::from_str(&format!("Failed to get impacts: {}", e)))?; let array = js_sys::Array::new(); - for objective in objectives { - array.push(&JsValue::from_str(&objective)); + for impact in impacts { + array.push(&JsValue::from_str(&impact)); } Ok(JsValue::from(array)) }) } - /// Get all actions from plans valid for a given date. + /// Get all modes from plans valid for a given date. /// /// date: JS Date object /// Returns Promise. - #[wasm_bindgen(js_name = getActions)] - pub fn get_actions(&self, date: js_sys::Date) -> js_sys::Promise { + #[wasm_bindgen(js_name = getModes)] + pub fn get_modes(&self, date: js_sys::Date) -> js_sys::Promise { let inner = self.inner.clone(); future_to_promise(async move { let naive_date = js_date_to_naive_date(&date)?; - let actions = inner - .get_actions(naive_date) + let modes = inner + .get_modes(naive_date) .await - .map_err(|e| JsValue::from_str(&format!("Failed to get actions: {}", e)))?; + .map_err(|e| JsValue::from_str(&format!("Failed to get modes: {}", e)))?; let array = js_sys::Array::new(); - for action in actions { - array.push(&JsValue::from_str(&action)); + for mode in modes { + array.push(&JsValue::from_str(&mode)); } Ok(JsValue::from(array)) @@ -311,76 +286,12 @@ impl PlanManager { }) } - /// Find an intent by ID across all plan files. - /// - /// intent_id: string - /// Returns Promise<{source: string, intent: Intent, planFilePath: string} | null>. - #[wasm_bindgen(js_name = findIntentById)] - pub fn find_intent_by_id(&self, intent_id: &str) -> js_sys::Promise { - let inner = self.inner.clone(); - let intent_id = intent_id.to_string(); - - future_to_promise(async move { - let result = inner - .find_intent_by_id(&intent_id) - .await - .map_err(|e| JsValue::from_str(&format!("Failed to find intent: {}", e)))?; - - match result { - Some((source, intent, path)) => { - let obj = js_sys::Object::new(); - js_sys::Reflect::set( - &obj, - &JsValue::from_str("source"), - &JsValue::from_str(&source), - )?; - js_sys::Reflect::set( - &obj, - &JsValue::from_str("intent"), - &JsValue::from(Intent { inner: intent }), - )?; - js_sys::Reflect::set( - &obj, - &JsValue::from_str("planFilePath"), - &JsValue::from_str(&path.to_string_lossy()), - )?; - Ok(JsValue::from(obj)) - } - None => Ok(JsValue::null()), - } - }) - } - - /// Update an intent by ID across all plan files. - /// - /// intent_id: string - /// updated_intent: Intent object - /// Returns Promise - updated Plan or null if intent not found. - #[wasm_bindgen(js_name = updateIntentById)] - pub fn update_intent_by_id(&self, intent_id: &str, updated_intent: &Intent) -> js_sys::Promise { - let inner = self.inner.clone(); - let intent_id = intent_id.to_string(); - let updated_intent = updated_intent.inner.clone(); - - future_to_promise(async move { - let result = inner - .update_intent_by_id(&intent_id, updated_intent) - .await - .map_err(|e| JsValue::from_str(&format!("Failed to update intent: {}", e)))?; - - match result { - Some(inner) => Ok(JsValue::from(Plan { inner })), - None => Ok(JsValue::null()), - } - }) - } - /// Replace a field value across all plans. /// - /// field: string field name (role, objective, action, subject) + /// field: string field name (role, impact, mode, subject) /// old_value: string old value to replace /// new_value: string new value - /// Returns Promise<{plansUpdated: number, intentsUpdated: number}>. + /// Returns Promise - number of plans updated. #[wasm_bindgen(js_name = replaceFieldInAllPlans)] pub fn replace_field_in_all_plans( &self, @@ -394,30 +305,18 @@ impl PlanManager { let new_value = new_value.to_string(); future_to_promise(async move { - let (plans_updated, intents_updated) = inner + let plans_updated = inner .replace_field_in_all_plans(&field, &old_value, &new_value) .await .map_err(|e| JsValue::from_str(&format!("Failed to replace field: {}", e)))?; - let obj = js_sys::Object::new(); - js_sys::Reflect::set( - &obj, - &JsValue::from_str("plansUpdated"), - &JsValue::from_f64(plans_updated as f64), - )?; - js_sys::Reflect::set( - &obj, - &JsValue::from_str("intentsUpdated"), - &JsValue::from_f64(intents_updated as f64), - )?; - - Ok(JsValue::from(obj)) + Ok(JsValue::from_f64(plans_updated as f64)) }) } /// Get usage statistics for a field across all plans. /// - /// field: string field name (role, objective, action, subject) + /// field: string field name (role, impact, mode, subject) /// Returns Promise> - field value -> intent count. #[wasm_bindgen(js_name = getFieldUsageStats)] pub fn get_field_usage_stats(&self, field: &str) -> js_sys::Promise { diff --git a/bindings-wasm/src/wasm/models.rs b/bindings-wasm/src/wasm/models.rs index 776f34b..cbec625 100644 --- a/bindings-wasm/src/wasm/models.rs +++ b/bindings-wasm/src/wasm/models.rs @@ -1,51 +1,52 @@ use chrono::{DateTime, Datelike, NaiveDate}; use chrono_tz::Tz; use faff_core::models::{ - Intent as RustIntent, Log as RustLog, Plan as RustPlan, Session as RustSession, - Timesheet as RustTimesheet, + Log as RustLog, Plan as RustPlan, Session as RustSession, Timesheet as RustTimesheet, }; use wasm_bindgen::prelude::*; -/// Intent represents what you're doing, classified semantically. -/// -/// All fields are optional except trackers which defaults to empty array. +/// A work session with start/end times and semantic classification. #[wasm_bindgen] #[derive(Clone)] -pub struct Intent { - pub(crate) inner: RustIntent, +pub struct Session { + inner: RustSession, } #[wasm_bindgen] -impl Intent { +impl Session { #[wasm_bindgen(constructor)] pub fn new( - alias: Option, + title: Option, role: Option, - objective: Option, - action: Option, + impact: Option, + mode: Option, subject: Option, trackers: Option>, - ) -> Self { - Self { - inner: RustIntent::new( - alias, + start: js_sys::Date, + end: Option, + note: Option, + ) -> Result { + let start_dt = js_date_to_chrono(&start)?; + let end_dt = end.as_ref().map(js_date_to_chrono).transpose()?; + + Ok(Self { + inner: RustSession::new( + title, role, - objective, - action, + impact, + mode, subject, trackers.unwrap_or_default(), + start_dt, + end_dt, + note, ), - } - } - - #[wasm_bindgen(getter)] - pub fn intent_id(&self) -> String { - self.inner.intent_id.clone() + }) } #[wasm_bindgen(getter)] - pub fn alias(&self) -> Option { - self.inner.alias.clone() + pub fn title(&self) -> Option { + self.inner.title.clone() } #[wasm_bindgen(getter)] @@ -54,13 +55,13 @@ impl Intent { } #[wasm_bindgen(getter)] - pub fn objective(&self) -> Option { - self.inner.objective.clone() + pub fn impact(&self) -> Option { + self.inner.impact.clone() } #[wasm_bindgen(getter)] - pub fn action(&self) -> Option { - self.inner.action.clone() + pub fn mode(&self) -> Option { + self.inner.mode.clone() } #[wasm_bindgen(getter)] @@ -73,52 +74,6 @@ impl Intent { self.inner.trackers.clone() } - /// Convert to JSON object - #[wasm_bindgen(js_name = toJSON)] - pub fn to_json(&self) -> Result { - serde_wasm_bindgen::to_value(&self.inner).map_err(|e| JsValue::from_str(&e.to_string())) - } - - /// Create from JSON object - #[wasm_bindgen(js_name = fromJSON)] - pub fn from_json(value: &JsValue) -> Result { - let inner: RustIntent = serde_wasm_bindgen::from_value(value.clone()) - .map_err(|e| JsValue::from_str(&e.to_string()))?; - Ok(Self { inner }) - } -} - -/// A work session with start/end times and intent classification. -#[wasm_bindgen] -#[derive(Clone)] -pub struct Session { - inner: RustSession, -} - -#[wasm_bindgen] -impl Session { - #[wasm_bindgen(constructor)] - pub fn new( - intent: &Intent, - start: js_sys::Date, - end: Option, - note: Option, - ) -> Result { - let start_dt = js_date_to_chrono(&start)?; - let end_dt = end.as_ref().map(js_date_to_chrono).transpose()?; - - Ok(Self { - inner: RustSession::new(intent.inner.clone(), start_dt, end_dt, note), - }) - } - - #[wasm_bindgen(getter)] - pub fn intent(&self) -> Intent { - Intent { - inner: self.inner.intent.clone(), - } - } - #[wasm_bindgen(getter)] pub fn start(&self) -> js_sys::Date { chrono_to_js_date(&self.inner.start) @@ -235,7 +190,7 @@ impl Log { /// now: JS Date for calculating duration of open sessions /// Returns an object with: /// totalMinutes: number - /// byIntent: Record + /// byTitle: Record /// byTracker: Record /// byTrackerSource: Record /// meanReflectionScore: number | null @@ -248,8 +203,8 @@ impl Log { js_sys::Reflect::set(&obj, &"totalMinutes".into(), &summary.total_minutes.into())?; js_sys::Reflect::set( &obj, - &"byIntent".into(), - &serde_wasm_bindgen::to_value(&summary.by_intent) + &"byTitle".into(), + &serde_wasm_bindgen::to_value(&summary.by_title) .map_err(|e| JsValue::from_str(&e.to_string()))?, )?; js_sys::Reflect::set( @@ -315,13 +270,13 @@ impl Plan { } #[wasm_bindgen(getter)] - pub fn actions(&self) -> Vec { - self.inner.actions.clone() + pub fn modes(&self) -> Vec { + self.inner.modes.clone() } #[wasm_bindgen(getter)] - pub fn objectives(&self) -> Vec { - self.inner.objectives.clone() + pub fn impacts(&self) -> Vec { + self.inner.impacts.clone() } #[wasm_bindgen(getter)] @@ -329,15 +284,6 @@ impl Plan { self.inner.subjects.clone() } - #[wasm_bindgen(getter)] - pub fn intents(&self) -> Vec { - self.inner - .intents - .iter() - .map(|i| Intent { inner: i.clone() }) - .collect() - } - #[wasm_bindgen(js_name = toJSON)] pub fn to_json(&self) -> Result { serde_wasm_bindgen::to_value(&self.inner).map_err(|e| JsValue::from_str(&e.to_string())) diff --git a/bindings-wasm/tests/wasm.rs b/bindings-wasm/tests/wasm.rs index 90360f5..72d22fb 100644 --- a/bindings-wasm/tests/wasm.rs +++ b/bindings-wasm/tests/wasm.rs @@ -11,31 +11,6 @@ fn test_wasm_module_loads() { assert!(true); } -#[wasm_bindgen_test] -fn test_intent_creation() { - // Test creating an Intent through WASM bindings - let intent = Intent::new( - Some("test-intent".to_string()), - Some("work".to_string()), - Some("development".to_string()), - Some("coding".to_string()), - Some("rust".to_string()), - None, // trackers - ); - - assert_eq!(intent.alias(), Some("test-intent".to_string())); - assert_eq!(intent.role(), Some("work".to_string())); -} - -#[wasm_bindgen_test] -fn test_intent_minimal() { - // Test creating an Intent with minimal fields - let intent = Intent::new(Some("minimal".to_string()), None, None, None, None, None); - - assert_eq!(intent.alias(), Some("minimal".to_string())); - assert_eq!(intent.role(), None); -} - // TODO: Add integration tests with mock JsStorage once we have test infrastructure // These would test: // - Workspace creation with JsStorage diff --git a/core/src/managers/log_manager.rs b/core/src/managers/log_manager.rs index 9db8f69..d71509e 100644 --- a/core/src/managers/log_manager.rs +++ b/core/src/managers/log_manager.rs @@ -173,7 +173,12 @@ impl LogManager { .context("Failed to create start of day timestamp")?; let continuation_session = crate::models::Session::new( - unclosed_session.intent.clone(), + unclosed_session.title.clone(), + unclosed_session.role.clone(), + unclosed_session.impact.clone(), + unclosed_session.mode.clone(), + unclosed_session.subject.clone(), + unclosed_session.trackers.clone(), start_of_day, None, // Unclosed unclosed_session.note.clone(), @@ -243,7 +248,7 @@ impl LogManager { .with_context(|| format!("Failed to delete log for {date}")) } - /// Start a new session with the given intent at a specific time + /// Start a new session at a specific time /// /// Validates that: /// - start_time is not in the future (relative to `now`) @@ -251,9 +256,14 @@ impl LogManager { /// /// If there's an active session, it will be stopped at `start_time` before /// starting the new session. - pub async fn start_intent( + pub async fn start_session( &self, - intent: crate::models::Intent, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, start_time: chrono::DateTime, note: Option, ) -> Result<()> { @@ -265,7 +275,7 @@ impl LogManager { let current_date = start_time.date_naive(); let now = ws.now(); - let trackers = ws.plans().get_trackers(current_date).await?; + let plan_trackers = ws.plans().get_trackers(current_date).await?; // Get today's log (returns empty log if file doesn't exist) let mut log = self.get_log(current_date).await?; @@ -305,12 +315,12 @@ impl LogManager { } // Validate trackers if any are specified - if !intent.trackers.is_empty() { - let tracker_ids: std::collections::HashSet<_> = trackers.keys().collect(); - let intent_tracker_set: std::collections::HashSet<_> = intent.trackers.iter().collect(); + if !trackers.is_empty() { + let tracker_ids: std::collections::HashSet<_> = plan_trackers.keys().collect(); + let session_tracker_set: std::collections::HashSet<_> = trackers.iter().collect(); - if !intent_tracker_set.is_subset(&tracker_ids) { - let missing: Vec<_> = intent_tracker_set + if !session_tracker_set.is_subset(&tracker_ids) { + let missing: Vec<_> = session_tracker_set .difference(&tracker_ids) .map(|s| s.as_str()) .collect(); @@ -319,11 +329,13 @@ impl LogManager { } // Create new session - let session = crate::models::Session::new(intent, start_time, None, note); + let session = crate::models::Session::new( + title, role, impact, mode, subject, trackers, start_time, None, note, + ); // Append to log and write let updated_log = log.append_session(session)?; - self.write_log(&updated_log, &trackers).await?; + self.write_log(&updated_log, &plan_trackers).await?; Ok(()) } @@ -353,62 +365,120 @@ impl LogManager { } } - /// Find all logs that contain sessions using the given intent + /// Replace the semantic fields of today's currently active session. /// - /// Returns a list of (date, session_count) tuples - pub async fn find_logs_with_intent(&self, intent_id: &str) -> Result> { - let all_dates = self.list_logs().await?; - let mut logs_with_intent = Vec::new(); + /// All semantic fields are replaced wholesale — passing `None` for a + /// field clears it. The session's `start`, `end`, `reflection_score`, + /// and `reflection` are preserved. + /// + /// Errors if there is no active session, or if any of the supplied + /// trackers are not present in today's plans. + #[allow(clippy::too_many_arguments)] + pub async fn update_active_session( + &self, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, + note: Option, + ) -> Result<()> { + let ws = self + .workspace + .upgrade() + .ok_or_else(|| anyhow::anyhow!("Workspace no longer available"))?; - for date in all_dates { - if let Ok(log) = self.get_log(date).await { - let count = log - .timeline - .iter() - .filter(|s| s.intent.intent_id == intent_id) - .count(); - - if count > 0 { - logs_with_intent.push((date, count)); - } + let current_date = ws.today(); + let plan_trackers = ws.plans().get_trackers(current_date).await?; + + // Validate trackers against today's plan, mirroring start_session. + if !trackers.is_empty() { + let tracker_ids: std::collections::HashSet<_> = plan_trackers.keys().collect(); + let session_tracker_set: std::collections::HashSet<_> = trackers.iter().collect(); + + if !session_tracker_set.is_subset(&tracker_ids) { + let missing: Vec<_> = session_tracker_set + .difference(&tracker_ids) + .map(|s| s.as_str()) + .collect(); + anyhow::bail!("Tracker {} not found in today's plan", missing.join(", ")); } } - Ok(logs_with_intent) + let log = self.get_log(current_date).await?; + let active = log + .active_session() + .ok_or_else(|| anyhow::anyhow!("No active session to edit"))?; + + let updated_session = + active.with_fields(title, role, impact, mode, subject, trackers, note); + let updated_log = log.update_active_session(updated_session)?; + self.write_log(&updated_log, &plan_trackers).await?; + Ok(()) } - /// Update an intent across all log files - /// - /// Returns the total number of sessions updated - pub async fn update_intent_in_logs( + /// Edit the semantic fields of any session identified by date + index. + /// Time fields (start/end) and reflections are preserved. + #[allow(clippy::too_many_arguments)] + pub async fn update_session_at( &self, - intent_id: &str, - updated_intent: crate::models::Intent, - trackers: &std::collections::HashMap, - ) -> Result { - let logs_with_intent = self.find_logs_with_intent(intent_id).await?; - let mut total_updated = 0; + date: NaiveDate, + index: usize, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, + note: Option, + ) -> Result<()> { + let ws = self + .workspace + .upgrade() + .ok_or_else(|| anyhow::anyhow!("Workspace no longer available"))?; - for (date, _) in logs_with_intent { - if let Ok(log) = self.get_log(date).await { - let (updated_log, count) = log.update_intent(intent_id, updated_intent.clone()); + let plan_trackers = ws.plans().get_trackers(date).await?; - if count > 0 { - self.write_log(&updated_log, trackers).await?; - total_updated += count; - } + // Validate trackers against the plan for the target date. + if !trackers.is_empty() { + let tracker_ids: std::collections::HashSet<_> = plan_trackers.keys().collect(); + let session_tracker_set: std::collections::HashSet<_> = trackers.iter().collect(); + + if !session_tracker_set.is_subset(&tracker_ids) { + let missing: Vec<_> = session_tracker_set + .difference(&tracker_ids) + .map(|s| s.as_str()) + .collect(); + anyhow::bail!( + "Tracker {} not found in plan for {}", + missing.join(", "), + date + ); } } - Ok(total_updated) + let log = self.get_log(date).await?; + let old = log.timeline.get(index).ok_or_else(|| { + anyhow::anyhow!( + "session index {} out of range (timeline has {} sessions)", + index, + log.timeline.len() + ) + })?; + + let updated_session = old.with_fields(title, role, impact, mode, subject, trackers, note); + let updated_log = log.update_session_at(index, updated_session)?; + self.write_log(&updated_log, &plan_trackers).await?; + Ok(()) } /// Replace a field value across all log sessions /// - /// Updates all sessions' embedded intent fields + /// Updates all sessions' embedded fields /// /// # Arguments - /// * `field` - The field to update (role, objective, action, subject) + /// * `field` - The field to update (role, impact, mode, subject) /// * `old_value` - The value to replace /// * `new_value` - The new value /// * `trackers` - Tracker mappings for reformatting @@ -436,51 +506,44 @@ impl LogManager { // Update sessions for session in &log.timeline { - let intent_field_value = match field { - "role" => &session.intent.role, - "objective" => &session.intent.objective, - "action" => &session.intent.action, - "subject" => &session.intent.subject, + let session_field_value = match field { + "role" => &session.role, + "impact" => &session.impact, + "mode" => &session.mode, + "subject" => &session.subject, _ => return Err(anyhow::anyhow!("Unsupported field: {}", field)), }; - if intent_field_value.as_ref().map(|s| s.as_str()) == Some(old_value) { - // Create updated intent - let updated_intent = crate::models::intent::Intent::new( - session.intent.alias.clone(), + if session_field_value.as_ref().map(|s| s.as_str()) == Some(old_value) { + // Create updated session with new field value + let updated_session = crate::models::Session::new( + session.title.clone(), if field == "role" { Some(new_value.to_string()) } else { - session.intent.role.clone() + session.role.clone() }, - if field == "objective" { + if field == "impact" { Some(new_value.to_string()) } else { - session.intent.objective.clone() + session.impact.clone() }, - if field == "action" { + if field == "mode" { Some(new_value.to_string()) } else { - session.intent.action.clone() + session.mode.clone() }, if field == "subject" { Some(new_value.to_string()) } else { - session.intent.subject.clone() + session.subject.clone() }, - session.intent.trackers.clone(), + session.trackers.clone(), + session.start, + session.end, + session.note.clone(), ); - // Create updated session - let updated_session = crate::models::session::Session { - intent: updated_intent, - start: session.start, - end: session.end, - note: session.note.clone(), - reflection_score: session.reflection_score, - reflection: session.reflection.clone(), - }; - updated_timeline.push(updated_session); sessions_updated += 1; log_modified = true; @@ -526,13 +589,13 @@ impl LogManager { // Count sessions for session in &log.timeline { let session_field_value = match field { - "role" => &session.intent.role, - "objective" => &session.intent.objective, - "action" => &session.intent.action, - "subject" => &session.intent.subject, + "role" => &session.role, + "impact" => &session.impact, + "mode" => &session.mode, + "subject" => &session.subject, "tracker" => { // Trackers are a list, count each one - for tracker in &session.intent.trackers { + for tracker in &session.trackers { *session_count.entry(tracker.clone()).or_insert(0) += 1; log_dates.entry(tracker.clone()).or_default().insert(date); } @@ -628,10 +691,10 @@ timezone = "UTC" version = "0.3.0" [[timeline]] -alias = "work" +title = "work" role = "dev" -objective = "feature" -action = "implement" +impact = "feature" +mode = "implement" subject = "api" trackers = ["PROJECT-123"] start = "09:00" @@ -647,8 +710,8 @@ note = "Morning session" assert_eq!(log.timeline.len(), 1); let session = &log.timeline[0]; - assert_eq!(session.intent.alias.as_ref().unwrap(), "work"); - assert_eq!(session.intent.role.as_ref().unwrap(), "dev"); + assert_eq!(session.title.as_ref().unwrap(), "work"); + assert_eq!(session.role.as_ref().unwrap(), "dev"); assert_eq!(session.note.as_ref().unwrap(), "Morning session"); } @@ -667,7 +730,7 @@ note = "Morning session" #[tokio::test] async fn test_midnight_crossing_continuation() { - use crate::models::{Intent, Session}; + use crate::models::Session; use chrono::{TimeZone, Timelike}; let storage = Arc::new(MockStorage::new()); @@ -677,20 +740,16 @@ note = "Morning session" let today = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); // Create an unclosed session yesterday at 23:30 - let intent = Intent::new( + let session_start = chrono_tz::UTC + .with_ymd_and_hms(2025, 3, 14, 23, 30, 0) + .unwrap(); + let unclosed_session = Session::new( Some("work".to_string()), Some("dev".to_string()), Some("project".to_string()), Some("coding".to_string()), Some("api".to_string()), vec![], - ); - - let session_start = chrono_tz::UTC - .with_ymd_and_hms(2025, 3, 14, 23, 30, 0) - .unwrap(); - let unclosed_session = Session::new( - intent.clone(), session_start, None, Some("late night coding".to_string()), @@ -719,7 +778,7 @@ note = "Morning session" // Verify today's log has one session starting at 00:00 assert_eq!(today_log.timeline.len(), 1); let today_session = &today_log.timeline[0]; - assert_eq!(today_session.intent.alias, Some("work".to_string())); + assert_eq!(today_session.title, Some("work".to_string())); assert_eq!(today_session.start.hour(), 0); assert_eq!(today_session.start.minute(), 0); assert!( @@ -742,8 +801,7 @@ note = "Morning session" } #[tokio::test] - async fn test_start_intent_validation_future_time() { - use crate::models::Intent; + async fn test_start_session_validation_future_time() { use chrono::{Duration, Utc}; let storage = Arc::new(MockStorage::new()); @@ -752,17 +810,27 @@ note = "Morning session" // Create a time that is definitely in the future (1 hour from now) let future = Utc::now().with_timezone(&chrono_tz::UTC) + Duration::hours(1); - let intent = Intent::new(Some("work".to_string()), None, None, None, None, vec![]); - - let result = ws.logs().start_intent(intent, future, None).await; + let result = ws + .logs() + .start_session( + Some("work".to_string()), + None, + None, + None, + None, + vec![], + future, + None, + ) + .await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("future")); } #[tokio::test] - async fn test_start_intent_validation_overlap_active_session() { - use crate::models::{Intent, Session}; + async fn test_start_session_validation_overlap_active_session() { + use crate::models::Session; use chrono::TimeZone; let storage = Arc::new(MockStorage::new()); @@ -771,29 +839,49 @@ note = "Morning session" let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); // Create a log with an active session starting at 10:00 - let intent = Intent::new(Some("existing".to_string()), None, None, None, None, vec![]); let session_start = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 10, 0, 0) .unwrap(); - let session = Session::new(intent, session_start, None, None); + let session = Session::new( + Some("existing".to_string()), + None, + None, + None, + None, + vec![], + session_start, + None, + None, + ); let log = crate::models::Log::new(date, chrono_tz::UTC, vec![session]); ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); // Try to start a new session at 09:00 (before active session started) - let new_intent = Intent::new(Some("new".to_string()), None, None, None, None, vec![]); let bad_start = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 9, 0, 0) .unwrap(); - let result = ws.logs().start_intent(new_intent, bad_start, None).await; + let result = ws + .logs() + .start_session( + Some("new".to_string()), + None, + None, + None, + None, + vec![], + bad_start, + None, + ) + .await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Active session")); } #[tokio::test] - async fn test_start_intent_validation_overlap_completed_session() { - use crate::models::{Intent, Session}; + async fn test_start_session_validation_overlap_completed_session() { + use crate::models::Session; use chrono::TimeZone; let storage = Arc::new(MockStorage::new()); @@ -802,24 +890,44 @@ note = "Morning session" let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); // Create a log with a completed session from 09:00 to 10:00 - let intent = Intent::new(Some("existing".to_string()), None, None, None, None, vec![]); let session_start = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 9, 0, 0) .unwrap(); let session_end = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 10, 0, 0) .unwrap(); - let session = Session::new(intent, session_start, Some(session_end), None); + let session = Session::new( + Some("existing".to_string()), + None, + None, + None, + None, + vec![], + session_start, + Some(session_end), + None, + ); let log = crate::models::Log::new(date, chrono_tz::UTC, vec![session]); ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); // Try to start at 09:30 (overlapping completed session) - let new_intent = Intent::new(Some("new".to_string()), None, None, None, None, vec![]); let bad_start = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 9, 30, 0) .unwrap(); - let result = ws.logs().start_intent(new_intent, bad_start, None).await; + let result = ws + .logs() + .start_session( + Some("new".to_string()), + None, + None, + None, + None, + vec![], + bad_start, + None, + ) + .await; assert!(result.is_err()); assert!(result @@ -829,8 +937,8 @@ note = "Morning session" } #[tokio::test] - async fn test_start_intent_stops_active_session() { - use crate::models::{Intent, Session}; + async fn test_start_session_stops_active_session() { + use crate::models::Session; use chrono::{TimeZone, Timelike}; let storage = Arc::new(MockStorage::new()); @@ -839,22 +947,39 @@ note = "Morning session" let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(); // Create a log with an active session starting at 09:00 - let intent = Intent::new(Some("existing".to_string()), None, None, None, None, vec![]); let session_start = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 9, 0, 0) .unwrap(); - let session = Session::new(intent, session_start, None, None); + let session = Session::new( + Some("existing".to_string()), + None, + None, + None, + None, + vec![], + session_start, + None, + None, + ); let log = crate::models::Log::new(date, chrono_tz::UTC, vec![session]); ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); // Start a new session at 11:00 (after active session started) - let new_intent = Intent::new(Some("new".to_string()), None, None, None, None, vec![]); let new_start = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 11, 0, 0) .unwrap(); ws.logs() - .start_intent(new_intent, new_start, None) + .start_session( + Some("new".to_string()), + None, + None, + None, + None, + vec![], + new_start, + None, + ) .await .unwrap(); @@ -863,12 +988,470 @@ note = "Morning session" assert_eq!(log.timeline.len(), 2); // First session should be closed at 11:00 - assert_eq!(log.timeline[0].intent.alias, Some("existing".to_string())); + assert_eq!(log.timeline[0].title, Some("existing".to_string())); assert!(log.timeline[0].end.is_some()); assert_eq!(log.timeline[0].end.unwrap().hour(), 11); // Second session should be active - assert_eq!(log.timeline[1].intent.alias, Some("new".to_string())); + assert_eq!(log.timeline[1].title, Some("new".to_string())); assert!(log.timeline[1].end.is_none()); } + + // -- update_active_session tests --------------------------------------- + + /// Helper: build an active (end=None) session at 00:01 UTC on `date` + /// with the given title. Time is well-defined and survives round-trip + /// through the TOML serializer. + fn make_active_session(date: NaiveDate, title: &str) -> crate::models::Session { + use chrono::TimeZone; + let start = chrono_tz::UTC + .from_utc_datetime(&date.and_hms_opt(0, 1, 0).unwrap()); + crate::models::Session::new( + Some(title.to_string()), + Some("old-role".to_string()), + Some("old-impact".to_string()), + Some("old-mode".to_string()), + Some("old-subject".to_string()), + vec![], + start, + None, + Some("old note".to_string()), + ) + } + + #[tokio::test] + async fn test_update_active_session_replaces_fields() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + let session = make_active_session(date, "old title"); + let original_start = session.start; + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![session]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + ws.logs() + .update_active_session( + Some("new title".to_string()), + Some("new-role".to_string()), + Some("new-impact".to_string()), + Some("new-mode".to_string()), + Some("new-subject".to_string()), + vec![], + Some("new note".to_string()), + ) + .await + .unwrap(); + + let updated = ws.logs().get_log(date).await.unwrap(); + assert_eq!(updated.timeline.len(), 1); + let s = &updated.timeline[0]; + assert_eq!(s.title, Some("new title".to_string())); + assert_eq!(s.role, Some("new-role".to_string())); + assert_eq!(s.impact, Some("new-impact".to_string())); + assert_eq!(s.mode, Some("new-mode".to_string())); + assert_eq!(s.subject, Some("new-subject".to_string())); + assert_eq!(s.note, Some("new note".to_string())); + // start preserved, still active + assert_eq!(s.start, original_start); + assert_eq!(s.end, None); + } + + #[tokio::test] + async fn test_update_active_session_errors_without_active() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + // Empty log: nothing active. + let result = ws + .logs() + .update_active_session( + Some("x".to_string()), + None, + None, + None, + None, + vec![], + None, + ) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("active session")); + } + + #[tokio::test] + async fn test_update_active_session_errors_when_last_session_closed() { + use chrono::TimeZone; + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + // A closed session — there's a timeline entry but nothing active. + let start = chrono_tz::UTC + .from_utc_datetime(&date.and_hms_opt(0, 1, 0).unwrap()); + let end = chrono_tz::UTC + .from_utc_datetime(&date.and_hms_opt(0, 2, 0).unwrap()); + let closed = crate::models::Session::new( + Some("done".to_string()), + None, + None, + None, + None, + vec![], + start, + Some(end), + None, + ); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![closed]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + let result = ws + .logs() + .update_active_session( + Some("x".to_string()), + None, + None, + None, + None, + vec![], + None, + ) + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("active session")); + } + + #[tokio::test] + async fn test_update_active_session_rejects_unknown_trackers() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + let session = make_active_session(date, "current"); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![session]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + // Today's plan has no trackers — passing one should fail. + let result = ws + .logs() + .update_active_session( + Some("current".to_string()), + None, + None, + None, + None, + vec!["bogus:42".to_string()], + None, + ) + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .to_lowercase() + .contains("not found")); + } + + #[tokio::test] + async fn test_update_active_session_preserves_other_sessions() { + use chrono::TimeZone; + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + // Closed session at 00:01-00:02, active session starting at 00:03. + let s1_start = chrono_tz::UTC + .from_utc_datetime(&date.and_hms_opt(0, 1, 0).unwrap()); + let s1_end = chrono_tz::UTC + .from_utc_datetime(&date.and_hms_opt(0, 2, 0).unwrap()); + let s2_start = chrono_tz::UTC + .from_utc_datetime(&date.and_hms_opt(0, 3, 0).unwrap()); + let closed = crate::models::Session::new( + Some("first".to_string()), + Some("role-1".to_string()), + None, + None, + None, + vec![], + s1_start, + Some(s1_end), + None, + ); + let active = crate::models::Session::new( + Some("second-old".to_string()), + None, + None, + None, + None, + vec![], + s2_start, + None, + None, + ); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![closed, active]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + ws.logs() + .update_active_session( + Some("second-new".to_string()), + None, + None, + None, + None, + vec![], + None, + ) + .await + .unwrap(); + + let updated = ws.logs().get_log(date).await.unwrap(); + assert_eq!(updated.timeline.len(), 2); + // First session untouched. + assert_eq!(updated.timeline[0].title, Some("first".to_string())); + assert_eq!(updated.timeline[0].role, Some("role-1".to_string())); + assert_eq!(updated.timeline[0].end, Some(s1_end)); + // Second session updated. + assert_eq!(updated.timeline[1].title, Some("second-new".to_string())); + assert_eq!(updated.timeline[1].start, s2_start); + assert_eq!(updated.timeline[1].end, None); + } + + #[tokio::test] + async fn test_update_active_session_preserves_reflection() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + let session = make_active_session(date, "current") + .with_reflection(Some(4), Some("went well".to_string())); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![session]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + ws.logs() + .update_active_session( + Some("renamed".to_string()), + None, + None, + None, + None, + vec![], + None, + ) + .await + .unwrap(); + + let updated = ws.logs().get_log(date).await.unwrap(); + let s = &updated.timeline[0]; + assert_eq!(s.title, Some("renamed".to_string())); + assert_eq!(s.reflection_score, Some(4)); + assert_eq!(s.reflection, Some("went well".to_string())); + } + + // -- update_session_at tests --------------------------------------------- + + /// Helper: build a closed session at `h_start`–`h_end` UTC on `date`. + fn make_closed_session( + date: NaiveDate, + title: &str, + h_start: u32, + h_end: u32, + ) -> crate::models::Session { + use chrono::TimeZone; + let start = chrono_tz::UTC.from_utc_datetime(&date.and_hms_opt(h_start, 0, 0).unwrap()); + let end = chrono_tz::UTC.from_utc_datetime(&date.and_hms_opt(h_end, 0, 0).unwrap()); + crate::models::Session::new( + Some(title.to_string()), + Some("role".to_string()), + None, + None, + None, + vec![], + start, + Some(end), + None, + ) + } + + #[tokio::test] + async fn test_update_session_at_replaces_fields() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + let s0 = make_closed_session(date, "first", 9, 10); + let s0_start = s0.start; + let s0_end = s0.end; + let s1 = make_closed_session(date, "second", 10, 11); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![s0, s1]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + ws.logs() + .update_session_at( + date, + 0, + Some("first-edited".to_string()), + Some("new-role".to_string()), + None, + None, + None, + vec![], + Some("added a note".to_string()), + ) + .await + .unwrap(); + + let updated = ws.logs().get_log(date).await.unwrap(); + assert_eq!(updated.timeline.len(), 2); + let s = &updated.timeline[0]; + assert_eq!(s.title, Some("first-edited".to_string())); + assert_eq!(s.role, Some("new-role".to_string())); + assert_eq!(s.note, Some("added a note".to_string())); + // Times preserved. + assert_eq!(s.start, s0_start); + assert_eq!(s.end, s0_end); + // Second session untouched. + assert_eq!(updated.timeline[1].title, Some("second".to_string())); + } + + #[tokio::test] + async fn test_update_session_at_invalid_index() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + let s0 = make_closed_session(date, "only", 9, 10); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![s0]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + let err = ws + .logs() + .update_session_at( + date, + 5, + Some("x".to_string()), + None, + None, + None, + None, + vec![], + None, + ) + .await; + assert!(err.is_err()); + assert!(err.unwrap_err().to_string().contains("out of range")); + } + + #[tokio::test] + async fn test_update_session_at_preserves_reflection() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + let session = make_closed_session(date, "done", 9, 10) + .with_reflection(Some(5), Some("great day".to_string())); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![session]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + ws.logs() + .update_session_at( + date, + 0, + Some("done-renamed".to_string()), + None, + None, + None, + None, + vec![], + None, + ) + .await + .unwrap(); + + let updated = ws.logs().get_log(date).await.unwrap(); + let s = &updated.timeline[0]; + assert_eq!(s.title, Some("done-renamed".to_string())); + assert_eq!(s.reflection_score, Some(5)); + assert_eq!(s.reflection, Some("great day".to_string())); + } + + #[tokio::test] + async fn test_update_session_at_rejects_unknown_trackers() { + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let date = ws.today(); + let s0 = make_closed_session(date, "first", 9, 10); + let log = crate::models::Log::new(date, chrono_tz::UTC, vec![s0]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + let err = ws + .logs() + .update_session_at( + date, + 0, + Some("first-edited".to_string()), + None, + None, + None, + None, + vec!["bogus:tracker".to_string()], + None, + ) + .await; + assert!(err.is_err()); + assert!(err.unwrap_err().to_string().contains("not found")); + } + + #[tokio::test] + async fn test_update_session_at_historical_date() { + // The whole point of update_session_at vs update_active_session is + // that it takes an arbitrary date. Write a log for yesterday, edit + // a session in it, verify the edit lands in yesterday's log file + // (not today's). + let storage = Arc::new(MockStorage::new()); + let ws = create_test_workspace(storage.clone()).await; + + let today = ws.today(); + let yesterday = today - chrono::Duration::days(1); + + let s0 = make_closed_session(yesterday, "yesterday-session", 9, 10); + let s0_start = s0.start; + let s0_end = s0.end; + let log = crate::models::Log::new(yesterday, chrono_tz::UTC, vec![s0]); + ws.logs().write_log(&log, &HashMap::new()).await.unwrap(); + + // Sanity: today's log is empty. + let today_log = ws.logs().get_log(today).await.unwrap(); + assert_eq!(today_log.timeline.len(), 0); + + ws.logs() + .update_session_at( + yesterday, + 0, + Some("yesterday-edited".to_string()), + Some("new-role".to_string()), + None, + None, + None, + vec![], + Some("edited after the fact".to_string()), + ) + .await + .unwrap(); + + // The edit lands in yesterday's log, not today's. + let updated_yesterday = ws.logs().get_log(yesterday).await.unwrap(); + assert_eq!(updated_yesterday.timeline.len(), 1); + let s = &updated_yesterday.timeline[0]; + assert_eq!(s.title, Some("yesterday-edited".to_string())); + assert_eq!(s.role, Some("new-role".to_string())); + assert_eq!(s.note, Some("edited after the fact".to_string())); + // Times still intact. + assert_eq!(s.start, s0_start); + assert_eq!(s.end, s0_end); + + // Today's log remains empty. + let today_after = ws.logs().get_log(today).await.unwrap(); + assert_eq!(today_after.timeline.len(), 0); + } } diff --git a/core/src/managers/plan_manager.rs b/core/src/managers/plan_manager.rs index 6565a5c..691be15 100644 --- a/core/src/managers/plan_manager.rs +++ b/core/src/managers/plan_manager.rs @@ -5,8 +5,19 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; -use crate::models::intent::Intent; -use crate::models::plan::Plan; +use crate::models::plan::{Plan, SessionHint}; +use crate::models::remote::TrackerMapping; + +/// All plan-derived data needed at session start time, loaded in a single pass. +pub struct StartData { + pub roles: Vec, + pub impacts: Vec, + pub modes: Vec, + pub subjects: Vec, + pub trackers: HashMap, + pub hints: Vec, + pub tracker_mappings: Vec, +} use crate::storage::Storage; // Regex for parsing plan filenames - validated at compile time @@ -129,40 +140,17 @@ impl PlanManager { Ok(candidates.into_values().map(|(_, path)| path).collect()) } - /// Get all intents from plans valid for a given date - pub async fn get_intents(&self, date: NaiveDate) -> Result> { - let plans = self.get_plans(date).await?; - let mut intents = std::collections::HashSet::new(); - - for plan in plans.values() { - for intent in &plan.intents { - intents.insert(intent.clone()); - } - } - - Ok(intents.into_iter().collect()) - } - /// Get all roles from plans valid for a given date /// /// Returns roles prefixed with their source (e.g., "element:engineer") - /// plus any roles from intents pub async fn get_roles(&self, date: NaiveDate) -> Result> { let plans = self.get_plans(date).await?; let mut roles = Vec::new(); for plan in plans.values() { - // Roles from plan (prefixed with source) for role in &plan.roles { roles.push(format!("{}:{}", plan.source, role)); } - - // Roles from intents - for intent in &plan.intents { - if let Some(role) = &intent.role { - roles.push(role.clone()); - } - } } // Deduplicate and sort @@ -172,56 +160,40 @@ impl PlanManager { Ok(roles) } - /// Get all objectives from plans valid for a given date - pub async fn get_objectives(&self, date: NaiveDate) -> Result> { + /// Get all impacts from plans valid for a given date + pub async fn get_impacts(&self, date: NaiveDate) -> Result> { let plans = self.get_plans(date).await?; - let mut objectives = Vec::new(); + let mut impacts = Vec::new(); for plan in plans.values() { - // Objectives from plan (prefixed with source) - for objective in &plan.objectives { - objectives.push(format!("{}:{}", plan.source, objective)); - } - - // Objectives from intents - for intent in &plan.intents { - if let Some(objective) = &intent.objective { - objectives.push(objective.clone()); - } + for impact in &plan.impacts { + impacts.push(format!("{}:{}", plan.source, impact)); } } // Deduplicate and sort - objectives.sort(); - objectives.dedup(); + impacts.sort(); + impacts.dedup(); - Ok(objectives) + Ok(impacts) } - /// Get all actions from plans valid for a given date - pub async fn get_actions(&self, date: NaiveDate) -> Result> { + /// Get all modes from plans valid for a given date + pub async fn get_modes(&self, date: NaiveDate) -> Result> { let plans = self.get_plans(date).await?; - let mut actions = Vec::new(); + let mut modes = Vec::new(); for plan in plans.values() { - // Actions from plan (prefixed with source) - for action in &plan.actions { - actions.push(format!("{}:{}", plan.source, action)); - } - - // Actions from intents - for intent in &plan.intents { - if let Some(action) = &intent.action { - actions.push(action.clone()); - } + for mode in &plan.modes { + modes.push(format!("{}:{}", plan.source, mode)); } } // Deduplicate and sort - actions.sort(); - actions.dedup(); + modes.sort(); + modes.dedup(); - Ok(actions) + Ok(modes) } /// Get all subjects from plans valid for a given date @@ -230,17 +202,9 @@ impl PlanManager { let mut subjects = Vec::new(); for plan in plans.values() { - // Subjects from plan (prefixed with source) for subject in &plan.subjects { subjects.push(format!("{}:{}", plan.source, subject)); } - - // Subjects from intents - for intent in &plan.intents { - if let Some(subject) = &intent.subject { - subjects.push(subject.clone()); - } - } } // Deduplicate and sort @@ -311,11 +275,146 @@ impl PlanManager { vec![], vec![], HashMap::new(), - vec![], )) } } + /// Get all session hints from plans valid for a given date + /// + /// Returns hints generated by plugins (e.g. POC/Support tracker hints), + /// collected across all plans for the date. + pub async fn get_session_hints(&self, date: NaiveDate) -> Result> { + let plans = self.get_plans(date).await?; + let mut all_hints = Vec::new(); + for plan in plans.values() { + all_hints.extend(plan.hints.clone()); + } + Ok(all_hints) + } + + /// Get tracker mappings for all plans valid for a given date + /// + /// Loads each plan's remote config (if any) and runs all tracker-source vocabulary + /// mappings to build a reverse lookup index from session field values to trackers. + /// Used for auto-deriving trackers at session start time. + pub async fn get_tracker_mappings(&self, date: NaiveDate) -> Result> { + use crate::models::remote::Remote; + + let plans = self.get_plans(date).await?; + let mut all_mappings = Vec::new(); + + for plan in plans.values() { + let remote_file = self + .storage + .remotes_dir() + .join(format!("{}.toml", plan.source)); + + if !self.storage.exists(&remote_file) { + continue; + } + + let remote_toml = self + .storage + .read_string(&remote_file) + .await + .with_context(|| { + format!("Failed to read remote config: {}", remote_file.display()) + })?; + + let remote = Remote::from_toml(&remote_toml).with_context(|| { + format!("Failed to parse remote config: {}", remote_file.display()) + })?; + + let tracker_mappings = remote.generate_tracker_mappings(plan).with_context(|| { + format!( + "Failed to generate tracker mappings for remote '{}'", + remote.id + ) + })?; + + all_mappings.extend(tracker_mappings); + } + + Ok(all_mappings) + } + + /// Return all plan-derived data needed at session start time in a single plan load. + /// + /// Replaces calling get_roles/get_impacts/get_modes/get_subjects/get_trackers/ + /// get_session_hints/get_tracker_mappings individually (each re-reads plan files). + pub async fn get_start_data(&self, date: NaiveDate) -> Result { + use crate::models::remote::Remote; + + let plans = self.get_plans(date).await?; + + let mut roles = Vec::new(); + let mut impacts = Vec::new(); + let mut modes = Vec::new(); + let mut subjects = Vec::new(); + let mut trackers = HashMap::new(); + let mut hints = Vec::new(); + let mut tracker_mappings = Vec::new(); + + for plan in plans.values() { + for role in &plan.roles { + roles.push(format!("{}:{}", plan.source, role)); + } + for impact in &plan.impacts { + impacts.push(format!("{}:{}", plan.source, impact)); + } + for mode in &plan.modes { + modes.push(format!("{}:{}", plan.source, mode)); + } + for subject in &plan.subjects { + subjects.push(format!("{}:{}", plan.source, subject)); + } + for (key, value) in &plan.trackers { + trackers.insert(format!("{}:{}", plan.source, key), value.clone()); + } + hints.extend(plan.hints.clone()); + + let remote_file = self + .storage + .remotes_dir() + .join(format!("{}.toml", plan.source)); + if self.storage.exists(&remote_file) { + let remote_toml = + self.storage + .read_string(&remote_file) + .await + .with_context(|| { + format!("Failed to read remote config: {}", remote_file.display()) + })?; + let remote = Remote::from_toml(&remote_toml).with_context(|| { + format!("Failed to parse remote config: {}", remote_file.display()) + })?; + let plan_mappings = remote.generate_tracker_mappings(plan).with_context(|| { + format!("Failed to generate tracker mappings for '{}'", remote.id) + })?; + tracker_mappings.extend(plan_mappings); + } + } + + roles.sort(); + roles.dedup(); + impacts.sort(); + impacts.dedup(); + modes.sort(); + modes.dedup(); + subjects.sort(); + subjects.dedup(); + + Ok(StartData { + roles, + impacts, + modes, + subjects, + trackers, + hints, + tracker_mappings, + }) + } + /// Write a plan to storage /// /// If a remote configuration exists for this plan's source, vocabulary mappings @@ -346,7 +445,7 @@ impl PlanManager { })?; if !remote.vocabulary_mappings.is_empty() { - // Try to load existing plan for this date to maintain intent ID continuity + // Try to load existing plan for this date to maintain continuity let existing_plan = self .get_plans(plan.valid_from) .await @@ -454,89 +553,6 @@ impl PlanManager { Ok(()) } - /// Find an intent by ID across all plan files - /// - /// Searches all plan files for an intent with the given intent_id. - /// Returns None if the intent is not found. - /// - /// # Returns - /// - Ok(Some((source, intent, plan_file_path))) if found - /// - Ok(None) if not found - /// - Err if there's an error reading files - pub async fn find_intent_by_id( - &self, - intent_id: &str, - ) -> Result> { - let plan_dir = self.storage.plan_dir(); - let plan_files = self - .storage - .list_files(&plan_dir, "*.toml") - .await - .context("Failed to list plan files")?; - - for file_path in plan_files { - let content = self - .storage - .read_string(&file_path) - .await - .with_context(|| format!("Failed to read plan file: {}", file_path.display()))?; - - let plan: Plan = match toml::from_str(&content) { - Ok(p) => p, - Err(_) => continue, // Skip invalid plan files - }; - - // Search for the intent in this plan - for intent in &plan.intents { - if intent.intent_id == intent_id { - return Ok(Some((plan.source.clone(), intent.clone(), file_path))); - } - } - } - - Ok(None) - } - - /// Update an intent by ID across all plan files - /// - /// Searches all plan files for an intent with the given intent_id and updates it. - /// Returns the updated plan if found and successfully updated. - /// - /// # Returns - /// - Ok(Some(plan)) if the intent was found and updated - /// - Ok(None) if the intent was not found - /// - Err if there's an error reading/writing files or updating the intent - pub async fn update_intent_by_id( - &self, - intent_id: &str, - updated_intent: Intent, - ) -> Result> { - // First find the intent - let found = self.find_intent_by_id(intent_id).await?; - - if let Some((_source, _original_intent, file_path)) = found { - // Load the plan - let content = self - .storage - .read_string(&file_path) - .await - .with_context(|| format!("Failed to read plan file: {}", file_path.display()))?; - - let plan: Plan = toml::from_str(&content) - .with_context(|| format!("Failed to parse plan file: {}", file_path.display()))?; - - // Update the intent - let updated_plan = plan.update_intent(intent_id, updated_intent)?; - - // Write it back - self.write_plan(&updated_plan).await?; - - Ok(Some(updated_plan)) - } else { - Ok(None) - } - } - /// Get plan remote plugin instances /// /// This is a convenience method that delegates to the plugin manager. @@ -559,27 +575,26 @@ impl PlanManager { /// Replace a field value across all plans /// - /// Updates both plan-level ASTRO collections and intents + /// Updates plan-level ASTRO collections /// /// # Arguments - /// * `field` - The field to update (role, objective, action, subject) + /// * `field` - The field to update (role, impact, mode, subject) /// * `old_value` - The value to replace /// * `new_value` - The new value /// /// # Returns - /// Tuple of (plans_updated, intents_updated) + /// Number of plans updated pub async fn replace_field_in_all_plans( &self, field: &str, old_value: &str, new_value: &str, - ) -> Result<(usize, usize)> { + ) -> Result { let plan_dir = self.storage.plan_dir(); let entries = std::fs::read_dir(&plan_dir) .with_context(|| format!("Failed to read plan directory: {}", plan_dir.display()))?; let mut plans_updated = 0; - let mut intents_updated = 0; for entry in entries { let entry = entry?; @@ -596,137 +611,99 @@ impl PlanManager { let mut plan_modified = false; + // Plans store vocabulary values WITHOUT source prefix (e.g. "engineer" not + // "element:engineer"). Strip the plan's source prefix from old/new values so + // that callers can pass prefixed values (as used in logs) and still match. + let source_prefix = format!("{}:", plan.source); + let plan_old = old_value.strip_prefix(&source_prefix).unwrap_or(old_value); + let plan_new = new_value.strip_prefix(&source_prefix).unwrap_or(new_value); + + // Skip if the normalised old and new values are identical (no real change) + if plan_old == plan_new { + continue; + } + // Update plan-level ASTRO collection match field { "role" => { - let mut roles = plan.roles.clone(); - if roles.iter().any(|v| v == old_value) { - roles = roles + if plan.roles.iter().any(|v| v == plan_old) { + plan.roles = plan + .roles .into_iter() .map(|v| { - if v == old_value { - new_value.to_string() + if v == plan_old { + plan_new.to_string() } else { v } }) .collect(); - plan.roles = roles; plan_modified = true; } } - "objective" => { - let mut objectives = plan.objectives.clone(); - if objectives.iter().any(|v| v == old_value) { - objectives = objectives + "impact" => { + if plan.impacts.iter().any(|v| v == plan_old) { + plan.impacts = plan + .impacts .into_iter() .map(|v| { - if v == old_value { - new_value.to_string() + if v == plan_old { + plan_new.to_string() } else { v } }) .collect(); - plan.objectives = objectives; plan_modified = true; } } - "action" => { - let mut actions = plan.actions.clone(); - if actions.iter().any(|v| v == old_value) { - actions = actions + "mode" => { + if plan.modes.iter().any(|v| v == plan_old) { + plan.modes = plan + .modes .into_iter() .map(|v| { - if v == old_value { - new_value.to_string() + if v == plan_old { + plan_new.to_string() } else { v } }) .collect(); - plan.actions = actions; plan_modified = true; } } "subject" => { - let mut subjects = plan.subjects.clone(); - if subjects.iter().any(|v| v == old_value) { - subjects = subjects + if plan.subjects.iter().any(|v| v == plan_old) { + plan.subjects = plan + .subjects .into_iter() .map(|v| { - if v == old_value { - new_value.to_string() + if v == plan_old { + plan_new.to_string() } else { v } }) .collect(); - plan.subjects = subjects; plan_modified = true; } } _ => return Err(anyhow::anyhow!("Unsupported field: {}", field)), }; - // Update intents - let mut updated_intents = Vec::new(); - for intent in &plan.intents { - let intent_field_value = match field { - "role" => &intent.role, - "objective" => &intent.objective, - "action" => &intent.action, - "subject" => &intent.subject, - _ => unreachable!(), - }; - - if intent_field_value.as_ref().map(|s| s.as_str()) == Some(old_value) { - // Create updated intent - let updated_intent = Intent::new( - intent.alias.clone(), - if field == "role" { - Some(new_value.to_string()) - } else { - intent.role.clone() - }, - if field == "objective" { - Some(new_value.to_string()) - } else { - intent.objective.clone() - }, - if field == "action" { - Some(new_value.to_string()) - } else { - intent.action.clone() - }, - if field == "subject" { - Some(new_value.to_string()) - } else { - intent.subject.clone() - }, - intent.trackers.clone(), - ); - updated_intents.push(updated_intent); - intents_updated += 1; - plan_modified = true; - } else { - updated_intents.push(intent.clone()); - } - } - if plan_modified { - plan.intents = updated_intents; self.write_plan(&plan).await?; plans_updated += 1; } } - Ok((plans_updated, intents_updated)) + Ok(plans_updated) } /// Get usage statistics for a field across all plans /// - /// Returns a HashMap of field value -> intent count + /// Returns a HashMap of field value -> plan count pub async fn get_field_usage_stats(&self, field: &str) -> Result> { let plan_dir = self.storage.plan_dir(); let entries = std::fs::read_dir(&plan_dir) @@ -747,26 +724,23 @@ impl PlanManager { let content = self.storage.read_string(&path).await?; let plan: Plan = toml::from_str(&content)?; - // Count intents using this field value - for intent in &plan.intents { - let intent_field_value = match field { - "role" => &intent.role, - "objective" => &intent.objective, - "action" => &intent.action, - "subject" => &intent.subject, - "tracker" => { - // Trackers are a list, count each one - for tracker in &intent.trackers { - *usage_stats.entry(tracker.clone()).or_insert(0) += 1; - } - continue; + // Count vocabulary usage in this plan + let values: Vec<&String> = match field { + "role" => plan.roles.iter().collect(), + "impact" => plan.impacts.iter().collect(), + "mode" => plan.modes.iter().collect(), + "subject" => plan.subjects.iter().collect(), + "tracker" => { + for tracker in plan.trackers.keys() { + *usage_stats.entry(tracker.clone()).or_insert(0) += 1; } - _ => return Err(anyhow::anyhow!("Unsupported field: {}", field)), - }; - - if let Some(value) = intent_field_value { - *usage_stats.entry(value.clone()).or_insert(0) += 1; + continue; } + _ => return Err(anyhow::anyhow!("Unsupported field: {}", field)), + }; + + for value in values { + *usage_stats.entry(value.clone()).or_insert(0) += 1; } } @@ -791,11 +765,6 @@ subjects = ["features"] [trackers] "123" = "Task 123" - -[[intents]] -alias = "Work on feature" -role = "{source}:engineer" -objective = "{source}:development" "# ) } @@ -869,7 +838,7 @@ objective = "{source}:development" let plan = manager.get_local_plan_or_create(date).await.unwrap(); assert_eq!(plan.source, "local"); assert_eq!(plan.valid_from, date); - assert_eq!(plan.intents.len(), 0); + assert!(plan.roles.is_empty()); } #[tokio::test] @@ -957,23 +926,19 @@ objective = "{source}:development" async fn test_write_plan_applies_vocabulary_mappings() { let storage = Arc::new(MockStorage::new()); - // Create a remote configuration with vocabulary mapping + // Create a remote configuration with vocabulary mapping (tracker -> subject) let remote_toml = r#" id = "test-remote" plugin = "test" [[vocabulary_mapping]] source_type = "tracker" -target_type = "intent" +target_type = "subject" pattern = "^POC-(?P\\d+)\\s+(?P.+)$" -alias = "POC-{id}: {description}" -role = "customer-success" -objective = "revenue" -action = "drive-poc" subject = "poc/{description|slugify}" "#; - // Store the remote config (MockStorage base_dir is /faff/.faff) + // Store the remote config storage.add_file( PathBuf::from("/faff/remotes/test-remote.toml"), remote_toml.to_string(), @@ -996,7 +961,6 @@ subject = "poc/{description|slugify}" vec![], vec![], trackers, - vec![], ); // Write the plan (should apply vocabulary mappings) @@ -1005,7 +969,7 @@ subject = "poc/{description|slugify}" .await .expect("Failed to write plan"); - // Read back the written plan (MockStorage base_dir is /faff/.faff) + // Read back the written plan let written_plan_path = PathBuf::from("/faff/plans/test-remote.20251104.toml"); assert!( storage.exists(&written_plan_path), @@ -1019,29 +983,19 @@ subject = "poc/{description|slugify}" let written_plan: Plan = toml::from_str(&written_content).expect("Failed to parse written plan"); - // Verify that intents were generated + // Verify that subjects were generated assert_eq!( - written_plan.intents.len(), + written_plan.subjects.len(), 2, - "Should generate 2 intents from 2 POC trackers" + "Should generate 2 subjects from 2 POC trackers" ); - // Check that POC-29 intent was created - let poc29 = written_plan - .intents - .iter() - .find(|i| { - i.alias - .as_ref() - .map(|a| a.contains("POC-29")) - .unwrap_or(false) - }) - .expect("Should find POC-29 intent"); - - assert_eq!(poc29.alias, Some("POC-29: European Commission".to_string())); - assert_eq!(poc29.role, Some("customer-success".to_string())); - assert_eq!(poc29.objective, Some("revenue".to_string())); - assert_eq!(poc29.action, Some("drive-poc".to_string())); - assert_eq!(poc29.subject, Some("poc/european-commission".to_string())); + // Check that subjects were created correctly + assert!(written_plan + .subjects + .contains(&"poc/european-commission".to_string())); + assert!(written_plan + .subjects + .contains(&"poc/unicredit-poc".to_string())); } } diff --git a/core/src/managers/plugin_manager.rs b/core/src/managers/plugin_manager.rs index bd1c1a0..c8a20ac 100644 --- a/core/src/managers/plugin_manager.rs +++ b/core/src/managers/plugin_manager.rs @@ -331,26 +331,26 @@ impl PluginManager { ), ); } - if !remote.vocabulary.objectives.is_empty() { + if !remote.vocabulary.impacts.is_empty() { defaults.insert( - "objectives".to_string(), + "impacts".to_string(), toml::Value::Array( remote .vocabulary - .objectives + .impacts .iter() .map(|s| toml::Value::String(s.clone())) .collect(), ), ); } - if !remote.vocabulary.actions.is_empty() { + if !remote.vocabulary.modes.is_empty() { defaults.insert( - "actions".to_string(), + "modes".to_string(), toml::Value::Array( remote .vocabulary - .actions + .modes .iter() .map(|s| toml::Value::String(s.clone())) .collect(), diff --git a/core/src/managers/timesheet_manager.rs b/core/src/managers/timesheet_manager.rs index 2040495..22171cb 100644 --- a/core/src/managers/timesheet_manager.rs +++ b/core/src/managers/timesheet_manager.rs @@ -96,6 +96,9 @@ impl TimesheetManager { let meta: TimesheetMeta = serde_json::from_str(&meta_data).context("Failed to parse timesheet metadata")?; timesheet.meta = meta; + } else { + // No meta file — infer audience_id from the directory name + timesheet.meta.audience_id = audience_id.to_string(); } Ok(Some(timesheet)) diff --git a/core/src/models/intent.rs b/core/src/models/intent.rs deleted file mode 100644 index 534b1e4..0000000 --- a/core/src/models/intent.rs +++ /dev/null @@ -1,125 +0,0 @@ -use chrono::NaiveDate; -use rand::Rng; -use serde::de::{self, Visitor}; -use serde::{Deserialize, Deserializer, Serialize}; -use std::collections::HashSet; - -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Intent { - #[serde(default)] - pub intent_id: String, - pub alias: Option, - pub role: Option, - pub objective: Option, - pub action: Option, - pub subject: Option, - #[serde(default, deserialize_with = "deserialize_trackers")] - pub trackers: Vec, -} - -/// Custom deserializer for trackers that handles both string and array formats -fn deserialize_trackers<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct TrackersVisitor; - - impl<'de> Visitor<'de> for TrackersVisitor { - type Value = Vec; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string or array of strings") - } - - fn visit_str(self, value: &str) -> Result, E> - where - E: de::Error, - { - Ok(vec![value.to_string()]) - } - - fn visit_seq(self, mut seq: A) -> Result, A::Error> - where - A: de::SeqAccess<'de>, - { - let mut trackers = Vec::new(); - while let Some(value) = seq.next_element()? { - trackers.push(value); - } - Ok(trackers) - } - } - - deserializer.deserialize_any(TrackersVisitor) -} - -impl Intent { - /// Generate a new intent ID in the format source:i-YYYYMMDD-{6 random chars} - /// - /// # Arguments - /// * `source` - The source prefix (e.g., "local", "element", "jira") - /// * `date` - The date to use in the ID (typically the current date) - /// - /// # Returns - /// A string in the format "local:i-20250125-abc123" - pub fn generate_intent_id(source: &str, date: NaiveDate) -> String { - let date_str = date.format("%Y%m%d"); - let random_suffix: String = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(6) - .map(|c| c.to_ascii_lowercase()) - .map(char::from) - .collect(); - format!("{source}:i-{date_str}-{random_suffix}") - } - - pub fn new( - alias: Option, - role: Option, - objective: Option, - action: Option, - subject: Option, - trackers: Vec, - ) -> Self { - Self::new_with_id( - None, // Auto-generate ID with current date - alias, role, objective, action, subject, trackers, - ) - } - - pub fn new_with_id( - intent_id: Option, - alias: Option, - role: Option, - objective: Option, - action: Option, - subject: Option, - trackers: Vec, - ) -> Self { - let deduped: Vec = HashSet::<_>::from_iter(trackers).into_iter().collect(); - - let alias = alias.or_else(|| { - Some(format!( - "{}: {} to {} for {}", - role.as_deref().unwrap_or(""), - action.as_deref().unwrap_or(""), - objective.as_deref().unwrap_or(""), - subject.as_deref().unwrap_or("") - )) - }); - - // Use provided ID or empty string - // Note: Plan::add_intent() will generate a properly prefixed ID if empty - let intent_id = intent_id.unwrap_or_default(); - - Self { - intent_id, - alias, - role, - objective, - action, - subject, - trackers: deduped, - } - } -} diff --git a/core/src/models/log.rs b/core/src/models/log.rs index 61dc75d..486fda3 100644 --- a/core/src/models/log.rs +++ b/core/src/models/log.rs @@ -6,7 +6,6 @@ use std::collections::HashMap; use std::sync::LazyLock; use thiserror::Error; -use crate::models::intent::Intent; use crate::models::session::Session; // Compiled regex for commentifying derived values - validated at compile time @@ -19,6 +18,10 @@ static DERIVED_VALUE_REGEX: LazyLock = LazyLock::new(|| { pub enum LogError { #[error("No timeline entries to stop")] NoTimelineEntries, + #[error("No active session in this log")] + NoActiveSession, + #[error("Session index {index} out of range (timeline has {len} sessions)")] + IndexOutOfRange { index: usize, len: usize }, #[error("Invalid time value: {0}")] InvalidTime(String), #[error("Ambiguous datetime during DST transition: {0}")] @@ -30,8 +33,8 @@ pub enum LogError { pub struct LogSummary { /// Total recorded time in minutes pub total_minutes: i64, - /// Time by intent alias in minutes - pub by_intent: HashMap, + /// Time by session title in minutes + pub by_title: HashMap, /// Time by tracker in minutes pub by_tracker: HashMap, /// Time by tracker source (prefix before ':') in minutes @@ -111,6 +114,41 @@ impl Log { Ok(Log::new(self.date, self.timezone, new_timeline)) } + /// Replace the active session with a new Session value, preserving its + /// position in the timeline. Errors if no session is currently active + /// (i.e. the last entry has a non-None `end`, or the timeline is empty). + /// + /// The caller is responsible for constructing `new_session` such that + /// invariants are preserved — typically via `Session::with_fields` on + /// the existing active session, which keeps the original start/end and + /// reflection intact. + pub fn update_active_session(&self, new_session: Session) -> Result { + if self.active_session().is_none() { + return Err(LogError::NoActiveSession); + } + + let mut new_timeline = self.timeline.clone(); + let last_idx = new_timeline.len() - 1; + new_timeline[last_idx] = new_session; + + Ok(Log::new(self.date, self.timezone, new_timeline)) + } + + /// Replace a session at `index` with `new_session`. The caller is + /// responsible for constructing a session that preserves the desired + /// invariants (e.g. via `Session::with_fields` to keep times intact). + pub fn update_session_at(&self, index: usize, new_session: Session) -> Result { + if index >= self.timeline.len() { + return Err(LogError::IndexOutOfRange { + index, + len: self.timeline.len(), + }); + } + let mut new_timeline = self.timeline.clone(); + new_timeline[index] = new_session; + Ok(Log::new(self.date, self.timezone, new_timeline)) + } + /// Check if all sessions in the log are closed (have end times) pub fn is_closed(&self) -> bool { self.timeline.iter().all(|session| session.end.is_some()) @@ -179,9 +217,13 @@ impl Log { .parse() .map_err(|e: String| anyhow::anyhow!("Invalid timezone '{}': {}", tz_str, e))?; - // Parse timeline sessions using Session's from_toml_table method + // Parse sessions - support both "session" (new) and "timeline" (old) keys let mut sessions = Vec::new(); - if let Some(timeline) = toml_value.get("timeline").and_then(|v| v.as_array()) { + let entries = toml_value + .get("session") + .or_else(|| toml_value.get("timeline")) + .and_then(|v| v.as_array()); + if let Some(timeline) = entries { for entry in timeline { if let Some(table) = entry.as_table() { sessions.push(Session::from_toml_table(table, date, timezone)?); @@ -200,7 +242,7 @@ impl Log { "# This is a Faff-format log file - see faffage.com for details.".to_string(), "# It has been generated but can be edited manually.".to_string(), "# Changes to rows starting with '#' will not be saved.".to_string(), - "version = \"1.1\"".to_string(), + "version = \"1.2\"".to_string(), ]; // Date with day of week comment @@ -224,7 +266,7 @@ impl Log { for session in &sorted_timeline { lines.push("".to_string()); - lines.push("[[timeline]]".to_string()); + lines.push("[[session]]".to_string()); Self::format_session_to_toml(&mut lines, session, trackers, &date_format); } @@ -243,30 +285,27 @@ impl Log { trackers: &HashMap, date_format: &str, ) { - // Intent ID - lines.push(format!("intent_id = \"{}\"", session.intent.intent_id)); - - // Alias - if let Some(alias) = &session.intent.alias { - lines.push(format!("alias = \"{alias}\"")); + // Title + if let Some(title) = &session.title { + lines.push(format!("title = \"{title}\"")); } - // Optional intent fields - if let Some(role) = &session.intent.role { + // Optional session fields + if let Some(role) = &session.role { lines.push(format!("role = \"{role}\"")); } - if let Some(objective) = &session.intent.objective { - lines.push(format!("objective = \"{objective}\"")); + if let Some(impact) = &session.impact { + lines.push(format!("impact = \"{impact}\"")); } - if let Some(action) = &session.intent.action { - lines.push(format!("action = \"{action}\"")); + if let Some(mode) = &session.mode { + lines.push(format!("mode = \"{mode}\"")); } - if let Some(subject) = &session.intent.subject { + if let Some(subject) = &session.subject { lines.push(format!("subject = \"{subject}\"")); } // Trackers - let tracker_list = &session.intent.trackers; + let tracker_list = &session.trackers; if !tracker_list.is_empty() { if tracker_list.len() == 1 { let tracker = &tracker_list[0]; @@ -430,30 +469,13 @@ impl Log { .to_string() } - /// Update all sessions in the log that use the given intent - /// - /// Returns a tuple of (updated_log, count) where count is the number of sessions updated - pub fn update_intent(&self, intent_id: &str, updated_intent: Intent) -> (Log, usize) { - let mut new_timeline = self.timeline.clone(); - let mut count = 0; - - for session in &mut new_timeline { - if session.intent.intent_id == intent_id { - session.intent = updated_intent.clone(); - count += 1; - } - } - - (Log::new(self.date, self.timezone, new_timeline), count) - } - /// Calculate summary statistics for this log /// /// Uses the provided `now` time for calculating duration of open sessions on today. /// For open sessions on past dates, caps at end-of-day (23:59). /// All durations are in minutes (faff's base unit). pub fn summary(&self, now: DateTime) -> LogSummary { - let mut by_intent: HashMap = HashMap::new(); + let mut by_title: HashMap = HashMap::new(); let mut by_tracker: HashMap = HashMap::new(); let mut by_tracker_source: HashMap = HashMap::new(); let mut total_minutes: i64 = 0; @@ -486,12 +508,12 @@ impl Log { total_minutes += duration_minutes; - // Aggregate by intent alias - let alias = session.intent.alias.clone().unwrap_or_default(); - *by_intent.entry(alias).or_insert(0) += duration_minutes; + // Aggregate by session title + let title = session.title.clone().unwrap_or_default(); + *by_title.entry(title).or_insert(0) += duration_minutes; // Aggregate by tracker and tracker source - for tracker in &session.intent.trackers { + for tracker in &session.trackers { *by_tracker.entry(tracker.clone()).or_insert(0) += duration_minutes; let source = tracker.split(':').next().unwrap_or("").to_string(); @@ -515,7 +537,7 @@ impl Log { LogSummary { total_minutes, - by_intent, + by_title, by_tracker, by_tracker_source, mean_reflection_score, @@ -526,17 +548,19 @@ impl Log { #[cfg(test)] mod tests { use super::*; - use crate::models::intent::Intent; use chrono::TimeZone; - fn sample_intent() -> Intent { - Intent::new( + fn sample_session(start: DateTime, end: Option>) -> Session { + Session::new( Some("work".to_string()), Some("engineer".to_string()), Some("development".to_string()), Some("coding".to_string()), Some("features".to_string()), vec![], + start, + end, + None, ) } @@ -558,12 +582,11 @@ mod tests { #[test] fn test_create_log_with_session() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = london_tz() .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = sample_session(start, Some(end)); let log = Log::new(sample_date(), london_tz(), vec![session.clone()]); @@ -579,12 +602,11 @@ mod tests { #[test] fn test_log_with_completed_session_has_no_active_session() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = london_tz() .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = sample_session(start, Some(end)); let log = Log::new(sample_date(), london_tz(), vec![session]); @@ -593,9 +615,8 @@ mod tests { #[test] fn test_log_with_open_session_returns_it() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 14, 0, 0).unwrap(); - let session = Session::new(intent.clone(), start, None, None); + let session = sample_session(start, None); let log = Log::new(sample_date(), london_tz(), vec![session.clone()]); @@ -606,15 +627,14 @@ mod tests { #[test] fn test_only_last_session_matters_for_active() { - let intent = sample_intent(); let start1 = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end1 = london_tz() .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session1 = Session::new(intent.clone(), start1, Some(end1), None); + let session1 = sample_session(start1, Some(end1)); let start2 = london_tz().with_ymd_and_hms(2025, 3, 15, 14, 0, 0).unwrap(); - let session2 = Session::new(intent, start2, None, None); + let session2 = sample_session(start2, None); let log = Log::new(sample_date(), london_tz(), vec![session1, session2.clone()]); @@ -624,12 +644,11 @@ mod tests { #[test] fn test_append_to_empty_log() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = london_tz() .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = sample_session(start, Some(end)); let log = Log::new(sample_date(), london_tz(), vec![]); let new_log = log.append_session(session.clone()).unwrap(); @@ -642,16 +661,15 @@ mod tests { #[test] fn test_append_to_log_with_completed_sessions() { - let intent = sample_intent(); let start1 = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end1 = london_tz() .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session1 = Session::new(intent.clone(), start1, Some(end1), None); + let session1 = sample_session(start1, Some(end1)); let start2 = london_tz().with_ymd_and_hms(2025, 3, 15, 11, 0, 0).unwrap(); let end2 = london_tz().with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap(); - let session2 = Session::new(intent, start2, Some(end2), None); + let session2 = sample_session(start2, Some(end2)); let log = Log::new(sample_date(), london_tz(), vec![session1]); let new_log = log.append_session(session2.clone()).unwrap(); @@ -662,13 +680,12 @@ mod tests { #[test] fn test_append_automatically_stops_active_session() { - let intent = sample_intent(); let start1 = london_tz().with_ymd_and_hms(2025, 3, 15, 14, 0, 0).unwrap(); - let open_session = Session::new(intent.clone(), start1, None, None); + let open_session = sample_session(start1, None); let start2 = london_tz().with_ymd_and_hms(2025, 3, 15, 15, 0, 0).unwrap(); let end2 = london_tz().with_ymd_and_hms(2025, 3, 15, 16, 0, 0).unwrap(); - let new_session = Session::new(intent, start2, Some(end2), None); + let new_session = sample_session(start2, Some(end2)); let log = Log::new(sample_date(), london_tz(), vec![open_session]); let new_log = log.append_session(new_session.clone()).unwrap(); @@ -681,9 +698,8 @@ mod tests { #[test] fn test_stop_active_session() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 14, 0, 0).unwrap(); - let open_session = Session::new(intent, start, None, None); + let open_session = sample_session(start, None); let log = Log::new(sample_date(), london_tz(), vec![open_session]); @@ -716,12 +732,11 @@ mod tests { #[test] fn test_log_with_completed_sessions_is_closed() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = london_tz() .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = sample_session(start, Some(end)); let log = Log::new(sample_date(), london_tz(), vec![session]); assert!(log.is_closed()); @@ -729,9 +744,8 @@ mod tests { #[test] fn test_log_with_open_session_is_not_closed() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 14, 0, 0).unwrap(); - let session = Session::new(intent, start, None, None); + let session = sample_session(start, None); let log = Log::new(sample_date(), london_tz(), vec![session]); assert!(!log.is_closed()); @@ -745,12 +759,11 @@ mod tests { #[test] fn test_single_completed_session() { - let intent = sample_intent(); let start = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = london_tz() .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = sample_session(start, Some(end)); let log = Log::new(sample_date(), london_tz(), vec![session]); let expected = Duration::hours(1) + Duration::minutes(30); @@ -759,17 +772,15 @@ mod tests { #[test] fn test_multiple_completed_sessions() { - let intent = sample_intent(); - let start1 = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end1 = london_tz().with_ymd_and_hms(2025, 3, 15, 10, 0, 0).unwrap(); - let session1 = Session::new(intent.clone(), start1, Some(end1), None); + let session1 = sample_session(start1, Some(end1)); let start2 = london_tz().with_ymd_and_hms(2025, 3, 15, 14, 0, 0).unwrap(); let end2 = london_tz() .with_ymd_and_hms(2025, 3, 15, 15, 30, 0) .unwrap(); - let session2 = Session::new(intent, start2, Some(end2), None); + let session2 = sample_session(start2, Some(end2)); let log = Log::new(sample_date(), london_tz(), vec![session1, session2]); @@ -779,11 +790,10 @@ mod tests { #[test] fn test_open_session_on_past_date_uses_end_of_day() { - let intent = sample_intent(); let past_date = NaiveDate::from_ymd_opt(2025, 3, 10).unwrap(); let start = london_tz().with_ymd_and_hms(2025, 3, 10, 14, 0, 0).unwrap(); - let open_session = Session::new(intent, start, None, None); + let open_session = sample_session(start, None); let log = Log::new(past_date, london_tz(), vec![open_session]); let total = log.total_recorded_time().unwrap(); @@ -800,7 +810,7 @@ mod tests { let output = log.to_log_file(&trackers); assert!(output.contains("# This is a Faff-format log file")); - assert!(output.contains("version = \"1.1\"")); + assert!(output.contains("version = \"1.2\"")); assert!(output.contains("date = \"2025-03-15\"")); assert!(output.contains("timezone = \"UTC\"")); assert!(output.contains("# Timeline is empty.")); @@ -808,23 +818,22 @@ mod tests { #[test] fn test_to_log_file_with_session() { - let intent = sample_intent(); let start = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 9, 0, 0) .unwrap(); let end = chrono_tz::UTC .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) .unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = sample_session(start, Some(end)); let log = Log::new(sample_date(), chrono_tz::UTC, vec![session]); let trackers = HashMap::new(); let output = log.to_log_file(&trackers); - assert!(output.contains("[[timeline]]")); - assert!(output.contains("alias = \"work\"")); - assert!(output.contains("start = \"09:00\"")); - assert!(output.contains("end = \"10:30\"")); + assert!(output.contains("[[session]]")); + assert!(output.contains("title = \"work\"")); + assert!(output.contains("start = \"09:00\"")); + assert!(output.contains("end = \"10:30\"")); assert!(output.contains("# duration = \"1 hour and 30 minutes\"")); } @@ -847,41 +856,44 @@ mod tests { #[test] fn test_summary() { - let intent1 = Intent::new( + let start1 = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); + let end1 = london_tz() + .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) + .unwrap(); // 90 mins + let session1 = Session::new( Some("coding".to_string()), None, None, None, None, vec!["element:123".to_string()], + start1, + Some(end1), + None, ); - let intent2 = Intent::new( + + let start2 = london_tz().with_ymd_and_hms(2025, 3, 15, 11, 0, 0).unwrap(); + let end2 = london_tz().with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap(); // 60 mins + let session2 = Session::new( Some("meeting".to_string()), None, None, None, None, vec!["element:456".to_string(), "jira:ABC-1".to_string()], + start2, + Some(end2), + None, ); - let start1 = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); - let end1 = london_tz() - .with_ymd_and_hms(2025, 3, 15, 10, 30, 0) - .unwrap(); // 90 mins - let session1 = Session::new(intent1, start1, Some(end1), None); - - let start2 = london_tz().with_ymd_and_hms(2025, 3, 15, 11, 0, 0).unwrap(); - let end2 = london_tz().with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap(); // 60 mins - let session2 = Session::new(intent2, start2, Some(end2), None); - let log = Log::new(sample_date(), london_tz(), vec![session1, session2]); let now = london_tz().with_ymd_and_hms(2025, 3, 15, 15, 0, 0).unwrap(); let summary = log.summary(now); assert_eq!(summary.total_minutes, 150); - assert_eq!(summary.by_intent.get("coding"), Some(&90)); - assert_eq!(summary.by_intent.get("meeting"), Some(&60)); + assert_eq!(summary.by_title.get("coding"), Some(&90)); + assert_eq!(summary.by_title.get("meeting"), Some(&60)); assert_eq!(summary.by_tracker.get("element:123"), Some(&90)); assert_eq!(summary.by_tracker.get("element:456"), Some(&60)); assert_eq!(summary.by_tracker.get("jira:ABC-1"), Some(&60)); @@ -892,20 +904,14 @@ mod tests { #[test] fn test_summary_with_reflection_scores() { - let mut session1 = Session::new( - sample_intent(), - london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(), - Some(london_tz().with_ymd_and_hms(2025, 3, 15, 10, 0, 0).unwrap()), // 60 mins - None, - ); + let start1 = london_tz().with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); + let end1 = london_tz().with_ymd_and_hms(2025, 3, 15, 10, 0, 0).unwrap(); // 60 mins + let mut session1 = sample_session(start1, Some(end1)); session1.reflection_score = Some(4); - let mut session2 = Session::new( - sample_intent(), - london_tz().with_ymd_and_hms(2025, 3, 15, 11, 0, 0).unwrap(), - Some(london_tz().with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap()), // 60 mins - None, - ); + let start2 = london_tz().with_ymd_and_hms(2025, 3, 15, 11, 0, 0).unwrap(); + let end2 = london_tz().with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap(); // 60 mins + let mut session2 = sample_session(start2, Some(end2)); session2.reflection_score = Some(2); let log = Log::new(sample_date(), london_tz(), vec![session1, session2]); @@ -919,12 +925,11 @@ mod tests { #[test] fn test_summary_open_session_on_past_date_caps_at_end_of_day() { - let intent = sample_intent(); let past_date = NaiveDate::from_ymd_opt(2025, 3, 10).unwrap(); // Open session starting at 17:00 on a past date let start = london_tz().with_ymd_and_hms(2025, 3, 10, 17, 0, 0).unwrap(); - let open_session = Session::new(intent, start, None, None); + let open_session = sample_session(start, None); let log = Log::new(past_date, london_tz(), vec![open_session]); diff --git a/core/src/models/mod.rs b/core/src/models/mod.rs index fbd552f..18e4bec 100644 --- a/core/src/models/mod.rs +++ b/core/src/models/mod.rs @@ -1,5 +1,4 @@ pub mod config; -pub mod intent; pub mod log; pub mod plan; pub mod remote; @@ -9,7 +8,6 @@ pub mod toy; pub mod valuetype; pub use config::Config; -pub use intent::Intent; pub use log::{Log, LogSummary}; pub use plan::Plan; pub use remote::{Remote, RemoteVocabulary}; diff --git a/core/src/models/plan.rs b/core/src/models/plan.rs index 032747a..d343e15 100644 --- a/core/src/models/plan.rs +++ b/core/src/models/plan.rs @@ -3,9 +3,34 @@ use serde::{Deserialize, Serialize}; use slug::slugify; use std::collections::HashMap; -use crate::models::intent::Intent; +/// A structured hint that guides session field suggestions and tracker auto-derivation +/// +/// Hints are stored in plans (generated by plugins) and injected into the +/// heuristic machinery at session-start time. A hint with a matching title +/// causes its fields to rank first in subsequent prompts; hints with field +/// values also participate in tracker auto-derivation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SessionHint { + /// Title associated with this hint (e.g. tracker name, project title) + pub title: String, + /// Suggested role (source-prefixed, e.g. "element:pre-sales-engineer") + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + /// Suggested impact (source-prefixed) + #[serde(skip_serializing_if = "Option::is_none")] + pub impact: Option, + /// Suggested mode (source-prefixed) + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// Suggested subject (source-prefixed) + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + /// Tracker IDs to associate with sessions matching this hint + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub trackers: Vec, +} -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Plan { pub source: String, pub valid_from: NaiveDate, @@ -13,16 +38,17 @@ pub struct Plan { pub valid_until: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub roles: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub objectives: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "actions")] + pub modes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "objectives")] + pub impacts: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subjects: Vec, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub trackers: HashMap, + /// Session hints generated by plugins for this plan's trackers #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub intents: Vec, + pub hints: Vec, } impl Plan { @@ -33,22 +59,21 @@ impl Plan { valid_from: NaiveDate, valid_until: Option, roles: Vec, - actions: Vec, - objectives: Vec, + modes: Vec, + impacts: Vec, subjects: Vec, trackers: HashMap, - intents: Vec, ) -> Self { Self { source, valid_from, valid_until, roles, - actions, - objectives, + modes, + impacts, subjects, trackers, - intents, + hints: Vec::new(), } } @@ -61,99 +86,12 @@ impl Plan { pub fn to_toml(&self) -> Result { toml::to_string(self) } - - /// Add an intent to the plan, deduplicating if it already exists - /// - /// If the intent doesn't have an ID, checks for an existing intent with matching ASTRO - /// properties and reuses its ID to maintain continuity. Only generates a new ID if no - /// matching intent exists. - pub fn add_intent(&self, mut intent: Intent) -> Plan { - let mut new_intents = self.intents.clone(); - - // Check if there's an existing intent with matching ASTRO properties (ignoring ID) - if let Some(existing) = new_intents.iter().find(|existing| { - existing.alias == intent.alias - && existing.role == intent.role - && existing.objective == intent.objective - && existing.action == intent.action - && existing.subject == intent.subject - && existing.trackers == intent.trackers - }) { - // Reuse the existing intent's ID to maintain continuity - intent.intent_id = existing.intent_id.clone(); - } else if intent.intent_id.is_empty() { - // Generate a new ID only if no matching intent exists - intent.intent_id = Intent::generate_intent_id(&self.source, self.valid_from); - } - - // Only add if not already present (by ID now) - if !new_intents.iter().any(|i| i.intent_id == intent.intent_id) { - new_intents.push(intent); - } - - Plan { - source: self.source.clone(), - valid_from: self.valid_from, - valid_until: self.valid_until, - roles: self.roles.clone(), - actions: self.actions.clone(), - objectives: self.objectives.clone(), - subjects: self.subjects.clone(), - trackers: self.trackers.clone(), - intents: new_intents, - } - } - - /// Update an existing intent in the plan - /// - /// Finds the intent by ID and replaces it with the updated version. - /// Returns an error if the intent is not found. - pub fn update_intent(&self, intent_id: &str, updated_intent: Intent) -> anyhow::Result { - let mut new_intents = self.intents.clone(); - - // Find the intent and update it - let found = new_intents.iter_mut().any(|intent| { - if intent.intent_id == intent_id { - *intent = updated_intent.clone(); - true - } else { - false - } - }); - - if !found { - anyhow::bail!("Intent with ID '{}' not found in plan", intent_id); - } - - Ok(Plan { - source: self.source.clone(), - valid_from: self.valid_from, - valid_until: self.valid_until, - roles: self.roles.clone(), - actions: self.actions.clone(), - objectives: self.objectives.clone(), - subjects: self.subjects.clone(), - trackers: self.trackers.clone(), - intents: new_intents, - }) - } } #[cfg(test)] mod tests { use super::*; - fn sample_intent() -> Intent { - Intent::new( - Some("work".to_string()), - Some("engineer".to_string()), - Some("development".to_string()), - Some("coding".to_string()), - Some("features".to_string()), - vec![], - ) - } - #[test] fn test_create_minimal_plan() { let plan = Plan::new( @@ -165,7 +103,6 @@ mod tests { vec![], vec![], HashMap::new(), - vec![], ); assert_eq!(plan.source, "local"); @@ -175,7 +112,6 @@ mod tests { ); assert_eq!(plan.valid_until, None); assert!(plan.roles.is_empty()); - assert!(plan.intents.is_empty()); } #[test] @@ -192,7 +128,6 @@ mod tests { vec!["development".to_string()], vec!["features".to_string()], trackers.clone(), - vec![], ); assert_eq!(plan.source, "https://example.com/plan"); @@ -215,7 +150,6 @@ mod tests { vec![], vec![], HashMap::new(), - vec![], ); assert_eq!(plan.id(), "local"); @@ -232,7 +166,6 @@ mod tests { vec![], vec![], HashMap::new(), - vec![], ); assert_eq!(plan.id(), "https-example-com-my-plan"); @@ -249,137 +182,11 @@ mod tests { vec![], vec![], HashMap::new(), - vec![], ); assert_eq!(plan.id(), "my-work-plan"); } - #[test] - fn test_add_intent_to_empty_plan() { - let plan = Plan::new( - "local".to_string(), - NaiveDate::from_ymd_opt(2025, 3, 20).unwrap(), - None, - vec![], - vec![], - vec![], - vec![], - HashMap::new(), - vec![], - ); - - let intent = sample_intent(); - let new_plan = plan.add_intent(intent.clone()); - - assert_eq!(new_plan.intents.len(), 1); - // The added intent should have the same ASTRO values but a generated ID - assert_eq!(new_plan.intents[0].alias, intent.alias); - assert_eq!(new_plan.intents[0].role, intent.role); - assert_eq!(new_plan.intents[0].objective, intent.objective); - assert_eq!(new_plan.intents[0].action, intent.action); - assert_eq!(new_plan.intents[0].subject, intent.subject); - assert!( - !new_plan.intents[0].intent_id.is_empty(), - "Intent should have a generated ID" - ); - assert!( - new_plan.intents[0].intent_id.starts_with("local:i-"), - "Intent ID should have correct prefix" - ); - // Original unchanged - assert_eq!(plan.intents.len(), 0); - } - - #[test] - fn test_add_intent_to_plan_with_intents() { - // Create intent1 with an ID so it can be compared - let intent1 = Intent::new_with_id( - Some(Intent::generate_intent_id( - "local", - NaiveDate::from_ymd_opt(2025, 3, 20).unwrap(), - )), - Some("work".to_string()), - Some("engineer".to_string()), - Some("development".to_string()), - Some("coding".to_string()), - Some("features".to_string()), - vec![], - ); - let plan = Plan::new( - "local".to_string(), - NaiveDate::from_ymd_opt(2025, 3, 20).unwrap(), - None, - vec![], - vec![], - vec![], - vec![], - HashMap::new(), - vec![intent1.clone()], - ); - - let intent2 = Intent::new( - Some("review".to_string()), - Some("manager".to_string()), - Some("quality".to_string()), - Some("reviewing".to_string()), - Some("code".to_string()), - vec![], - ); - - let new_plan = plan.add_intent(intent2.clone()); - - assert_eq!(new_plan.intents.len(), 2); - assert!(new_plan.intents.contains(&intent1)); - // Check that second intent exists with correct ASTRO values - let added_intent2 = new_plan - .intents - .iter() - .find(|i| i.alias.as_deref() == Some("review")) - .unwrap(); - assert_eq!(added_intent2.role, intent2.role); - assert_eq!(added_intent2.objective, intent2.objective); - assert_eq!(added_intent2.action, intent2.action); - assert_eq!(added_intent2.subject, intent2.subject); - assert!( - !added_intent2.intent_id.is_empty(), - "Intent should have a generated ID" - ); - } - - #[test] - fn test_add_duplicate_intent_deduplicates() { - // Create intent with an ID so deduplication can work - let intent = Intent::new_with_id( - Some(Intent::generate_intent_id( - "local", - NaiveDate::from_ymd_opt(2025, 3, 20).unwrap(), - )), - Some("work".to_string()), - Some("engineer".to_string()), - Some("development".to_string()), - Some("coding".to_string()), - Some("features".to_string()), - vec![], - ); - let plan = Plan::new( - "local".to_string(), - NaiveDate::from_ymd_opt(2025, 3, 20).unwrap(), - None, - vec![], - vec![], - vec![], - vec![], - HashMap::new(), - vec![intent.clone()], - ); - - let new_plan = plan.add_intent(intent); - - // Should still only have 1 intent (deduplicated) - assert_eq!(new_plan.intents.len(), 1); - } - #[test] fn test_plan_serialization() { let mut trackers = HashMap::new(); @@ -394,7 +201,6 @@ mod tests { vec!["development".to_string()], vec!["features".to_string()], trackers, - vec![sample_intent()], ); let toml_str = plan.to_toml().unwrap(); @@ -420,14 +226,6 @@ subjects = ["features"] [trackers] "ABC-123" = "Fix critical bug" - -[[intents]] -alias = "work" -role = "engineer" -objective = "development" -action = "coding" -subject = "features" -trackers = [] "#; let plan: Plan = toml::from_str(toml_str).unwrap(); @@ -446,7 +244,26 @@ trackers = [] plan.trackers.get("ABC-123"), Some(&"Fix critical bug".to_string()) ); - assert_eq!(plan.intents.len(), 1); + } + + #[test] + fn test_plan_deserialization_ignores_intents() { + // Old plan files with [[intents]] should silently be ignored + let toml_str = r#" +source = "local" +valid_from = "2025-03-20" +roles = ["engineer"] + +[[intents]] +alias = "work" +role = "engineer" +"#; + + let plan: Plan = toml::from_str(toml_str).unwrap(); + + assert_eq!(plan.source, "local"); + assert_eq!(plan.roles, vec!["engineer"]); + // intents are silently ignored } #[test] @@ -460,7 +277,6 @@ trackers = [] vec!["development".to_string()], vec!["features".to_string()], HashMap::new(), - vec![sample_intent()], ); let toml_str = original.to_toml().unwrap(); @@ -480,7 +296,6 @@ trackers = [] vec![], vec![], HashMap::new(), - vec![], ); assert_eq!( @@ -489,29 +304,6 @@ trackers = [] ); } - #[test] - fn test_plan_immutability() { - let plan = Plan::new( - "local".to_string(), - NaiveDate::from_ymd_opt(2025, 3, 20).unwrap(), - None, - vec![], - vec![], - vec![], - vec![], - HashMap::new(), - vec![], - ); - - let intent = sample_intent(); - let new_plan = plan.add_intent(intent); - - // Original plan should be unchanged - assert_eq!(plan.intents.len(), 0); - // New plan should have the intent - assert_eq!(new_plan.intents.len(), 1); - } - #[test] fn test_plan_empty_collections_omitted_in_toml() { let plan = Plan::new( @@ -523,17 +315,15 @@ trackers = [] vec![], vec![], HashMap::new(), - vec![], ); let toml_str = plan.to_toml().unwrap(); // Empty collections should be omitted assert!(!toml_str.contains("roles = []")); - assert!(!toml_str.contains("actions = []")); - assert!(!toml_str.contains("objectives = []")); + assert!(!toml_str.contains("modes = []")); + assert!(!toml_str.contains("impacts = []")); assert!(!toml_str.contains("subjects = []")); - assert!(!toml_str.contains("intents = []")); // But source and valid_from should be present assert!(toml_str.contains("source")); assert!(toml_str.contains("valid_from")); @@ -550,7 +340,6 @@ trackers = [] vec![], vec![], HashMap::new(), - vec![], ); let cloned = plan.clone(); @@ -576,7 +365,6 @@ trackers = [] vec![], vec![], trackers.clone(), - vec![], ); assert_eq!(plan.trackers.len(), 3); diff --git a/core/src/models/remote.rs b/core/src/models/remote.rs index ff87ecb..1f3a72e 100644 --- a/core/src/models/remote.rs +++ b/core/src/models/remote.rs @@ -1,9 +1,15 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::sync::LazyLock; use regex::{Captures, Regex}; use slug::slugify; +/// Compiled once; used in apply_template to find `{name}` / `{name|filter}` placeholders. +static PLACEHOLDER_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"\{([^}|]+)(\|[^}]+)?\}").expect("PLACEHOLDER_REGEX pattern is valid") +}); + /// Configuration for a remote plugin instance /// /// A Remote represents a configured instance of a plugin that can: @@ -41,10 +47,11 @@ pub struct Remote { pub enum VocabularyType { Tracker, Role, - Objective, - Action, + #[serde(alias = "objective")] + Impact, + #[serde(alias = "action")] + Mode, Subject, - Intent, } /// Configuration for mapping vocabulary from one type to another @@ -54,8 +61,7 @@ pub enum VocabularyType { /// /// Examples: /// - tracker → subject: Extract customer name from tracker -/// - subject → intent: Create standard intents for customer meetings -/// - tracker → intent: Most common case, direct tracker to intent mapping +/// - tracker → role: Derive role from tracker pattern #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct VocabularyMapping { /// Type of vocabulary to match against @@ -68,29 +74,30 @@ pub struct VocabularyMapping { /// Example: "^POC-(?P\\d+)\\s+(?P.+)$" pub pattern: String, - /// Template for intent alias (required if target_type is Intent) - /// Example: "POC-{id}: {description}" - #[serde(skip_serializing_if = "Option::is_none")] - pub alias: Option, - - /// Template for role (required if target_type is Role, optional for Intent) + /// Template for role (required if target_type is Role) #[serde(skip_serializing_if = "Option::is_none")] pub role: Option, - /// Template for objective (required if target_type is Objective, optional for Intent) - #[serde(skip_serializing_if = "Option::is_none")] - pub objective: Option, + /// Template for impact (required if target_type is Impact) + #[serde(skip_serializing_if = "Option::is_none", alias = "objective")] + pub impact: Option, - /// Template for action (required if target_type is Action, optional for Intent) - #[serde(skip_serializing_if = "Option::is_none")] - pub action: Option, + /// Template for mode (required if target_type is Mode) + #[serde(skip_serializing_if = "Option::is_none", alias = "action")] + pub mode: Option, - /// Template for subject (required if target_type is Subject, optional for Intent) + /// Template for subject (required if target_type is Subject) /// Supports filters: "customer/{customer|slugify}" #[serde(skip_serializing_if = "Option::is_none")] pub subject: Option, - /// Templates for trackers (optional for Intent) + /// Template for the hint title (used in session start suggestions) + /// Defaults to the raw source value (e.g. tracker description) if not set. + /// Example: "Support {customer}" instead of "Support - Acme Corp" + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Templates for trackers /// Example: ["{source_id}"] #[serde(skip_serializing_if = "Option::is_none")] pub trackers: Option>, @@ -107,11 +114,11 @@ impl VocabularyMapping { source_type, target_type, pattern: pattern.into(), - alias: None, role: None, - objective: None, - action: None, + impact: None, + mode: None, subject: None, + title: None, trackers: None, } } @@ -125,24 +132,19 @@ impl VocabularyMapping { /// Validate that required fields are present for the target type pub fn validate(&self) -> anyhow::Result<()> { match self.target_type { - VocabularyType::Intent => { - if self.alias.is_none() { - anyhow::bail!("Intent mapping requires 'alias' field"); - } - } VocabularyType::Role => { if self.role.is_none() { anyhow::bail!("Role mapping requires 'role' field"); } } - VocabularyType::Objective => { - if self.objective.is_none() { - anyhow::bail!("Objective mapping requires 'objective' field"); + VocabularyType::Impact => { + if self.impact.is_none() { + anyhow::bail!("Impact mapping requires 'impact' field"); } } - VocabularyType::Action => { - if self.action.is_none() { - anyhow::bail!("Action mapping requires 'action' field"); + VocabularyType::Mode => { + if self.mode.is_none() { + anyhow::bail!("Mode mapping requires 'mode' field"); } } VocabularyType::Subject => { @@ -170,23 +172,14 @@ impl VocabularyMapping { if let Some(captures) = regex.captures(source_value) { let mut result = MappingResult { target_type: self.target_type.clone(), - alias: None, role: None, - objective: None, - action: None, + impact: None, + mode: None, subject: None, trackers: None, }; // Apply templates to each field - if let Some(template) = &self.alias { - result.alias = Some(Self::apply_template( - template, - &captures, - source_value, - source_id, - )?); - } if let Some(template) = &self.role { result.role = Some(Self::apply_template( template, @@ -195,16 +188,16 @@ impl VocabularyMapping { source_id, )?); } - if let Some(template) = &self.objective { - result.objective = Some(Self::apply_template( + if let Some(template) = &self.impact { + result.impact = Some(Self::apply_template( template, &captures, source_value, source_id, )?); } - if let Some(template) = &self.action { - result.action = Some(Self::apply_template( + if let Some(template) = &self.mode { + result.mode = Some(Self::apply_template( template, &captures, source_value, @@ -246,20 +239,16 @@ impl VocabularyMapping { /// - {name|filter1|filter2} - chain multiple filters /// - {original} - the original source value /// - {source_id} - the source/remote ID - fn apply_template( + pub(super) fn apply_template( template: &str, captures: &Captures, original_value: &str, source_id: &str, ) -> anyhow::Result { - // Find all {xxx} or {xxx|filter} patterns - let placeholder_regex = - Regex::new(r"\{([^}|]+)(\|[^}]+)?\}").expect("Placeholder regex is valid"); - let mut result = String::new(); let mut last_end = 0; - for cap in placeholder_regex.captures_iter(template) { + for cap in PLACEHOLDER_REGEX.captures_iter(template) { let full_match = cap.get(0).unwrap(); let range = full_match.range(); @@ -317,23 +306,79 @@ impl VocabularyMapping { } } +/// A resolved tracker mapping entry for reverse lookup +/// +/// Maps specific session field values back to a tracker, enabling auto-derivation +/// of trackers from session fields at start time. +#[derive(Clone, Debug, PartialEq)] +pub struct TrackerMapping { + /// Prefixed tracker ID (e.g., "element:1231232") + pub tracker_id: String, + /// Human-readable tracker name (raw tracker description) + pub tracker_name: String, + /// Hint title to use in session suggestions (may differ from tracker_name + /// when a `title` template is configured on the vocabulary mapping) + pub hint_title: String, + /// Required role value for this mapping (if any) + pub role: Option, + /// Required impact value for this mapping (if any) + pub impact: Option, + /// Required mode value for this mapping (if any) + pub mode: Option, + /// Required subject value for this mapping (if any) + pub subject: Option, +} + +impl TrackerMapping { + /// Check if this mapping matches the given session field values + /// + /// Returns true if every non-None field on the mapping matches the corresponding + /// session value. None fields on the mapping are wildcards (match anything). + pub fn matches_session( + &self, + role: Option<&str>, + subject: Option<&str>, + impact: Option<&str>, + mode: Option<&str>, + ) -> bool { + if let Some(required) = &self.role { + if role != Some(required.as_str()) { + return false; + } + } + if let Some(required) = &self.subject { + if subject != Some(required.as_str()) { + return false; + } + } + if let Some(required) = &self.impact { + if impact != Some(required.as_str()) { + return false; + } + } + if let Some(required) = &self.mode { + if mode != Some(required.as_str()) { + return false; + } + } + true + } +} + /// Result of applying a vocabulary mapping #[derive(Clone, Debug, PartialEq)] pub struct MappingResult { /// Type of vocabulary that was generated pub target_type: VocabularyType, - /// Generated alias (for Intent targets) - pub alias: Option, - /// Generated role pub role: Option, - /// Generated objective - pub objective: Option, + /// Generated impact + pub impact: Option, - /// Generated action - pub action: Option, + /// Generated mode + pub mode: Option, /// Generated subject pub subject: Option, @@ -352,13 +397,13 @@ pub struct RemoteVocabulary { #[serde(default)] pub roles: Vec, - /// Objective identifiers (e.g., ["mycompany:feature-dev"]) - #[serde(default)] - pub objectives: Vec, + /// Impact identifiers (e.g., ["mycompany:feature-dev"]) + #[serde(default, alias = "objectives")] + pub impacts: Vec, - /// Action identifiers (e.g., ["mycompany:coding"]) - #[serde(default)] - pub actions: Vec, + /// Mode identifiers (e.g., ["mycompany:coding"]) + #[serde(default, alias = "actions")] + pub modes: Vec, /// Subject identifiers (e.g., ["mycompany:api"]) #[serde(default)] @@ -387,25 +432,136 @@ impl Remote { toml::to_string(self) } + /// Generate tracker mappings by running all tracker-source vocabulary mappings + /// + /// For each tracker in the plan, tries all vocabulary mappings where `source_type == Tracker`. + /// For each match, collects all non-None output fields (role, impact, mode, subject) into a + /// `TrackerMapping` entry. This builds a reverse lookup index from session field values to + /// tracker IDs, used for auto-deriving trackers at session start time. + pub fn generate_tracker_mappings( + &self, + plan: &crate::models::plan::Plan, + ) -> anyhow::Result> { + let mut mappings = Vec::new(); + + for mapping in &self.vocabulary_mappings { + if mapping.source_type != VocabularyType::Tracker { + continue; + } + + // Compile regex once per mapping, not once per tracker + let regex = mapping.regex()?; + + for (tracker_key, tracker_desc) in &plan.trackers { + let tracker_id = format!("{}:{}", plan.source, tracker_key); + + let Some(captures) = regex.captures(tracker_desc) else { + continue; + }; + + // Build result from captures (inline try_match logic to reuse captures) + let result = { + let mut r = MappingResult { + target_type: mapping.target_type.clone(), + role: None, + impact: None, + mode: None, + subject: None, + trackers: None, + }; + if let Some(t) = &mapping.role { + r.role = Some(VocabularyMapping::apply_template( + t, + &captures, + tracker_desc, + &tracker_id, + )?); + } + if let Some(t) = &mapping.impact { + r.impact = Some(VocabularyMapping::apply_template( + t, + &captures, + tracker_desc, + &tracker_id, + )?); + } + if let Some(t) = &mapping.mode { + r.mode = Some(VocabularyMapping::apply_template( + t, + &captures, + tracker_desc, + &tracker_id, + )?); + } + if let Some(t) = &mapping.subject { + r.subject = Some(VocabularyMapping::apply_template( + t, + &captures, + tracker_desc, + &tracker_id, + )?); + } + r + }; + + // Only create a TrackerMapping if there's at least one field constraint + if result.role.is_none() + && result.impact.is_none() + && result.mode.is_none() + && result.subject.is_none() + { + continue; + } + + let qualify = |v: String| -> String { + if v.contains(':') { + v + } else { + format!("{}:{}", plan.source, v) + } + }; + + // Reuse captures for hint title (no second regex.captures() call) + let hint_title = if let Some(title_template) = &mapping.title { + VocabularyMapping::apply_template( + title_template, + &captures, + tracker_desc, + &tracker_id, + )? + } else { + tracker_desc.clone() + }; + + mappings.push(TrackerMapping { + tracker_id, + tracker_name: tracker_desc.clone(), + hint_title, + role: result.role.map(&qualify), + impact: result.impact.map(&qualify), + mode: result.mode.map(&qualify), + subject: result.subject.map(&qualify), + }); + } + } + + Ok(mappings) + } + /// Apply vocabulary mappings to a plan, augmenting its vocabulary /// /// This method: /// - Iterates through configured vocabulary mappings /// - Matches source vocabulary (trackers, roles, etc.) against patterns - /// - Generates new vocabulary (intents, subjects, etc.) from matches + /// - Generates new vocabulary (subjects, roles, etc.) from matches /// - Returns an augmented plan with additional vocabulary /// /// The original plan vocabulary is preserved; mappings only add new items. - /// - /// If `previous_plan` is provided, intent IDs will be preserved for intents - /// that have matching ASTRO properties, maintaining continuity across updates. pub fn apply_vocabulary_mappings( &self, plan: &crate::models::plan::Plan, - previous_plan: Option<&crate::models::plan::Plan>, + _previous_plan: Option<&crate::models::plan::Plan>, ) -> anyhow::Result { - use crate::models::intent::Intent; - let mut augmented_plan = plan.clone(); // Validate all mappings first @@ -432,13 +588,13 @@ impl Remote { .map(|r| (format!("{}:{}", plan.source, r), r.clone())) .collect() } - VocabularyType::Objective => plan - .objectives + VocabularyType::Impact => plan + .impacts .iter() .map(|o| (format!("{}:{}", plan.source, o), o.clone())) .collect(), - VocabularyType::Action => plan - .actions + VocabularyType::Mode => plan + .modes .iter() .map(|a| (format!("{}:{}", plan.source, a), a.clone())) .collect(), @@ -447,74 +603,71 @@ impl Remote { .iter() .map(|s| (format!("{}:{}", plan.source, s), s.clone())) .collect(), - VocabularyType::Intent => { - // For intents, use the alias as the value to match against - // Filter out intents without an alias - plan.intents - .iter() - .filter_map(|i| { - i.alias - .as_ref() - .map(|alias| (i.intent_id.clone(), alias.clone())) - }) - .collect() - } }; // Try to match each source value for (source_id, source_value) in source_values { if let Some(result) = mapping.try_match(&source_value, &source_id)? { - // Generate new vocabulary based on target_type - match result.target_type { - VocabularyType::Intent => { - // Generate a new intent - let alias = result.alias.ok_or_else(|| { - anyhow::anyhow!("Intent mapping must produce an alias") - })?; - - let trackers = result.trackers.unwrap_or_default(); - - // Check if there's a matching intent in the previous plan to reuse its ID - let existing_id = previous_plan.and_then(|prev| { - prev.intents - .iter() - .find(|existing| { - existing.alias.as_deref() == Some(&alias) - && existing.role == result.role - && existing.objective == result.objective - && existing.action == result.action - && existing.subject == result.subject - && existing.trackers == trackers - }) - .map(|i| i.intent_id.clone()) - }); - - let intent = if let Some(id) = existing_id { - // Reuse existing ID to maintain continuity - Intent::new_with_id( - Some(id), - Some(alias), - result.role, - result.objective, - result.action, - result.subject, - trackers, - ) + // For tracker-source mappings with multi-field results, generate a + // SessionHint so the CLI can pre-weight field suggestions and + // auto-derive the tracker. Fields are source-prefixed to match + // the qualified values that session prompts use. + if mapping.source_type == VocabularyType::Tracker { + let qualify = |v: String| -> String { + if v.contains(':') { + v } else { - // Create new intent (ID will be generated by add_intent) - Intent::new( - Some(alias), - result.role, - result.objective, - result.action, - result.subject, - trackers, - ) - }; - - // Add to plan (using add_intent to handle ID generation and deduplication) - augmented_plan = augmented_plan.add_intent(intent); + format!("{}:{}", plan.source, v) + } + }; + let hint_role = result.role.clone().map(&qualify); + let hint_impact = result.impact.clone().map(&qualify); + let hint_mode = result.mode.clone().map(&qualify); + let hint_subject = result.subject.clone().map(&qualify); + + if hint_role.is_some() + || hint_impact.is_some() + || hint_mode.is_some() + || hint_subject.is_some() + { + if !augmented_plan + .hints + .iter() + .any(|h| h.trackers.contains(&source_id)) + { + // Use the title template if set, otherwise fall back to + // the raw tracker description. + let hint_title = if let Some(title_template) = &mapping.title { + let regex = mapping.regex()?; + regex + .captures(&source_value) + .map(|caps| { + VocabularyMapping::apply_template( + title_template, + &caps, + &source_value, + &source_id, + ) + }) + .transpose()? + .unwrap_or_else(|| source_value.clone()) + } else { + source_value.clone() + }; + augmented_plan.hints.push(crate::models::plan::SessionHint { + title: hint_title, + role: hint_role, + impact: hint_impact, + mode: hint_mode, + subject: hint_subject, + trackers: vec![source_id.clone()], + }); + } } + } + + // Generate new vocabulary based on target_type + match result.target_type { VocabularyType::Role => { let role = result.role.ok_or_else(|| { anyhow::anyhow!("Role mapping must produce a role") @@ -523,20 +676,20 @@ impl Remote { augmented_plan.roles.push(role); } } - VocabularyType::Objective => { - let objective = result.objective.ok_or_else(|| { - anyhow::anyhow!("Objective mapping must produce an objective") + VocabularyType::Impact => { + let impact = result.impact.ok_or_else(|| { + anyhow::anyhow!("Impact mapping must produce an impact") })?; - if !augmented_plan.objectives.contains(&objective) { - augmented_plan.objectives.push(objective); + if !augmented_plan.impacts.contains(&impact) { + augmented_plan.impacts.push(impact); } } - VocabularyType::Action => { - let action = result.action.ok_or_else(|| { - anyhow::anyhow!("Action mapping must produce an action") + VocabularyType::Mode => { + let mode = result.mode.ok_or_else(|| { + anyhow::anyhow!("Mode mapping must produce a mode") })?; - if !augmented_plan.actions.contains(&action) { - augmented_plan.actions.push(action); + if !augmented_plan.modes.contains(&mode) { + augmented_plan.modes.push(mode); } } VocabularyType::Subject => { @@ -606,7 +759,7 @@ mod tests { ); assert_eq!(remote.vocabulary.roles.len(), 2); assert_eq!(remote.vocabulary.roles[0], "mycompany:engineer"); - assert_eq!(remote.vocabulary.objectives.len(), 2); + assert_eq!(remote.vocabulary.impacts.len(), 2); } #[test] @@ -630,39 +783,14 @@ mod tests { assert_eq!(remote, parsed); } - #[test] - fn test_vocabulary_mapping_parse() { - let toml_str = r#" - id = "test" - plugin = "myhours" - - [[vocabulary_mapping]] - source_type = "tracker" - target_type = "intent" - pattern = "^POC-(?P\\d+)\\s+(?P.+)$" - alias = "POC-{id}: {description}" - role = "element.io:pre-sales-engineer" - objective = "element.io:new-revenue-new-business" - action = "element.io:support-poc" - "#; - - let remote = Remote::from_toml(toml_str).unwrap(); - assert_eq!(remote.vocabulary_mappings.len(), 1); - - let mapping = &remote.vocabulary_mappings[0]; - assert!(matches!(mapping.source_type, VocabularyType::Tracker)); - assert!(matches!(mapping.target_type, VocabularyType::Intent)); - assert_eq!(mapping.pattern, "^POC-(?P\\d+)\\s+(?P.+)$"); - assert_eq!(mapping.alias, Some("POC-{id}: {description}".to_string())); - } - #[test] fn test_template_substitution() { - let mapping = VocabularyMapping::new( + let mut mapping = VocabularyMapping::new( VocabularyType::Tracker, - VocabularyType::Intent, + VocabularyType::Subject, r"^POC-(?P\d+)\s+(?P.+)$", ); + mapping.subject = Some("poc/{description|slugify}".to_string()); let result = mapping.try_match("POC-123 Test customer", "456").unwrap(); assert!(result.is_some()); @@ -685,30 +813,26 @@ mod tests { } #[test] - fn test_apply_vocabulary_mapping_tracker_to_intent() { + fn test_apply_vocabulary_mapping_tracker_to_subject() { use crate::models::plan::Plan; use chrono::NaiveDate; - let mut remote = Remote::new("element", "myhours"); + let mut remote = Remote::new("test", "test"); let mut mapping = VocabularyMapping::new( VocabularyType::Tracker, - VocabularyType::Intent, - r"^POC-(?P\d+)\s+(?P.+)$", + VocabularyType::Subject, + r"^Customer:\s+(?P.+)$", ); - mapping.alias = Some("POC-{id}: {description}".to_string()); - mapping.role = Some("element.io:pre-sales-engineer".to_string()); - mapping.objective = Some("element.io:new-revenue-new-business".to_string()); - mapping.action = Some("element.io:support-poc".to_string()); + mapping.subject = Some("customer/{name|slugify}".to_string()); remote.vocabulary_mappings.push(mapping); - // Create a plan with a matching tracker let mut trackers = std::collections::HashMap::new(); - trackers.insert("123".to_string(), "POC-456 Acme Corporation".to_string()); + trackers.insert("1".to_string(), "Customer: Acme Corp".to_string()); let plan = Plan::new( - "element".to_string(), + "test".to_string(), NaiveDate::from_ymd_opt(2025, 11, 4).unwrap(), None, vec![], @@ -716,28 +840,17 @@ mod tests { vec![], vec![], trackers, - vec![], ); let augmented = remote.apply_vocabulary_mappings(&plan, None).unwrap(); - // Check that an intent was generated - assert_eq!(augmented.intents.len(), 1); - let intent = &augmented.intents[0]; - assert_eq!(intent.alias, Some("POC-456: Acme Corporation".to_string())); - assert_eq!( - intent.role, - Some("element.io:pre-sales-engineer".to_string()) - ); - assert_eq!( - intent.objective, - Some("element.io:new-revenue-new-business".to_string()) - ); - assert_eq!(intent.action, Some("element.io:support-poc".to_string())); + // Check that a subject was generated + assert_eq!(augmented.subjects.len(), 1); + assert_eq!(augmented.subjects[0], "customer/acme-corp"); } #[test] - fn test_apply_vocabulary_mapping_tracker_to_subject() { + fn test_vocabulary_mapping_no_match() { use crate::models::plan::Plan; use chrono::NaiveDate; @@ -746,14 +859,17 @@ mod tests { let mut mapping = VocabularyMapping::new( VocabularyType::Tracker, VocabularyType::Subject, - r"^Customer:\s+(?P.+)$", + r"^POC-(?P\d+).*$", ); - mapping.subject = Some("customer/{name|slugify}".to_string()); + mapping.subject = Some("poc/{id}".to_string()); remote.vocabulary_mappings.push(mapping); let mut trackers = std::collections::HashMap::new(); - trackers.insert("1".to_string(), "Customer: Acme Corp".to_string()); + trackers.insert( + "1".to_string(), + "Something completely different".to_string(), + ); let plan = Plan::new( "test".to_string(), @@ -764,40 +880,35 @@ mod tests { vec![], vec![], trackers, - vec![], ); let augmented = remote.apply_vocabulary_mappings(&plan, None).unwrap(); - // Check that a subject was generated - assert_eq!(augmented.subjects.len(), 1); - assert_eq!(augmented.subjects[0], "customer/acme-corp"); + // No subjects should be generated since pattern doesn't match + assert_eq!(augmented.subjects.len(), 0); } #[test] - fn test_vocabulary_mapping_no_match() { + fn test_apply_vocabulary_mapping_tracker_to_role() { use crate::models::plan::Plan; use chrono::NaiveDate; - let mut remote = Remote::new("test", "test"); + let mut remote = Remote::new("element", "myhours"); let mut mapping = VocabularyMapping::new( VocabularyType::Tracker, - VocabularyType::Intent, - r"^POC-(?P\d+).*$", + VocabularyType::Role, + r"^POC-(?P\d+)\s+(?P.+)$", ); - mapping.alias = Some("POC-{id}".to_string()); + mapping.role = Some("element.io:pre-sales-engineer".to_string()); remote.vocabulary_mappings.push(mapping); let mut trackers = std::collections::HashMap::new(); - trackers.insert( - "1".to_string(), - "Something completely different".to_string(), - ); + trackers.insert("123".to_string(), "POC-456 Acme Corporation".to_string()); let plan = Plan::new( - "test".to_string(), + "element".to_string(), NaiveDate::from_ymd_opt(2025, 11, 4).unwrap(), None, vec![], @@ -805,13 +916,13 @@ mod tests { vec![], vec![], trackers, - vec![], ); let augmented = remote.apply_vocabulary_mappings(&plan, None).unwrap(); - // No intents should be generated since pattern doesn't match - assert_eq!(augmented.intents.len(), 0); + // Check that a role was generated + assert_eq!(augmented.roles.len(), 1); + assert_eq!(augmented.roles[0], "element.io:pre-sales-engineer"); } #[test] @@ -819,18 +930,14 @@ mod tests { use crate::models::plan::Plan; use chrono::NaiveDate; - // Simulate the element.io remote configuration + // Simulate an element.io remote configuration mapping trackers to subjects let mut remote = Remote::new("element", "myhours"); let mut mapping = VocabularyMapping::new( VocabularyType::Tracker, - VocabularyType::Intent, + VocabularyType::Subject, r"^POC-(?P\d+)\s+(?P.+)$", ); - mapping.alias = Some("POC-{id}: {description}".to_string()); - mapping.role = Some("customer-success-manager".to_string()); - mapping.objective = Some("new-revenue-new-business".to_string()); - mapping.action = Some("drive-poc".to_string()); mapping.subject = Some("poc/{description|slugify}".to_string()); remote.vocabulary_mappings.push(mapping); @@ -857,129 +964,146 @@ mod tests { vec![], vec![], trackers, - vec![], ); let augmented = remote.apply_vocabulary_mappings(&plan, None).unwrap(); - // Should generate 3 intents from the 3 POC trackers - assert_eq!(augmented.intents.len(), 3); - - // Find the POC-29 intent - let poc29 = augmented - .intents - .iter() - .find(|i| { - i.alias - .as_ref() - .map(|a| a.contains("POC-29")) - .unwrap_or(false) - }) - .expect("Should find POC-29 intent"); - - assert_eq!( - poc29.alias, - Some("POC-29: European Commission - PoC".to_string()) - ); - assert_eq!(poc29.role, Some("customer-success-manager".to_string())); - assert_eq!( - poc29.objective, - Some("new-revenue-new-business".to_string()) - ); - assert_eq!(poc29.action, Some("drive-poc".to_string())); - assert_eq!( - poc29.subject, - Some("poc/european-commission-poc".to_string()) - ); + // Should generate 3 subjects from the 3 POC trackers + assert_eq!(augmented.subjects.len(), 3); - // Verify POC-62 - let poc62 = augmented - .intents - .iter() - .find(|i| { - i.alias - .as_ref() - .map(|a| a.contains("POC-62")) - .unwrap_or(false) - }) - .expect("Should find POC-62 intent"); - - assert_eq!(poc62.alias, Some("POC-62: Unicredit POC".to_string())); - assert_eq!(poc62.subject, Some("poc/unicredit-poc".to_string())); - - // Verify POC-66 - let poc66 = augmented - .intents - .iter() - .find(|i| { - i.alias - .as_ref() - .map(|a| a.contains("POC-66")) - .unwrap_or(false) - }) - .expect("Should find POC-66 intent"); - - assert_eq!(poc66.alias, Some("POC-66: EPPO".to_string())); - assert_eq!(poc66.subject, Some("poc/eppo".to_string())); + // Verify subjects were generated correctly + assert!(augmented + .subjects + .contains(&"poc/european-commission-poc".to_string())); + assert!(augmented + .subjects + .contains(&"poc/unicredit-poc".to_string())); + assert!(augmented.subjects.contains(&"poc/eppo".to_string())); // Verify that non-POC trackers are not converted - assert!(!augmented.intents.iter().any(|i| i - .alias - .as_ref() - .map(|a| a.contains("BIZ-")) - .unwrap_or(false))); + assert!(!augmented.subjects.iter().any(|s| s.contains("experiment"))); } #[test] - fn test_vocabulary_mapping_tracker_field() { + fn test_generate_tracker_mappings_multi_field() { use crate::models::plan::Plan; use chrono::NaiveDate; let mut remote = Remote::new("element", "myhours"); - // Create a mapping that includes the tracker field + // A multi-field mapping: tracker → subject, with extra role field let mut mapping = VocabularyMapping::new( VocabularyType::Tracker, - VocabularyType::Intent, - r"^Support - (?P.+)$", + VocabularyType::Subject, + r"^POC-(?P\d+)\s+(?P.+)$", ); - mapping.alias = Some("Support {customer}".to_string()); - mapping.role = Some("customer-success-manager".to_string()); - mapping.objective = Some("retain-customer".to_string()); - mapping.action = Some("support".to_string()); - mapping.subject = Some("customer/{customer|slugify}".to_string()); - mapping.trackers = Some(vec!["{source_id}".to_string()]); + mapping.subject = Some("poc/{description|slugify}".to_string()); + mapping.role = Some("element:pre-sales-engineer".to_string()); remote.vocabulary_mappings.push(mapping); - // Create a plan with a matching tracker (keys are unprefixed in plan files) let mut trackers = std::collections::HashMap::new(); - trackers.insert("12345".to_string(), "Support - Acme Corp".to_string()); + trackers.insert("1231232".to_string(), "POC-123 Acme Corp".to_string()); + trackers.insert("9999".to_string(), "Support: Other".to_string()); let plan = Plan::new( "element".to_string(), - NaiveDate::from_ymd_opt(2025, 11, 12).unwrap(), + NaiveDate::from_ymd_opt(2025, 11, 4).unwrap(), None, vec![], vec![], vec![], vec![], trackers, - vec![], ); - let augmented = remote.apply_vocabulary_mappings(&plan, None).unwrap(); + let mappings = remote.generate_tracker_mappings(&plan).unwrap(); + + // Only POC tracker matches + assert_eq!(mappings.len(), 1); + let m = &mappings[0]; + assert_eq!(m.tracker_id, "element:1231232"); + assert_eq!(m.tracker_name, "POC-123 Acme Corp"); + // Both fields are prefixed with plan.source ("element") to match session values + assert_eq!(m.role, Some("element:pre-sales-engineer".to_string())); + assert_eq!(m.subject, Some("element:poc/acme-corp".to_string())); + assert!(m.impact.is_none()); + assert!(m.mode.is_none()); + } + + #[test] + fn test_tracker_mapping_matches_session() { + let mapping = TrackerMapping { + tracker_id: "element:1231232".to_string(), + tracker_name: "POC-123 Acme Corp".to_string(), + hint_title: "POC-123 Acme Corp".to_string(), + role: Some("element:pre-sales-engineer".to_string()), + subject: Some("element:poc/acme-corp".to_string()), + impact: None, + mode: None, + }; + + // Exact match + assert!(mapping.matches_session( + Some("element:pre-sales-engineer"), + Some("element:poc/acme-corp"), + None, + None, + )); + + // Wrong role + assert!(!mapping.matches_session( + Some("element:engineer"), + Some("element:poc/acme-corp"), + None, + None, + )); + + // Wrong subject + assert!(!mapping.matches_session( + Some("element:pre-sales-engineer"), + Some("element:poc/other"), + None, + None, + )); + + // Role missing (session has no role) + assert!(!mapping.matches_session(None, Some("element:poc/acme-corp"), None, None)); + + // Impact/mode don't matter (they're None on the mapping) + assert!(mapping.matches_session( + Some("element:pre-sales-engineer"), + Some("element:poc/acme-corp"), + Some("element:revenue"), + Some("element:meeting"), + )); + } + + #[test] + fn test_generate_tracker_mappings_no_tracker_source() { + use crate::models::plan::Plan; + use chrono::NaiveDate; + + let mut remote = Remote::new("element", "myhours"); + + // Non-tracker source mapping — should not generate tracker mappings + let mut mapping = + VocabularyMapping::new(VocabularyType::Role, VocabularyType::Subject, r"^engineer$"); + mapping.subject = Some("engineering".to_string()); + remote.vocabulary_mappings.push(mapping); + + let plan = Plan::new( + "element".to_string(), + NaiveDate::from_ymd_opt(2025, 11, 4).unwrap(), + None, + vec![], + vec![], + vec![], + vec![], + std::collections::HashMap::new(), + ); - // Check that an intent was generated with the tracker - assert_eq!(augmented.intents.len(), 1); - let intent = &augmented.intents[0]; - assert_eq!(intent.alias, Some("Support Acme Corp".to_string())); - assert_eq!(intent.role, Some("customer-success-manager".to_string())); - assert_eq!(intent.objective, Some("retain-customer".to_string())); - assert_eq!(intent.action, Some("support".to_string())); - assert_eq!(intent.subject, Some("customer/acme-corp".to_string())); - - // Verify the tracker ID is included - assert_eq!(intent.trackers, vec!["element:12345"]); + let mappings = remote.generate_tracker_mappings(&plan).unwrap(); + assert!(mappings.is_empty()); } } diff --git a/core/src/models/session.rs b/core/src/models/session.rs index 1b7139f..fe178a0 100644 --- a/core/src/models/session.rs +++ b/core/src/models/session.rs @@ -1,8 +1,8 @@ +use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::HashMap; -use crate::models::intent::Intent; use crate::models::valuetype::ValueType; use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeZone}; @@ -72,6 +72,42 @@ where } } +/// Custom deserializer for trackers that handles both string and array formats +fn deserialize_trackers<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct TrackersVisitor; + + impl<'de> Visitor<'de> for TrackersVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, value: &str) -> Result, E> + where + E: de::Error, + { + Ok(vec![value.to_string()]) + } + + fn visit_seq(self, mut seq: A) -> Result, A::Error> + where + A: de::SeqAccess<'de>, + { + let mut trackers = Vec::new(); + while let Some(value) = seq.next_element()? { + trackers.push(value); + } + Ok(trackers) + } + } + + deserializer.deserialize_any(TrackersVisitor) +} + #[derive(Error, Debug)] pub enum SessionError { #[error("Cannot compute duration: session has no end time")] @@ -101,7 +137,16 @@ fn combine_date_time(date: NaiveDate, tz: Tz, time_str: &str) -> Result, + pub role: Option, + #[serde(alias = "objective")] + pub impact: Option, + #[serde(alias = "action")] + pub mode: Option, + pub subject: Option, + #[serde(default, deserialize_with = "deserialize_trackers")] + pub trackers: Vec, #[serde( serialize_with = "serialize_datetime", deserialize_with = "deserialize_datetime" @@ -122,14 +167,25 @@ pub struct Session { } impl Session { + #[allow(clippy::too_many_arguments)] pub fn new( - intent: Intent, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, start: DateTime, end: Option>, note: Option, ) -> Self { Self { - intent, + title, + role, + impact, + mode, + subject, + trackers, start, end, note, @@ -144,13 +200,25 @@ impl Session { date: chrono::NaiveDate, timezone: chrono_tz::Tz, ) -> Result { - let alias = dict.get("alias").and_then(|v| v.as_string()).cloned(); + let title = dict + .get("title") + .or_else(|| dict.get("alias")) + .and_then(|v| v.as_string()) + .cloned(); let role = dict.get("role").and_then(|v| v.as_string()).cloned(); - let objective = dict.get("objective").and_then(|v| v.as_string()).cloned(); + let impact = dict + .get("impact") + .or_else(|| dict.get("objective")) + .and_then(|v| v.as_string()) + .cloned(); - let action = dict.get("action").and_then(|v| v.as_string()).cloned(); + let mode = dict + .get("mode") + .or_else(|| dict.get("action")) + .and_then(|v| v.as_string()) + .cloned(); let subject = dict.get("subject").and_then(|v| v.as_string()).cloned(); @@ -168,8 +236,6 @@ impl Session { }) .unwrap_or_default(); - let intent: Intent = Intent::new(alias, role, objective, action, subject, trackers); - let start: String = dict .get("start") .and_then(|v| v.as_string()) @@ -195,7 +261,12 @@ impl Session { let reflection = dict.get("reflection").and_then(|v| v.as_string()).cloned(); Ok(Self { - intent, + title, + role, + impact, + mode, + subject, + trackers, start, end, note, @@ -211,6 +282,33 @@ impl Session { } } + /// Replace all semantic fields wholesale, preserving start/end and any + /// reflection. Used by `LogManager::update_active_session` so the + /// caller can hand in the desired final state of the session without + /// touching the time-bounded or post-hoc reflection bits. + #[allow(clippy::too_many_arguments)] + pub fn with_fields( + &self, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, + note: Option, + ) -> Self { + Self { + title, + role, + impact, + mode, + subject, + trackers, + note, + ..self.clone() + } + } + pub fn with_reflection(&self, score: Option, reflection: Option) -> Self { Self { reflection_score: score, @@ -253,8 +351,44 @@ impl Session { date: NaiveDate, timezone: Tz, ) -> anyhow::Result { - // Deserialize Intent fields - let intent: Intent = toml::from_str(&toml::to_string(table)?)?; + // Extract fields directly from the TOML table (try new names first, fall back to old) + let title = table + .get("title") + .or_else(|| table.get("alias")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let role = table + .get("role") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let impact = table + .get("impact") + .or_else(|| table.get("objective")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let mode = table + .get("mode") + .or_else(|| table.get("action")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let subject = table + .get("subject") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Handle trackers as either a string or array + let trackers = match table.get("trackers") { + Some(toml::Value::String(s)) => vec![s.clone()], + Some(toml::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(), + _ => vec![], + }; // Parse start/end times let start_str = table @@ -285,7 +419,12 @@ impl Session { .map(|s| s.to_string()); Ok(Session { - intent, + title, + role, + impact, + mode, + subject, + trackers, start, end, note, @@ -330,26 +469,39 @@ mod tests { use super::*; use chrono::{NaiveDate, Timelike}; - fn sample_intent() -> Intent { - Intent::new( + fn sample_session(start: DateTime) -> Session { + Session::new( Some("work".to_string()), Some("engineer".to_string()), Some("development".to_string()), - Some("coding".to_string()), + Some("coding".to_string()), // mode Some("features".to_string()), vec![], + start, + None, + None, ) } #[test] fn test_session_creation() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 30, 0).unwrap(); - let session = Session::new(intent.clone(), start, Some(end), None); + let session = Session::new( + Some("work".to_string()), + Some("engineer".to_string()), + Some("development".to_string()), + Some("coding".to_string()), + Some("features".to_string()), + vec![], + start, + Some(end), + None, + ); - assert_eq!(session.intent, intent); + assert_eq!(session.title, Some("work".to_string())); + assert_eq!(session.role, Some("engineer".to_string())); assert_eq!(session.start, start); assert_eq!(session.end, Some(end)); assert_eq!(session.note, None); @@ -357,21 +509,29 @@ mod tests { #[test] fn test_session_with_note() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); - let session = Session::new(intent, start, None, Some("Working on tests".to_string())); + let session = Session::new( + None, + None, + None, + None, + None, + vec![], + start, + None, + Some("Working on tests".to_string()), + ); assert_eq!(session.note, Some("Working on tests".to_string())); } #[test] fn test_duration_completed_session() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 30, 0).unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = Session::new(None, None, None, None, None, vec![], start, Some(end), None); let duration = session.duration().unwrap(); assert_eq!(duration, Duration::minutes(90)); @@ -379,10 +539,9 @@ mod tests { #[test] fn test_duration_open_session_error() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); - let session = Session::new(intent, start, None, None); + let session = Session::new(None, None, None, None, None, vec![], start, None, None); let result = session.duration(); assert!(matches!(result, Err(SessionError::MissingEnd))); @@ -390,11 +549,10 @@ mod tests { #[test] fn test_elapsed_open_session() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let now = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 30, 0).unwrap(); - let session = Session::new(intent, start, None, None); + let session = Session::new(None, None, None, None, None, vec![], start, None, None); let elapsed = session.elapsed(now); assert_eq!(elapsed, Duration::minutes(90)); @@ -403,22 +561,20 @@ mod tests { #[test] #[should_panic(expected = "elapsed() called on closed session")] fn test_elapsed_closed_session_panics() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 0, 0).unwrap(); let now = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 11, 0, 0).unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = Session::new(None, None, None, None, None, vec![], start, Some(end), None); session.elapsed(now); // should panic } #[test] fn test_duration_end_before_start_error() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 0, 0).unwrap(); let end = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); - let session = Session::new(intent, start, Some(end), None); + let session = Session::new(None, None, None, None, None, vec![], start, Some(end), None); let result = session.duration(); assert!(matches!(result, Err(SessionError::EndBeforeStart))); @@ -426,26 +582,24 @@ mod tests { #[test] fn test_with_end() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 30, 0).unwrap(); - let open_session = Session::new(intent.clone(), start, None, None); + let open_session = sample_session(start); assert_eq!(open_session.end, None); let closed_session = open_session.with_end(end); assert_eq!(closed_session.end, Some(end)); - assert_eq!(closed_session.intent, intent); + assert_eq!(closed_session.title, Some("work".to_string())); assert_eq!(closed_session.start, start); } #[test] fn test_with_end_immutability() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 30, 0).unwrap(); - let open_session = Session::new(intent, start, None, None); + let open_session = sample_session(start); let _closed_session = open_session.with_end(end); // Original should be unchanged @@ -507,10 +661,7 @@ mod tests { "role".to_string(), ValueType::String("engineer".to_string()), ); - dict.insert( - "action".to_string(), - ValueType::String("coding".to_string()), - ); + dict.insert("mode".to_string(), ValueType::String("coding".to_string())); dict.insert( "subject".to_string(), ValueType::String("tests".to_string()), @@ -523,9 +674,9 @@ mod tests { let session = Session::from_dict_with_tz(dict, date, tz).unwrap(); - assert_eq!(session.intent.role, Some("engineer".to_string())); - assert_eq!(session.intent.action, Some("coding".to_string())); - assert_eq!(session.intent.subject, Some("tests".to_string())); + assert_eq!(session.role, Some("engineer".to_string())); + assert_eq!(session.mode, Some("coding".to_string())); + assert_eq!(session.subject, Some("tests".to_string())); assert_eq!(session.start.hour(), 9); assert_eq!(session.end.unwrap().hour(), 10); assert_eq!(session.end.unwrap().minute(), 30); @@ -592,7 +743,7 @@ mod tests { let session = Session::from_dict_with_tz(dict, date, tz).unwrap(); - assert_eq!(session.intent.trackers, vec!["work:admin".to_string()]); + assert_eq!(session.trackers, vec!["work:admin".to_string()]); } #[test] @@ -609,32 +760,57 @@ mod tests { let session = Session::from_dict_with_tz(dict, date, tz).unwrap(); - assert_eq!(session.intent.trackers.len(), 2); - assert!(session.intent.trackers.contains(&"work:admin".to_string())); - assert!(session - .intent - .trackers - .contains(&"personal:study".to_string())); + assert_eq!(session.trackers.len(), 2); + assert!(session.trackers.contains(&"work:admin".to_string())); + assert!(session.trackers.contains(&"personal:study".to_string())); } #[test] fn test_session_equality() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); let end = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 10, 0, 0).unwrap(); - let session1 = Session::new(intent.clone(), start, Some(end), None); - let session2 = Session::new(intent, start, Some(end), None); + let session1 = Session::new( + Some("work".to_string()), + None, + None, + None, + None, + vec![], + start, + Some(end), + None, + ); + let session2 = Session::new( + Some("work".to_string()), + None, + None, + None, + None, + vec![], + start, + Some(end), + None, + ); assert_eq!(session1, session2); } #[test] fn test_session_clone() { - let intent = sample_intent(); let start = Tz::UTC.with_ymd_and_hms(2025, 3, 15, 9, 0, 0).unwrap(); - let session1 = Session::new(intent, start, None, Some("note".to_string())); + let session1 = Session::new( + None, + None, + None, + None, + None, + vec![], + start, + None, + Some("note".to_string()), + ); let session2 = session1.clone(); assert_eq!(session1, session2); diff --git a/core/src/plugins/models/intent.rs b/core/src/plugins/models/intent.rs deleted file mode 100644 index b91698b..0000000 --- a/core/src/plugins/models/intent.rs +++ /dev/null @@ -1,157 +0,0 @@ -use crate::models::intent::Intent as RustIntent; -use pyo3::prelude::*; -use pyo3::types::PyAny; -use pyo3::types::{PyDict, PyType}; - -/// Arguments tuple for Intent pickle serialization (__reduce__) -/// Contains: (alias, role, objective, action, subject, tags, note) -type PickleArgs = ( - Option, - Option, - Option, - Option, - Option, - Vec, - Option, -); - -#[pyclass(name = "Intent")] -#[derive(Clone)] -pub struct PyIntent { - pub inner: RustIntent, -} - -pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - Ok(()) -} - -// Helper function for creating intents from dicts (used by Plan and Session bindings) -pub(crate) fn intent_from_dict_internal(dict: &Bound<'_, PyDict>) -> PyResult { - let inner: RustIntent = pythonize::depythonize(dict.as_any()) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - Ok(PyIntent { inner }) -} - -#[pymethods] -impl PyIntent { - #[new] - #[pyo3(signature = (alias=None, role=None, objective=None, action=None, subject=None, trackers=vec![], intent_id=None))] - pub fn new( - alias: Option, - role: Option, - objective: Option, - action: Option, - subject: Option, - trackers: Vec, - intent_id: Option, - ) -> Self { - Self { - inner: RustIntent::new_with_id( - intent_id, alias, role, objective, action, subject, trackers, - ), - } - } - - #[getter] - fn intent_id(&self) -> String { - self.inner.intent_id.clone() - } - - #[getter] - fn alias(&self) -> Option { - self.inner.alias.clone() - } - - #[getter] - fn role(&self) -> Option { - self.inner.role.clone() - } - - #[getter] - fn objective(&self) -> Option { - self.inner.objective.clone() - } - - #[getter] - fn action(&self) -> Option { - self.inner.action.clone() - } - - #[getter] - fn subject(&self) -> Option { - self.inner.subject.clone() - } - - #[getter] - fn trackers(&self) -> Vec { - self.inner.trackers.clone() - } - - #[classmethod] - fn from_dict(_cls: &Bound<'_, PyType>, dict: &Bound<'_, PyAny>) -> PyResult { - let py_dict = dict.downcast::()?; - intent_from_dict_internal(py_dict) - } - - fn __hash__(&self) -> u64 { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - self.inner.hash(&mut hasher); - hasher.finish() - } - - fn __eq__(&self, other: PyRef) -> PyResult { - Ok(self.inner == other.inner) - } - - fn __ne__(&self, other: PyRef) -> PyResult { - self.__eq__(other).map(|eq| !eq) - } - - fn as_dict(&self) -> PyResult> { - Python::attach(|py| { - let py_obj = pythonize::pythonize(py, &self.inner) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - Ok(py_obj.downcast::()?.clone().unbind()) - }) - } - - fn __getstate__(&self) -> PyResult> { - self.as_dict() - } - - fn __repr__(&self) -> PyResult { - Ok(format!( - "Intent(intent_id={:?}, alias={:?}, role={:?}, objective={:?}, action={:?}, subject={:?}, trackers={:?})", - self.inner.intent_id, - self.inner.alias, - self.inner.role, - self.inner.objective, - self.inner.action, - self.inner.subject, - self.inner.trackers, - )) - } - - fn __str__(&self) -> PyResult { - self.__repr__() - } - - fn __reduce__(&self, py: Python) -> PyResult<(Py, PickleArgs)> { - let intent_type = py.get_type::(); - Ok(( - intent_type.into(), - ( - self.inner.alias.clone(), - self.inner.role.clone(), - self.inner.objective.clone(), - self.inner.action.clone(), - self.inner.subject.clone(), - self.inner.trackers.clone(), - Some(self.inner.intent_id.clone()), - ), - )) - } -} diff --git a/core/src/plugins/models/log.rs b/core/src/plugins/models/log.rs index 86cc84e..ce6ddc1 100644 --- a/core/src/plugins/models/log.rs +++ b/core/src/plugins/models/log.rs @@ -211,7 +211,7 @@ impl PyLog { let result = PyDict::new(py); result.set_item("total_minutes", summary.total_minutes)?; - result.set_item("by_intent", summary.by_intent.into_py_dict(py)?)?; + result.set_item("by_title", summary.by_title.into_py_dict(py)?)?; result.set_item("by_tracker", summary.by_tracker.into_py_dict(py)?)?; result.set_item( "by_tracker_source", diff --git a/core/src/plugins/models/mod.rs b/core/src/plugins/models/mod.rs index 7f09cbc..ba428ae 100644 --- a/core/src/plugins/models/mod.rs +++ b/core/src/plugins/models/mod.rs @@ -1,5 +1,4 @@ pub mod config; -pub mod intent; pub mod log; pub mod plan; pub mod session; diff --git a/core/src/plugins/models/plan.rs b/core/src/plugins/models/plan.rs index 007b91a..ace6f2c 100644 --- a/core/src/plugins/models/plan.rs +++ b/core/src/plugins/models/plan.rs @@ -1,11 +1,10 @@ use chrono::{Datelike, NaiveDate}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::{PyDate, PyDict, PyList, PyType}; +use pyo3::types::{PyDate, PyDict, PyType}; use std::collections::HashMap; -use crate::models::plan::Plan as RustPlan; -use crate::plugins::models::intent::PyIntent; +use crate::models::plan::{Plan as RustPlan, SessionHint}; #[pyclass(name = "Plan")] #[derive(Clone)] @@ -21,7 +20,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { #[pymethods] impl PyPlan { #[new] - #[pyo3(signature = (source, valid_from, valid_until=None, roles=vec![], actions=vec![], objectives=vec![], subjects=vec![], trackers=None, intents=vec![]))] + #[pyo3(signature = (source, valid_from, valid_until=None, roles=vec![], modes=vec![], impacts=vec![], subjects=vec![], trackers=None, actions=vec![], objectives=vec![]))] /// Python constructor mirrors struct fields, so many arguments are unavoidable #[allow(clippy::too_many_arguments)] fn py_new( @@ -29,11 +28,12 @@ impl PyPlan { valid_from: Bound<'_, PyDate>, valid_until: Option>, roles: Vec, - actions: Vec, - objectives: Vec, + modes: Vec, + impacts: Vec, subjects: Vec, trackers: Option>, - intents: Vec, + actions: Vec, + objectives: Vec, ) -> PyResult { // Convert Python dates to NaiveDate let valid_from_str: String = valid_from.call_method0("isoformat")?.extract()?; @@ -50,7 +50,11 @@ impl PyPlan { None }; - let rust_intents: Vec<_> = intents.into_iter().map(|i| i.inner).collect(); + // Support both old names (actions/objectives) and new names (modes/impacts) + let mut merged_modes = modes; + merged_modes.extend(actions); + let mut merged_impacts = impacts; + merged_impacts.extend(objectives); Ok(Self { inner: RustPlan::new( @@ -58,11 +62,10 @@ impl PyPlan { valid_from_date, valid_until_date, roles, - actions, - objectives, + merged_modes, + merged_impacts, subjects, trackers.unwrap_or_default(), - rust_intents, ), }) } @@ -102,13 +105,13 @@ impl PyPlan { } #[getter] - fn actions(&self) -> Vec { - self.inner.actions.clone() + fn modes(&self) -> Vec { + self.inner.modes.clone() } #[getter] - fn objectives(&self) -> Vec { - self.inner.objectives.clone() + fn impacts(&self) -> Vec { + self.inner.impacts.clone() } #[getter] @@ -122,12 +125,36 @@ impl PyPlan { } #[getter] - fn intents(&self) -> Vec { - self.inner - .intents + fn hints<'py>(&self, py: Python<'py>) -> PyResult> { + let list = pyo3::types::PyList::empty(py); + for hint in &self.inner.hints { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("title", &hint.title)?; + dict.set_item("role", hint.role.as_deref().into_pyobject(py)?)?; + dict.set_item("subject", hint.subject.as_deref().into_pyobject(py)?)?; + dict.set_item("impact", hint.impact.as_deref().into_pyobject(py)?)?; + dict.set_item("mode", hint.mode.as_deref().into_pyobject(py)?)?; + dict.set_item("trackers", pyo3::types::PyList::new(py, &hint.trackers)?)?; + list.append(dict)?; + } + Ok(list) + } + + /// Return a new Plan with the given hints attached. + /// + /// Each hint is a dict with keys: title (str), role, subject, impact, mode + /// (all Optional[str]), trackers (list[str]). + fn with_hints(&self, py: Python, hints: Vec>) -> PyResult { + let parsed: Vec = hints .iter() - .map(|i| PyIntent { inner: i.clone() }) - .collect() + .map(|h| { + pythonize::depythonize(h.bind(py)) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + }) + .collect::>()?; + let mut new_inner = self.inner.clone(); + new_inner.hints = parsed; + Ok(PyPlan { inner: new_inner }) } #[classmethod] @@ -158,15 +185,31 @@ impl PyPlan { .and_then(|item| item.extract().ok()) .unwrap_or_default(); - let actions: Vec = data - .get_item("actions")? + let modes: Vec = data + .get_item("modes") + .ok() + .flatten() .and_then(|item| item.extract().ok()) - .unwrap_or_default(); - - let objectives: Vec = data - .get_item("objectives")? + .unwrap_or_else(|| { + data.get_item("actions") + .ok() + .flatten() + .and_then(|item| item.extract().ok()) + .unwrap_or_default() + }); + + let impacts: Vec = data + .get_item("impacts") + .ok() + .flatten() .and_then(|item| item.extract().ok()) - .unwrap_or_default(); + .unwrap_or_else(|| { + data.get_item("objectives") + .ok() + .flatten() + .and_then(|item| item.extract().ok()) + .unwrap_or_default() + }); let subjects: Vec = data .get_item("subjects")? @@ -178,33 +221,16 @@ impl PyPlan { .and_then(|item| item.extract().ok()) .unwrap_or_default(); - // Extract intents - let intents = match data.get_item("intents")? { - Some(intents_item) => { - let intents_list = intents_item.downcast::()?; - let mut rust_intents = Vec::new(); - for item in intents_list.iter() { - let intent_dict = item.downcast::()?; - let py_intent = - crate::plugins::models::intent::intent_from_dict_internal(intent_dict)?; - rust_intents.push(py_intent.inner); - } - rust_intents - } - None => vec![], - }; - Ok(Self { inner: RustPlan::new( source, valid_from, valid_until, roles, - actions, - objectives, + modes, + impacts, subjects, trackers, - intents, ), }) } @@ -213,12 +239,6 @@ impl PyPlan { self.inner.id() } - fn add_intent(&self, intent: PyIntent) -> PyPlan { - PyPlan { - inner: self.inner.add_intent(intent.inner), - } - } - fn to_toml(&self) -> PyResult { self.inner .to_toml() @@ -231,10 +251,8 @@ impl PyPlan { fn __repr__(&self) -> PyResult { Ok(format!( - "Plan(source={:?}, valid_from={}, intents=[{} intents])", - self.inner.source, - self.inner.valid_from, - self.inner.intents.len() + "Plan(source={:?}, valid_from={})", + self.inner.source, self.inner.valid_from, )) } diff --git a/core/src/plugins/models/session.rs b/core/src/plugins/models/session.rs index 573e45e..903da61 100644 --- a/core/src/plugins/models/session.rs +++ b/core/src/plugins/models/session.rs @@ -1,7 +1,6 @@ use crate::models::session::SessionError; use crate::models::valuetype::ValueType; use crate::models::Session as RustSession; -use crate::plugins::models::intent::PyIntent; use chrono::NaiveDate; use chrono_tz::Tz; use pyo3::exceptions::PyValueError; @@ -30,63 +29,7 @@ pub(crate) fn session_from_dict_internal( date: NaiveDate, tz: Tz, ) -> PyResult { - // Check if there's a nested intent dict (from saved JSON format) - if let Some(intent_item) = dict.get_item("intent")? { - if let Ok(intent_dict) = intent_item.downcast::() { - // Parse the intent first - let py_intent = crate::plugins::models::intent::intent_from_dict_internal(intent_dict)?; - - // Extract start/end/note from the session dict - // Parse RFC3339 datetime (includes offset) and convert to semantic timezone - let start_str: String = dict.get_item("start")?.unwrap().extract()?; - let start = chrono::DateTime::parse_from_rfc3339(&start_str) - .map_err(|e| PyValueError::new_err(format!("Invalid start datetime: {e}")))? - .with_timezone(&tz); - - let end = dict - .get_item("end")? - .and_then(|v| if v.is_none() { None } else { Some(v) }) - .map(|v| v.extract::()) - .transpose()? - .map(|s| { - chrono::DateTime::parse_from_rfc3339(&s) - .map(|dt| dt.with_timezone(&tz)) - .map_err(|e| PyValueError::new_err(format!("Invalid end datetime: {e}"))) - }) - .transpose()?; - - let note = dict - .get_item("note")? - .and_then(|v| if v.is_none() { None } else { Some(v) }) - .map(|v| v.extract::()) - .transpose()?; - - let reflection_score = dict - .get_item("reflection_score")? - .and_then(|v| if v.is_none() { None } else { Some(v) }) - .map(|v| v.extract::()) - .transpose()?; - - let reflection = dict - .get_item("reflection")? - .and_then(|v| if v.is_none() { None } else { Some(v) }) - .map(|v| v.extract::()) - .transpose()?; - - return Ok(PySession { - inner: RustSession { - intent: py_intent.inner, - start, - end, - note, - reflection_score, - reflection, - }, - }); - } - } - - // Otherwise, use the flat format (for backwards compatibility) + // Use the flat format (new format without nested intent dict) let mut data = HashMap::new(); for (k, v) in dict.iter() { @@ -110,10 +53,16 @@ pub(crate) fn session_from_dict_internal( #[pymethods] impl PySession { #[new] - #[pyo3(signature = (intent, start, end=None, note=None))] + #[pyo3(signature = (start, title=None, role=None, impact=None, mode=None, subject=None, trackers=vec![], end=None, note=None))] + #[allow(clippy::too_many_arguments)] fn py_new<'py>( - intent: PyIntent, start: Bound<'py, PyDateTime>, + title: Option, + role: Option, + impact: Option, + mode: Option, + subject: Option, + trackers: Vec, end: Option>, note: Option, ) -> PyResult { @@ -123,22 +72,33 @@ impl PySession { None => None, }; Ok(Self { - inner: RustSession::new(intent.inner, start, end, note), + inner: RustSession::new( + title, role, impact, mode, subject, trackers, start, end, note, + ), }) } fn __getstate__(&self, py: Python<'_>) -> PyResult> { let dict = PyDict::new(py); - dict.set_item( - "intent", - Py::new( - py, - PyIntent { - inner: self.inner.intent.clone(), - }, - )?, - )?; + if let Some(title) = &self.inner.title { + dict.set_item("title", title)?; + } + if let Some(role) = &self.inner.role { + dict.set_item("role", role)?; + } + if let Some(impact) = &self.inner.impact { + dict.set_item("impact", impact)?; + } + if let Some(mode) = &self.inner.mode { + dict.set_item("mode", mode)?; + } + if let Some(subject) = &self.inner.subject { + dict.set_item("subject", subject)?; + } + if !self.inner.trackers.is_empty() { + dict.set_item("trackers", self.inner.trackers.clone())?; + } dict.set_item("start", self.inner.start.to_rfc3339())?; if let Some(end) = &self.inner.end { dict.set_item("end", end.to_rfc3339())?; @@ -157,10 +117,33 @@ impl PySession { } #[getter] - fn intent(&self) -> PyIntent { - PyIntent { - inner: self.inner.intent.clone(), - } + fn title(&self) -> Option { + self.inner.title.clone() + } + + #[getter] + fn role(&self) -> Option { + self.inner.role.clone() + } + + #[getter] + fn impact(&self) -> Option { + self.inner.impact.clone() + } + + #[getter] + fn mode(&self) -> Option { + self.inner.mode.clone() + } + + #[getter] + fn subject(&self) -> Option { + self.inner.subject.clone() + } + + #[getter] + fn trackers(&self) -> Vec { + self.inner.trackers.clone() } #[getter] @@ -299,13 +282,24 @@ impl PySession { Python::attach(|py| { let d = PyDict::new(py); - let intent = &self.inner.intent; - d.set_item( - "intent", - PyIntent { - inner: intent.clone(), - }, - )?; + if let Some(title) = &self.inner.title { + d.set_item("title", title)?; + } + if let Some(role) = &self.inner.role { + d.set_item("role", role)?; + } + if let Some(impact) = &self.inner.impact { + d.set_item("impact", impact)?; + } + if let Some(mode) = &self.inner.mode { + d.set_item("mode", mode)?; + } + if let Some(subject) = &self.inner.subject { + d.set_item("subject", subject)?; + } + if !self.inner.trackers.is_empty() { + d.set_item("trackers", self.inner.trackers.clone())?; + } let start = &self.inner.start; d.set_item("start", type_mapping::datetime_rust_to_py(py, start)?)?; @@ -328,8 +322,8 @@ impl PySession { fn __repr__(&self) -> PyResult { Ok(format!( - "Session(intent={:?}, start={:?}, end={:?}, note={:?})", - self.inner.intent, self.inner.start, self.inner.end, self.inner.note, + "Session(title={:?}, role={:?}, start={:?}, end={:?}, note={:?})", + self.inner.title, self.inner.role, self.inner.start, self.inner.end, self.inner.note, )) } diff --git a/core/src/storage/traits.rs b/core/src/storage/traits.rs index 7c34a3b..9eab31a 100644 --- a/core/src/storage/traits.rs +++ b/core/src/storage/traits.rs @@ -100,10 +100,6 @@ pub trait Storage: Send + Sync { self.base_dir().join("plugins") } - fn intents_dir(&self) -> PathBuf { - self.base_dir().join("intents") - } - fn config_file(&self) -> PathBuf { self.base_dir().join("config.toml") } @@ -185,7 +181,6 @@ pub trait Storage: Send + Sync { self.create_dir_all(&self.timesheet_dir()).await?; self.create_dir_all(&self.remotes_dir()).await?; self.create_dir_all(&self.identity_dir()).await?; - self.create_dir_all(&self.intents_dir()).await?; self.create_dir_all(&self.plugins_dir()).await?; self.create_dir_all(&self.plugin_state_dir()).await?; @@ -253,10 +248,6 @@ pub trait Storage { self.base_dir().join("plugins") } - fn intents_dir(&self) -> PathBuf { - self.base_dir().join("intents") - } - fn config_file(&self) -> PathBuf { self.base_dir().join("config.toml") } @@ -294,7 +285,6 @@ pub trait Storage { self.create_dir_all(&self.timesheet_dir()).await?; self.create_dir_all(&self.remotes_dir()).await?; self.create_dir_all(&self.identity_dir()).await?; - self.create_dir_all(&self.intents_dir()).await?; self.create_dir_all(&self.plugins_dir()).await?; self.create_dir_all(&self.plugin_state_dir()).await?; diff --git a/core/src/utils/query.rs b/core/src/utils/query.rs index c573dcb..c6a5d17 100644 --- a/core/src/utils/query.rs +++ b/core/src/utils/query.rs @@ -36,10 +36,10 @@ impl FilterOperator { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum FilterField { - Alias, + Title, Role, - Objective, - Action, + Impact, + Mode, Subject, Note, } @@ -49,10 +49,10 @@ impl FromStr for FilterField { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "alias" => Ok(FilterField::Alias), + "title" | "alias" => Ok(FilterField::Title), "role" => Ok(FilterField::Role), - "objective" => Ok(FilterField::Objective), - "action" => Ok(FilterField::Action), + "impact" | "objective" => Ok(FilterField::Impact), + "mode" | "action" => Ok(FilterField::Mode), "subject" => Ok(FilterField::Subject), "note" => Ok(FilterField::Note), _ => Err(FilterError::InvalidField(s.to_string())), @@ -63,11 +63,11 @@ impl FromStr for FilterField { impl FilterField { fn get_value<'a>(&self, session: &'a Session) -> Option<&'a str> { match self { - FilterField::Alias => session.intent.alias.as_deref(), - FilterField::Role => session.intent.role.as_deref(), - FilterField::Objective => session.intent.objective.as_deref(), - FilterField::Action => session.intent.action.as_deref(), - FilterField::Subject => session.intent.subject.as_deref(), + FilterField::Title => session.title.as_deref(), + FilterField::Role => session.role.as_deref(), + FilterField::Impact => session.impact.as_deref(), + FilterField::Mode => session.mode.as_deref(), + FilterField::Subject => session.subject.as_deref(), FilterField::Note => session.note.as_deref(), } } @@ -195,25 +195,15 @@ pub fn query_sessions( #[cfg(test)] mod tests { use super::*; - use crate::models::Intent; use chrono::{TimeZone, Utc}; use chrono_tz::America::New_York; fn create_test_session( - alias: Option<&str>, + title: Option<&str>, role: Option<&str>, - objective: Option<&str>, + impact: Option<&str>, note: Option<&str>, ) -> Session { - let intent = Intent::new( - alias.map(String::from), - role.map(String::from), - objective.map(String::from), - None, - None, - vec![], - ); - let start = Utc .with_ymd_and_hms(2025, 1, 1, 9, 0, 0) .unwrap() @@ -223,7 +213,17 @@ mod tests { .unwrap() .with_timezone(&New_York); - Session::new(intent, start, Some(end), note.map(String::from)) + Session::new( + title.map(String::from), + role.map(String::from), + impact.map(String::from), + None, // mode + None, + vec![], + start, + Some(end), + note.map(String::from), + ) } #[test] @@ -233,8 +233,8 @@ mod tests { assert_eq!(filter.operator, FilterOperator::Equals); assert_eq!(filter.value, "developer"); - let filter: Filter = "objective~planning".parse().unwrap(); - assert_eq!(filter.field, FilterField::Objective); + let filter: Filter = "impact~planning".parse().unwrap(); + assert_eq!(filter.field, FilterField::Impact); assert_eq!(filter.operator, FilterOperator::Contains); assert_eq!(filter.value, "planning"); @@ -261,10 +261,10 @@ mod tests { assert!(!filter.matches(&session)); // Contains operator - let filter: Filter = "objective~development".parse().unwrap(); + let filter: Filter = "impact~development".parse().unwrap(); assert!(filter.matches(&session)); - let filter: Filter = "objective~planning".parse().unwrap(); + let filter: Filter = "impact~planning".parse().unwrap(); assert!(!filter.matches(&session)); // Not equals operator diff --git a/core/tests/integration_tests.rs b/core/tests/integration_tests.rs index b88842a..4c1c29d 100644 --- a/core/tests/integration_tests.rs +++ b/core/tests/integration_tests.rs @@ -6,7 +6,6 @@ use async_trait::async_trait; use chrono::NaiveDate; use faff_core::managers::{IdentityManager, PlanManager, TimesheetManager}; -use faff_core::models::intent::Intent; use faff_core::models::log::Log; use faff_core::models::plan::Plan; use faff_core::models::session::Session; @@ -149,14 +148,6 @@ subjects = ["api"] [trackers] "PROJ-123" = "Implement user auth" "PROJ-456" = "Add API endpoints" - -[[intents]] -alias = "auth-work" -role = "engineer" -objective = "development" -action = "coding" -subject = "api" -trackers = ["PROJ-123"] "# .to_string(), ); @@ -179,16 +170,19 @@ trackers = ["PROJ-123"] Some(&"Implement user auth".to_string()) ); - // Create a log using intent from plan - let intents = ws.plans().get_intents(date).await.unwrap(); - assert_eq!(intents.len(), 1); - - let intent = &intents[0]; - assert_eq!(intent.alias.as_ref().unwrap(), "auth-work"); - // Create session and log let start_time = chrono::Utc::now().with_timezone(&chrono_tz::UTC); - let session = Session::new(intent.clone(), start_time, None, None); + let session = Session::new( + Some("auth-work".to_string()), + Some("engineer".to_string()), + Some("development".to_string()), + Some("coding".to_string()), + Some("api".to_string()), + vec!["local:PROJ-123".to_string()], + start_time, + None, + None, + ); let log = Log::new(date, chrono_tz::UTC, vec![session]); // Write log @@ -198,7 +192,7 @@ trackers = ["PROJ-123"] let retrieved_log = ws.logs().get_log(date).await.unwrap(); assert_eq!(retrieved_log.timeline.len(), 1); assert_eq!( - retrieved_log.timeline[0].intent.alias.as_ref().unwrap(), + retrieved_log.timeline[0].title.as_ref().unwrap(), "auth-work" ); } @@ -211,16 +205,6 @@ async fn test_log_and_timesheet_integration() { let date = NaiveDate::from_ymd_opt(2025, 3, 20).unwrap(); - // Create a log with sessions - let intent = Intent::new( - Some("work".to_string()), - Some("engineer".to_string()), - Some("development".to_string()), - Some("coding".to_string()), - Some("features".to_string()), - vec!["PROJ-123".to_string()], - ); - let start_datetime = date .and_hms_opt(9, 0, 0) .unwrap() @@ -233,7 +217,12 @@ async fn test_log_and_timesheet_integration() { .with_timezone(&chrono_tz::UTC); let session = Session::new( - intent.clone(), + Some("work".to_string()), + Some("engineer".to_string()), + Some("development".to_string()), + Some("coding".to_string()), + Some("features".to_string()), + vec!["PROJ-123".to_string()], start_datetime, Some(end_datetime), Some("Morning work".to_string()), @@ -269,7 +258,7 @@ async fn test_log_and_timesheet_integration() { assert_eq!(retrieved.date, date); assert_eq!(retrieved.timeline.len(), 1); - assert_eq!(retrieved.timeline[0].intent.alias.as_ref().unwrap(), "work"); + assert_eq!(retrieved.timeline[0].title.as_ref().unwrap(), "work"); assert_eq!(retrieved.timeline[0].note.as_ref().unwrap(), "Morning work"); } @@ -389,7 +378,6 @@ roles = ["engineer"] vec![], vec![], HashMap::new(), - vec![], ); plan_manager.write_plan(&new_plan).await.unwrap(); diff --git a/docs/python.md b/docs/python.md new file mode 100644 index 0000000..d2c938e --- /dev/null +++ b/docs/python.md @@ -0,0 +1,491 @@ +# faff-core Python SDK + +The Python SDK is a compiled Rust extension (`faff_core`) that gives Python code full access to faff's data and operations. It is the primary way to build faff plugins and tooling. + +## Installation + +```bash +pip install faff-core +``` + +## Quick start + +```python +import faff_core + +ws = faff_core.Workspace() +today = ws.today() +log = ws.logs.get_log(today) + +print(log) # Log(date=2026-03-21, timezone=Europe/London, timeline=[3 sessions]) +``` + +--- + +## `Workspace` + +The entry point for all SDK operations. + +```python +ws = faff_core.Workspace() +ws = faff_core.Workspace(storage=my_storage) # custom storage backend +``` + +`Workspace()` reads the faff repository from `~/.faff` (or `$FAFF_DIR`). Raises `UninitializedLedgerError` if no faff directory is found. + +### Properties + +| Property | Type | Description | +|---|---|---| +| `ws.plans` | `PlanManager` | Access to plans | +| `ws.logs` | `LogManager` | Access to daily logs | +| `ws.timesheets` | `TimesheetManager` | Access to timesheets | +| `ws.identities` | `IdentityManager` | Access to signing identities | +| `ws.plugins` | `PluginManager` | Access to loaded plugins | + +### Methods + +```python +ws.now() # -> datetime current time in configured timezone +ws.today() # -> date today's date +ws.timezone() # -> ZoneInfo configured timezone + +ws.config() # -> Config workspace configuration + +# Natural language date/time parsing +ws.parse_natural_date("yesterday") # -> date +ws.parse_natural_date("last monday") # -> date +ws.parse_natural_date("2026-01-15") # -> date +ws.parse_natural_date(None) # -> today + +ws.parse_natural_datetime("09:30") # -> datetime (must be today) +ws.parse_natural_datetime("2 hours ago") # -> datetime +ws.parse_natural_datetime(None) # -> now +# Raises ValueError if the parsed time is not today +``` + +--- + +## Models + +### `Session` + +A single tracked activity. + +```python +from faff_core.models import Session +import datetime + +session = Session( + start=some_datetime, + title="Sprint planning", # optional + role="engineer", # optional + impact="delivery", # optional + mode="sync", # optional + subject="project-x", # optional + trackers=["PROJ-123"], # optional, list of tracker IDs + end=some_other_datetime, # optional, None = session still active + note="went well", # optional +) +``` + +**Attributes** (all read-only): + +| Attribute | Type | Description | +|---|---|---| +| `title` | `str \| None` | | +| `role` | `str \| None` | | +| `impact` | `str \| None` | | +| `mode` | `str \| None` | | +| `subject` | `str \| None` | | +| `trackers` | `list[str]` | Tracker IDs | +| `start` | `datetime` | Timezone-aware | +| `end` | `datetime \| None` | None if session is still active | +| `note` | `str \| None` | | +| `reflection_score` | `int \| None` | | +| `reflection` | `str \| None` | | +| `duration` | `timedelta` | Raises `ValueError` if session has no end | + +**Methods**: + +```python +session.elapsed(now) # -> timedelta time since start (open sessions only) +session.with_end(dt) # -> Session new Session with end set +session.with_reflection(score, text) # -> Session new Session with reflection fields +session.as_dict() # -> dict +Session.from_dict_with_tz(d, date, tz) # classmethod +``` + +--- + +### `Log` + +A day's worth of sessions. + +```python +from faff_core.models import Log + +log = Log(date=some_date, timezone=some_zoneinfo) +log = Log(date=some_date, timezone=some_zoneinfo, timeline=[session1, session2]) +``` + +**Attributes**: + +| Attribute | Type | Description | +|---|---|---| +| `date` | `date` | | +| `timezone` | `ZoneInfo` | | +| `timeline` | `list[Session]` | Ordered list of sessions, oldest first | + +**Methods**: + +```python +log.active_session() # -> Session | None the open session, if any +log.is_closed() # -> bool True if all sessions have an end +log.total_recorded_time() # -> timedelta +log.append_session(session) # -> Log new Log with session appended +log.stop_active_session(end_time) # -> Log new Log with active session closed +log.summary(now) # -> dict see below +log.hash(trackers) # -> str content hash (trackers: dict[str, str]) +log.to_log_file(trackers) # -> str TOML serialisation + +Log.from_dict(d) # classmethod +Log.calculate_hash(toml_content) # staticmethod +``` + +**`log.summary(now)` returns**: + +```python +{ + "total_minutes": float, + "by_title": dict[str, float], # title -> minutes + "by_tracker": dict[str, float], # tracker_id -> minutes + "by_tracker_source": dict[str, float], + "mean_reflection_score": float | None, +} +``` + +--- + +### `Plan` + +The vocabulary and trackers available for a date range. + +```python +from faff_core.models import Plan +import datetime + +plan = Plan( + source="local", + valid_from=datetime.date(2026, 1, 1), + valid_until=None, # optional, open-ended if None + roles=["engineer", "lead"], + impacts=["delivery", "quality"], + modes=["sync", "async"], + subjects=["project-x"], + trackers={"PROJ-123": "Add login page"}, # id -> description +) +``` + +**Attributes** (all read-only): + +| Attribute | Type | +|---|---| +| `source` | `str` | +| `valid_from` | `date` | +| `valid_until` | `date \| None` | +| `roles` | `list[str]` | +| `impacts` | `list[str]` | +| `modes` | `list[str]` | +| `subjects` | `list[str]` | +| `trackers` | `dict[str, str]` | +| `hints` | `list[dict]` | + +**Methods**: + +```python +plan.id() # -> str unique identifier for this plan +plan.to_toml() # -> str +plan.as_dict() # -> dict +plan.with_hints(hints) # -> Plan new Plan with hints attached +Plan.from_dict(d) # classmethod +``` + +Each hint dict has: `title` (str), `role`, `subject`, `impact`, `mode` (all `str | None`), `trackers` (list[str]). + +--- + +### `Timesheet` + +A compiled, signed record of a day's sessions for submission to an audience. + +Timesheets are produced by `TimesheetManager.compile()` and should not be constructed directly. + +**Attributes**: + +| Attribute | Type | +|---|---| +| `date` | `date` | +| `timeline` | `list[Session]` | +| `actor` | `dict[str, str]` | +| `version` | `str` | + +--- + +## Managers + +Managers are accessed via `Workspace` properties, not instantiated directly. + +--- + +### `LogManager` + +`ws.logs` + +```python +# Check existence +ws.logs.log_exists(date) # -> bool +ws.logs.log_file_path(date) # -> str absolute path + +# Read +ws.logs.get_log(date) # -> Log (empty Log if file doesn't exist) +ws.logs.list_log_dates() # -> list[date] +ws.logs.list_logs() # -> list[Log] +ws.logs.list_logs_recent(n) # -> list[Log] n most recent, oldest-first + +# Write +ws.logs.write_log(log, trackers) # trackers: dict[str, str] +ws.logs.delete_log(date) + +# Raw file access +ws.logs.read_log_raw(date) # -> str +ws.logs.write_log_raw(date, text) + +# Session lifecycle +ws.logs.start_session( + title=None, role=None, impact=None, mode=None, subject=None, + trackers=[], + start_time=None, # defaults to now + note=None, +) +ws.logs.stop_current_session() + +# Bulk operations +ws.logs.replace_field_in_all_logs(field, old_value, new_value, trackers) +# -> (logs_updated: int, sessions_updated: int) + +ws.logs.get_field_usage_stats(field) +# -> (dict[str, int], dict[str, list[date]]) +# (value -> session count, value -> list of log dates) + +ws.logs.timezone() # -> ZoneInfo +``` + +`start_session` stops any currently active session before starting the new one. Raises `ValueError` if `start_time` is in the future or conflicts with existing sessions. + +--- + +### `PlanManager` + +`ws.plans` + +```python +# Get plans for a date +ws.plans.get_plans(date) # -> dict[str, Plan] source -> Plan +ws.plans.get_local_plan(date) # -> Plan | None +ws.plans.get_local_plan_or_create(date) # -> Plan (creates empty if missing) +ws.plans.get_plan_by_tracker_id(tracker_id, date) # -> Plan | None + +# Get vocabulary lists for a date (merged across all valid plans) +ws.plans.get_roles(date) # -> list[str] +ws.plans.get_impacts(date) # -> list[str] +ws.plans.get_modes(date) # -> list[str] +ws.plans.get_subjects(date) # -> list[str] +ws.plans.get_trackers(date) # -> dict[str, str] id -> description + +# Hints and mappings +ws.plans.get_session_hints(date) # -> list[dict] +ws.plans.get_tracker_mappings(date) # -> list[dict] + +# All plan data needed at session-start time in a single call +ws.plans.get_start_data(date) +# -> dict with keys: roles, impacts, modes, subjects, trackers, hints, tracker_mappings + +# Write +ws.plans.write_plan(plan) + +# Bulk operations +ws.plans.replace_field_in_all_plans(field, old_value, new_value) # -> int (plans updated) +ws.plans.get_field_usage_stats(field) # -> dict[str, int] value -> count + +# Remote plugin instances +ws.plans.remotes() # -> list[PlanSource plugin instances] +``` + +`get_session_hints` returns `list[dict]` where each dict has: `title`, `role`, `subject`, `impact`, `mode` (`str | None`), `trackers` (`list[str]`). + +`get_tracker_mappings` returns `list[dict]` where each dict has: `tracker_id`, `tracker_name`, `hint_title`, `role`, `subject`, `impact`, `mode` (last four are `str | None`). + +--- + +### `TimesheetManager` + +`ws.timesheets` + +```python +# Compile and submit +ws.timesheets.compile(log, plugin) # -> Timesheet +ws.timesheets.submit(timesheet) + +# Sign +ws.timesheets.sign_timesheet(timesheet, signing_ids) # -> Timesheet +# signing_ids: list[str] - identity names to sign with + +# Persistence +ws.timesheets.write_timesheet(timesheet) +ws.timesheets.get_timesheet(audience_id, date) # -> Timesheet | None +ws.timesheets.list_timesheets(date=None) # -> list[Timesheet] +ws.timesheets.delete_timesheet(audience_id, date) + +# Status checks +ws.timesheets.find_stale_timesheets(date=None) # -> list[Timesheet] +ws.timesheets.find_failed_submissions(date=None) # -> list[Timesheet] + +# Audience plugins +ws.timesheets.audiences() # -> list[Audience plugin instances] +ws.timesheets.get_audience(audience_id) # -> Audience | None +``` + +A stale timesheet is one where the source log has changed since the timesheet was compiled. + +--- + +### `IdentityManager` + +`ws.identities` + +Ed25519 key pairs used for signing timesheets. Keys are stored in `~/.faff/identities/`. + +```python +ws.identities.create_identity(name, overwrite=False) +ws.identities.load_identity(name) # signing key +ws.identities.verify_identity(name) # verification key +``` + +--- + +## Querying + +`faff_core.Filter` and `faff_core.query_sessions` let you aggregate session time across logs. + +```python +from faff_core import Filter, query_sessions + +# Parse a filter from a string +f = Filter.parse("role=engineer") +f = Filter.parse("impact~delivery") # contains (case-insensitive) +f = Filter.parse("mode!=async") + +# Filter fields: title, role, impact, mode, subject, note +# Operators: = (equals), ~ (contains), != (not equals) + +f.field() # -> str e.g. "role" +f.operator() # -> str e.g. "=" +f.value() # -> str e.g. "engineer" + +# Aggregate session durations across a list of logs +results = query_sessions( + logs=ws.logs.list_logs(), + filters=[Filter.parse("role=engineer")], + from_date=None, # optional date + to_date=None, # optional date +) +# results: dict[tuple[str, ...], int] +# keys are tuples of filter field values, values are durations in seconds +``` + +--- + +## Event watching + +Watch the faff repository for file changes. + +```python +from faff_core import start_watching + +stream = start_watching("~/.faff") + +for event in stream: # blocks until events arrive + print(event.event_type) # "log_changed" or "plan_changed" + print(event.path) # absolute path of changed file +``` + +`EventStream` is a blocking iterator that releases the GIL between events and respects `KeyboardInterrupt`. If events are produced faster than they are consumed it raises `StopIteration` with a "lagged" message. + +--- + +## Writing plugins + +Plugins live in `~/.faff/plugins//plugin/plugin.py` and subclass one of the base classes from `faff_core.plugins`. + +### `PlanSource` + +Provides a plan from an external system (e.g. Jira, Linear). + +```python +from faff_core.plugins import PlanSource +from faff_core.models import Plan +import datetime + +class MyPlanSource(PlanSource): + def pull_plan(self, date: datetime.date) -> Plan: + # fetch from external system using self.config + return Plan( + source=self.name, + valid_from=date, + trackers={"PROJ-1": "Fix the bug"}, + ) +``` + +### `Audience` + +Compiles and submits timesheets to an external system (e.g. Harvest, Clockify). + +```python +from faff_core.plugins import Audience +from faff_core.models import Log, Timesheet + +class MyAudience(Audience): + def compile_time_sheet(self, log: Log) -> Timesheet: + # filter log sessions and build a Timesheet + ... + + def submit_timesheet(self, timesheet: Timesheet) -> None: + # send to external system using self.config + ... +``` + +### `Plugin` base class + +Both `PlanSource` and `Audience` inherit from `Plugin`, which provides: + +| Attribute | Type | Description | +|---|---|---| +| `self.plugin` | `str` | Plugin type/class name | +| `self.name` | `str` | Instance name | +| `self.id` | `str` | Slugified instance name | +| `self.slug` | `str` | Same as `id` | +| `self.config` | `dict` | Plugin-specific configuration | +| `self.defaults` | `dict` | Default values | +| `self.state_path` | `Path` | Directory for persistent state | + +--- + +## Errors + +| Exception | When raised | +|---|---| +| `faff_core.UninitializedLedgerError` | No faff directory found at startup | +| `ValueError` | Invalid dates, times, or field values | +| `FileNotFoundError` | Log file does not exist | +| `RuntimeError` | Internal errors (plugin loading, etc.) |