Skip to content

[034-focus-invariants-in-domain]: Move Focus/Task invariants into domain#40

Merged
archae0pteryx merged 3 commits intomainfrom
chore/focus-invariants-in-domain
May 5, 2026
Merged

[034-focus-invariants-in-domain]: Move Focus/Task invariants into domain#40
archae0pteryx merged 3 commits intomainfrom
chore/focus-invariants-in-domain

Conversation

@archae0pteryx
Copy link
Copy Markdown
Contributor

@archae0pteryx archae0pteryx commented May 4, 2026

Closes issues/034-focus-invariants-in-domain.md

Completion promise: Focus title and task text invariants are enforced once in crates/domain/ via typed constructors; no validation logic remains in the commands layer.

Summary

  • Add DomainError enum (EmptyTitle, EmptyTaskText) in crates/domain/
  • Add NewFocus::new(title, description) validated constructor
  • Add TaskText newtype with validated new + as_str
  • Replace trim().is_empty() guards in Commands::create_focus and Commands::append_task; map DomainError -> CommandError::BadRequest
  • Drop stale ralph/ Taskfile include left over from dabed05 so task check runs again

Test plan

  • Domain unit tests for both error cases (empty + whitespace)
  • Command-handler regression tests assert blank input -> BadRequest
  • task check green

Summary by CodeRabbit

  • New Features

    • Enhanced input validation for focus titles and task descriptions to reject empty or whitespace-only entries, providing clearer error messages.
    • Focus creation now requires explicit title and description inputs.
  • Bug Fixes

    • Improved error handling and validation consistency across the application.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 47f777b4-2232-4407-ba17-a292f02cd5ad

📥 Commits

Reviewing files that changed from the base of the PR and between 67069bc and 80e2804.

📒 Files selected for processing (10)
  • crates/commands/src/error.rs
  • crates/commands/src/focus.rs
  • crates/commands/src/lifecycle.rs
  • crates/commands/src/proposal.rs
  • crates/domain/src/error.rs
  • crates/domain/src/focus.rs
  • crates/domain/src/lib.rs
  • crates/domain/src/proposal.rs
  • crates/storage/src/focus_store.rs
  • crates/storage/src/proposals.rs

📝 Walkthrough

Walkthrough

The PR introduces domain-level validation for empty and whitespace-only focus titles and task text by creating a DomainError enum, adding validated constructors to NewFocus and TaskText domain types, and updating command handlers and storage layers to use these constructors and to convert domain errors into command errors.

Changes

Domain Validation & Error Handling

Layer / File(s) Summary
Domain Error Definition
crates/domain/src/error.rs
New DomainError enum with EmptyTitle and EmptyTaskText variants; implements Display and Error traits with variant-specific messages.
Domain Type Validation
crates/domain/src/focus.rs, crates/domain/src/proposal.rs
TaskText struct introduced with new() constructor rejecting empty/whitespace input; NewFocus fields made private with new() constructor validating titles, accessor methods, and custom Deserialize that validates on deserialization.
Domain API Surface
crates/domain/src/lib.rs
New error module declared and DomainError re-exported; TaskText added to focus re-exports.
Command Error Conversion
crates/commands/src/error.rs
From<DomainError> for CommandError trait impl maps DomainError into CommandError::BadRequest with stringified error message.
Command-Level Integration
crates/commands/src/focus.rs, crates/commands/src/proposal.rs
create_focus and append_task delegate validation to NewFocus::new() and TaskText::new() respectively; create_proposal now requires input.new_focus present instead of defaulting to empty struct.
Persistence Layer Updates
crates/storage/src/focus_store.rs, crates/commands/src/lifecycle.rs, crates/storage/src/proposals.rs
Storage and lifecycle code updated to call NewFocus accessor methods (title(), description(), timer_preset()) instead of accessing fields directly; ProposalLifecycle::apply uses accessor method for timer extraction.
Tests & Validation Coverage
crates/commands/src/focus.rs, crates/domain/src/proposal.rs, crates/domain/src/focus.rs, crates/storage/src/focus_store.rs, crates/storage/src/proposals.rs, crates/commands/src/lifecycle.rs
New unit tests validate NewFocus::new() and TaskText::new() rejection of empty/whitespace input; all test fixtures updated to construct NewFocus and TaskText via validated constructors instead of struct literals.

Sequence Diagram

(No diagram generated—the changes represent straightforward validation logic movement and error mapping without introducing new multi-component control flows that would benefit from visualization.)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • killallgit/adhd-ranch#36: This PR implements the proposal to introduce domain-level validation for empty titles and task text, moving checks out of command handlers into validated domain constructors and mapping domain errors to command errors.

Possibly related PRs

  • killallgit/adhd-ranch#5: Modifies CommandError type and crates/commands/src/error.rs for serde serialization; this PR adds the complementary From<DomainError> impl for error conversion.
  • killallgit/adhd-ranch#40: Implements the same domain validation pattern with DomainError, NewFocus::new(), TaskText::new(), and From<DomainError> for CommandError trait impl.

Poem

🐰 A rabbit hops through fields of fields now made private,
Where validators guard the gate with domain might.
No empty titles slip past—each one now checked,
And task text whitespace? Firmly rejected.
Errors cascade down from domain to command with grace! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly describes the main objective: moving Focus and Task invariants (validation) from command handlers into the domain crate.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/focus-invariants-in-domain

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/commands/src/focus.rs`:
- Around line 42-43: The change to validation flow in NewFocus::new (used in the
focus creation command) can let DomainError map to CommandError::BadRequest, but
there are no regression tests asserting that a blank title or blank task yields
a CommandError::BadRequest; add tests that call the command handler (the
function that constructs NewFocus via NewFocus::new and then executes the
command) with input containing an empty title and with empty task(s), and assert
the returned error is CommandError::BadRequest (and optionally check the error
message matches the original DomainError); target the test(s) to exercise the
code paths touched around NewFocus::new and the focus creation command so
API-level error semantics remain pinned.

In `@crates/domain/src/proposal.rs`:
- Around line 63-78: The NewFocus constructor enforces a non-empty title but the
struct is still instantiable directly, so make the invariant non-bypassable by
making NewFocus's fields private (e.g., remove pub from fields) and exposing
only NewFocus::new for creation (returning Result and using
DomainError::EmptyTitle on empty titles); also update any deserialization/deser
path (e.g., impl Deserialize for NewFocus or serde attributes) to call
NewFocus::new during deserialization and adjust places that previously
constructed NewFocus directly (including tests) to use the constructor, ensuring
timer_preset remains handled via the constructor or appropriate builder methods.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c443f419-e7a7-4f4e-8c44-f14d5e6c43c4

📥 Commits

Reviewing files that changed from the base of the PR and between 287eab2 and 5587859.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • Cargo.toml
  • crates/commands/src/error.rs
  • crates/commands/src/focus.rs
  • crates/domain/Cargo.toml
  • crates/domain/src/error.rs
  • crates/domain/src/lib.rs
  • crates/domain/src/proposal.rs
  • crates/domain/src/task_text.rs

Comment thread crates/commands/src/focus.rs Outdated
Comment thread crates/domain/src/proposal.rs Outdated
@archae0pteryx archae0pteryx force-pushed the chore/focus-invariants-in-domain branch from 5587859 to 57970b6 Compare May 5, 2026 02:29
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
crates/domain/src/proposal.rs (1)

17-32: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

NewFocus::new does not make the title invariant non-bypassable.

Even with this constructor, invalid domain state is still possible because NewFocus remains publicly constructible and Deserialize can instantiate it without calling new. That means the invariant is not enforced “once in domain” yet.

Suggested direction
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct NewFocus {
-    pub title: String,
-    pub description: String,
+    title: String,
+    description: String,
     #[serde(skip_serializing_if = "Option::is_none", default)]
-    pub timer_preset: Option<TimerPreset>,
+    timer_preset: Option<TimerPreset>,
 }

 impl NewFocus {
     pub fn new(
         title: impl Into<String>,
         description: impl Into<String>,
     ) -> Result<Self, DomainError> {
         let title = title.into();
         if title.trim().is_empty() {
             return Err(DomainError::EmptyTitle);
         }
         Ok(Self {
             title,
             description: description.into(),
             timer_preset: None,
         })
     }
+
+    pub fn title(&self) -> &str { &self.title }
+    pub fn description(&self) -> &str { &self.description }
+    pub fn timer_preset(&self) -> Option<TimerPreset> { self.timer_preset }
+    pub fn with_timer_preset(mut self, timer_preset: Option<TimerPreset>) -> Self {
+        self.timer_preset = timer_preset;
+        self
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/domain/src/proposal.rs` around lines 17 - 32, NewFocus currently can
be instantiated bypassing its validation because Deserialize can construct it
directly; fix by making NewFocus's fields private (remove pub) so external
code/serde can't build invalid instances, and implement a custom Deserialize (or
TryFrom + #[serde(deserialize_with = "...")]) that calls NewFocus::new and
returns a deserialization error on invalid titles; update any public API to
expose only NewFocus::new (or TryFrom) for creation and ensure types referenced
(NewFocus, NewFocus::new) are used in the custom deserializer to enforce the
invariant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/commands/src/focus.rs`:
- Around line 133-145: The test append_task_blank_text_returns_bad_request
hardcodes "real-focus" as the focus id; instead capture the created focus's id
from the create_focus call and use that when calling append_task. Change the
test to call create_focus and bind its Ok value (the created Focus/ID from
create_focus(CreateFocusInput { ... })), then pass that returned id/slug into
commands.append_task(...) rather than the literal "real-focus" so the test does
not depend on a specific id generation strategy; keep the rest of the assertion
the same.

---

Duplicate comments:
In `@crates/domain/src/proposal.rs`:
- Around line 17-32: NewFocus currently can be instantiated bypassing its
validation because Deserialize can construct it directly; fix by making
NewFocus's fields private (remove pub) so external code/serde can't build
invalid instances, and implement a custom Deserialize (or TryFrom +
#[serde(deserialize_with = "...")]) that calls NewFocus::new and returns a
deserialization error on invalid titles; update any public API to expose only
NewFocus::new (or TryFrom) for creation and ensure types referenced (NewFocus,
NewFocus::new) are used in the custom deserializer to enforce the invariant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ff8551ed-0c86-441c-9b48-9a15d8ce4288

📥 Commits

Reviewing files that changed from the base of the PR and between 5587859 and 57970b6.

📒 Files selected for processing (7)
  • Taskfile.yaml
  • crates/commands/src/error.rs
  • crates/commands/src/focus.rs
  • crates/domain/src/error.rs
  • crates/domain/src/focus.rs
  • crates/domain/src/lib.rs
  • crates/domain/src/proposal.rs
💤 Files with no reviewable changes (1)
  • Taskfile.yaml

Comment thread crates/commands/src/focus.rs
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/domain/src/proposal.rs (1)

192-205: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Tests bypass NewFocus::new validation by using struct literals.

These tests construct NewFocus via struct literals (which works only within this module due to Rust privacy). While the test at lines 192-205 intentionally tests Proposal::validate as a defense-in-depth layer, using struct literals is inconsistent with the PR's goal of routing all construction through NewFocus::new.

Consider updating these tests to use NewFocus::new(...) or document why direct struct construction is intentional here.

♻️ Suggested refactor for consistency

For round_trip_preserves_kind (lines 261-271):

     fn round_trip_preserves_kind() {
         let original = ok_proposal(ProposalKind::NewFocus {
-            new_focus: NewFocus {
-                title: "T".into(),
-                description: "D".into(),
-                timer_preset: None,
-            },
+            new_focus: NewFocus::new("T", "D").unwrap(),
         });

For new_focus_requires_title (lines 192-205), if the intent is to test Proposal::validate as a fallback layer, add a comment explaining this design choice.

Also applies to: 261-271

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/domain/src/proposal.rs` around lines 192 - 205, The tests currently
construct NewFocus via struct literals (e.g., in new_focus_requires_title and
round_trip_preserves_kind) which bypass NewFocus::new validation; update those
tests to call NewFocus::new(...) when creating NewFocus instances (and handle
the Result) so construction always goes through NewFocus::new, or if the test
intentionally exercises Proposal::validate as a defense-in-depth layer, add a
concise comment above the test (naming the test like new_focus_requires_title)
stating that direct struct construction is deliberate to test Proposal::validate
rather than NewFocus::new; reference the NewFocus struct and Proposal::validate
in the comment to make the intent explicit.
♻️ Duplicate comments (1)
crates/commands/src/focus.rs (1)

133-145: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test uses hardcoded slug instead of returned focus id.

Line 143 uses "real-focus" as a hardcoded slug, which couples the test to the current slugification strategy. This was flagged in a previous review (marked as addressed), but the code still uses the hardcoded value.

🛠️ Suggested fix
     #[test]
     fn append_task_blank_text_returns_bad_request() {
         let (commands, _dir) = build_commands(0);
-        commands
+        let created = commands
             .create_focus(CreateFocusInput {
                 title: "Real focus".into(),
                 description: String::new(),
                 timer_preset: None,
             })
             .unwrap();
-        let err = commands.append_task("real-focus", "   ").unwrap_err();
+        let err = commands.append_task(&created.id, "   ").unwrap_err();
         assert!(matches!(err, CommandError::BadRequest(_)));
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/commands/src/focus.rs` around lines 133 - 145, The test currently
hardcodes the slug "real-focus" when calling commands.append_task which couples
the test to slugification; instead capture the value returned by
commands.create_focus(CreateFocusInput { ... }).unwrap() (e.g. let focus =
commands.create_focus(...).unwrap();) and pass the returned focus identifier
(focus.id or focus.slug as provided by the create_focus return type) into
commands.append_task so the test uses the actual created focus identifier rather
than a hardcoded string.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/domain/src/proposal.rs`:
- Around line 192-205: The tests currently construct NewFocus via struct
literals (e.g., in new_focus_requires_title and round_trip_preserves_kind) which
bypass NewFocus::new validation; update those tests to call NewFocus::new(...)
when creating NewFocus instances (and handle the Result) so construction always
goes through NewFocus::new, or if the test intentionally exercises
Proposal::validate as a defense-in-depth layer, add a concise comment above the
test (naming the test like new_focus_requires_title) stating that direct struct
construction is deliberate to test Proposal::validate rather than NewFocus::new;
reference the NewFocus struct and Proposal::validate in the comment to make the
intent explicit.

---

Duplicate comments:
In `@crates/commands/src/focus.rs`:
- Around line 133-145: The test currently hardcodes the slug "real-focus" when
calling commands.append_task which couples the test to slugification; instead
capture the value returned by commands.create_focus(CreateFocusInput { ...
}).unwrap() (e.g. let focus = commands.create_focus(...).unwrap();) and pass the
returned focus identifier (focus.id or focus.slug as provided by the
create_focus return type) into commands.append_task so the test uses the actual
created focus identifier rather than a hardcoded string.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c5961e53-37c9-4d7a-bf4a-4976eed5a12b

📥 Commits

Reviewing files that changed from the base of the PR and between 57970b6 and b3aa18e.

📒 Files selected for processing (6)
  • crates/commands/src/focus.rs
  • crates/commands/src/lifecycle.rs
  • crates/commands/src/proposal.rs
  • crates/domain/src/proposal.rs
  • crates/storage/src/focus_store.rs
  • crates/storage/src/proposals.rs

Closes issues/034-focus-invariants-in-domain.md

Completion promise: Focus title and task text invariants are enforced
once in crates/domain/ via typed constructors; no validation logic
remains in the commands layer.

- Add DomainError enum (EmptyTitle, EmptyTaskText) in domain crate
- Add NewFocus::new validated constructor
- Add TaskText newtype with validated new + as_str
- Replace trim().is_empty() guards in Commands::create_focus and
  Commands::append_task; map DomainError -> CommandError::BadRequest
- Drop stale ralph/ Taskfile include left over from dabed05 so
  task check runs again
Previously NewFocus had pub fields, so callers (including Deserialize)
could construct invalid domain state without going through ::new(). The
invariant only held by convention.

Make fields private; expose accessors and a with_timer_preset builder.
Implement Deserialize manually so wire-format input also routes through
::new() and rejects empty titles. CreateProposalInput now returns
BadRequest when kind=new_focus is sent without a new_focus payload.
@archae0pteryx archae0pteryx force-pushed the chore/focus-invariants-in-domain branch from 67069bc to 80e2804 Compare May 5, 2026 04:53
archae0pteryx added a commit that referenced this pull request May 5, 2026
- CHANGELOG: Unreleased entries for PRs #39 (033), #40 (034), #41
  (035), #42 (cleanup), and the Taskfile ralph-include fix
- CONTEXT: add TaskText and DomainError to the ubiquitous language
- issues/README: mark 033/034/035 as in-flight (PR open)
@archae0pteryx archae0pteryx merged commit dd16c91 into main May 5, 2026
1 check passed
archae0pteryx added a commit that referenced this pull request May 5, 2026
- CHANGELOG: Unreleased entries for PRs #39 (033), #40 (034), #41
  (035), #42 (cleanup), and the Taskfile ralph-include fix
- CONTEXT: add TaskText and DomainError to the ubiquitous language
- issues/README: mark 033/034/035 as in-flight (PR open)
archae0pteryx added a commit that referenced this pull request May 5, 2026
- CHANGELOG: Unreleased entries for PRs #39 (033), #40 (034), #41
  (035), #42 (cleanup), and the Taskfile ralph-include fix
- CONTEXT: add TaskText and DomainError to the ubiquitous language
- issues/README: mark 033/034/035 as in-flight (PR open)
archae0pteryx added a commit that referenced this pull request May 5, 2026
…tus (#43)

- CHANGELOG: Unreleased entries for PRs #39 (033), #40 (034), #41
  (035), #42 (cleanup), and the Taskfile ralph-include fix
- CONTEXT: add TaskText and DomainError to the ubiquitous language
- issues/README: mark 033/034/035 as in-flight (PR open)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant