Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/commands/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use adhd_ranch_domain::ProposalValidationError;
use adhd_ranch_domain::{DomainError, ProposalValidationError};
use adhd_ranch_storage::{FocusStoreError, JsonlError};

#[derive(Debug, serde::Serialize)]
Expand Down Expand Up @@ -49,3 +49,9 @@ impl From<ProposalValidationError> for CommandError {
CommandError::Validation(e.to_string())
}
}

impl From<DomainError> for CommandError {
fn from(e: DomainError) -> Self {
CommandError::BadRequest(e.to_string())
}
}
45 changes: 32 additions & 13 deletions crates/commands/src/focus.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::sync::Arc;

use adhd_ranch_domain::{Caps, Focus, FocusTimer, NewFocus, TimerPreset, TimerStatus};
use adhd_ranch_domain::{Caps, Focus, FocusTimer, NewFocus, TaskText, TimerPreset, TimerStatus};
use adhd_ranch_storage::FocusStore;
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -39,19 +39,13 @@ impl Commands {
}

pub fn create_focus(&self, input: CreateFocusInput) -> Result<CreatedFocus, CommandError> {
if input.title.trim().is_empty() {
return Err(CommandError::BadRequest("title must not be empty".into()));
}
let timer = input.timer_preset.as_ref().map(|preset| FocusTimer {
duration_secs: preset.duration_secs(),
started_at: (self.clock_secs)(),
status: TimerStatus::Running,
});
let new_focus = NewFocus {
title: input.title,
description: input.description,
timer_preset: input.timer_preset,
};
let new_focus =
NewFocus::new(input.title, input.description)?.with_timer_preset(input.timer_preset);
let slug =
create_focus_in_store(&self.store, &self.clock, &self.id_gen, &new_focus, timer)?;
Ok(CreatedFocus { id: slug })
Expand All @@ -63,10 +57,8 @@ impl Commands {
}

pub fn append_task(&self, focus_id: &str, text: &str) -> Result<(), CommandError> {
if text.trim().is_empty() {
return Err(CommandError::BadRequest("text must not be empty".into()));
}
self.store.append_task(focus_id, text)?;
let text = TaskText::new(text)?;
self.store.append_task(focus_id, text.as_str())?;
Ok(())
}

Expand Down Expand Up @@ -125,6 +117,33 @@ mod tests {
assert!(focuses[0].timer.is_none());
}

#[test]
fn create_focus_blank_title_returns_bad_request() {
let (commands, _dir) = build_commands(0);
let err = commands
.create_focus(CreateFocusInput {
title: " ".into(),
description: String::new(),
timer_preset: None,
})
.unwrap_err();
assert!(matches!(err, CommandError::BadRequest(_)));
}

#[test]
fn append_task_blank_text_returns_bad_request() {
let (commands, _dir) = build_commands(0);
let created = commands
.create_focus(CreateFocusInput {
title: "Real focus".into(),
description: String::new(),
timer_preset: None,
})
.unwrap();
let err = commands.append_task(&created.id, " ").unwrap_err();
assert!(matches!(err, CommandError::BadRequest(_)));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[test]
fn create_focus_with_preset_stores_timer_with_correct_duration() {
let started_at = 1_700_000_000_i64;
Expand Down
8 changes: 2 additions & 6 deletions crates/commands/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ impl ProposalLifecycle {
Ok(Some(target_focus_id.clone()))
}
ProposalKind::NewFocus { new_focus } => {
let timer = new_focus.timer_preset.as_ref().map(|preset| FocusTimer {
let timer = new_focus.timer_preset().map(|preset| FocusTimer {
duration_secs: preset.duration_secs(),
started_at: (self.clock_secs)(),
status: TimerStatus::Running,
Expand Down Expand Up @@ -243,11 +243,7 @@ mod tests {
.append(&proposal(
"p1",
ProposalKind::NewFocus {
new_focus: NewFocus {
title: "Customer X bug".into(),
description: "ship".into(),
timer_preset: None,
},
new_focus: NewFocus::new("Customer X bug", "ship").unwrap(),
},
))
.unwrap();
Expand Down
9 changes: 4 additions & 5 deletions crates/commands/src/proposal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,10 @@ impl Commands {
task_text: input.task_text.clone().unwrap_or_default(),
},
"new_focus" => ProposalKind::NewFocus {
new_focus: input.new_focus.clone().unwrap_or(NewFocus {
title: String::new(),
description: String::new(),
timer_preset: None,
}),
new_focus: input
.new_focus
.clone()
.ok_or_else(|| CommandError::BadRequest("new_focus required".into()))?,
},
"discard" => ProposalKind::Discard,
other => {
Expand Down
16 changes: 16 additions & 0 deletions crates/domain/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DomainError {
EmptyTitle,
EmptyTaskText,
}

impl std::fmt::Display for DomainError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyTitle => f.write_str("title must not be empty"),
Self::EmptyTaskText => f.write_str("task text must not be empty"),
}
}
}

impl std::error::Error for DomainError {}
37 changes: 37 additions & 0 deletions crates/domain/src/focus.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
use serde::{Deserialize, Serialize};

use crate::error::DomainError;
use crate::timer::FocusTimer;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskText(String);

impl TaskText {
pub fn new(text: impl Into<String>) -> Result<Self, DomainError> {
let text = text.into();
if text.trim().is_empty() {
return Err(DomainError::EmptyTaskText);
}
Ok(Self(text))
}

pub fn as_str(&self) -> &str {
&self.0
}
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FocusId(pub String);

Expand All @@ -26,6 +44,25 @@ pub struct Focus {
mod tests {
use super::*;

#[test]
fn task_text_new_preserves_input() {
let t = TaskText::new("ship it").unwrap();
assert_eq!(t.as_str(), "ship it");
}

#[test]
fn task_text_new_rejects_empty() {
assert_eq!(TaskText::new("").unwrap_err(), DomainError::EmptyTaskText);
}

#[test]
fn task_text_new_rejects_whitespace() {
assert_eq!(
TaskText::new(" ").unwrap_err(),
DomainError::EmptyTaskText
);
}

#[test]
fn focus_round_trips_via_serde() {
let f = Focus {
Expand Down
4 changes: 3 additions & 1 deletion crates/domain/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod cap_monitor;
pub mod caps;
pub mod decision;
pub mod error;
pub mod focus;
pub mod parse;
pub mod pig_rect;
Expand All @@ -12,7 +13,8 @@ pub mod timer;
pub use cap_monitor::{CapTransition, OverCapMonitor};
pub use caps::{cap_state, CapState};
pub use decision::{Decision, DecisionKind};
pub use focus::{Focus, FocusId, Task};
pub use error::DomainError;
pub use focus::{Focus, FocusId, Task, TaskText};
pub use parse::{parse_focus_md, ParseError};
pub use pig_rect::{PigRect, RectUpdater};
pub use proposal::{NewFocus, Proposal, ProposalId, ProposalKind, ProposalValidationError};
Expand Down
97 changes: 86 additions & 11 deletions crates/domain/src/proposal.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,68 @@
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};

use crate::error::DomainError;
use crate::timer::TimerPreset;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProposalId(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
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.as_ref()
}

#[must_use]
pub fn with_timer_preset(mut self, timer_preset: Option<TimerPreset>) -> Self {
self.timer_preset = timer_preset;
self
}
}

impl<'de> Deserialize<'de> for NewFocus {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Raw {
title: String,
#[serde(default)]
description: String,
#[serde(default)]
timer_preset: Option<TimerPreset>,
}
let raw = Raw::deserialize(d)?;
let nf = NewFocus::new(raw.title, raw.description).map_err(serde::de::Error::custom)?;
Ok(nf.with_timer_preset(raw.timer_preset))
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -80,7 +132,7 @@ impl Proposal {
}
}
ProposalKind::NewFocus { new_focus } => {
if new_focus.title.trim().is_empty() {
if new_focus.title().trim().is_empty() {
return Err(ProposalValidationError::NewFocusEmptyTitle);
}
}
Expand Down Expand Up @@ -139,6 +191,9 @@ mod tests {

#[test]
fn new_focus_requires_title() {
// Struct literal is deliberate: NewFocus::new rejects empty titles, so
// this exercises Proposal::validate as a defense-in-depth fallback for
// any path that bypasses the constructor.
let p = ok_proposal(ProposalKind::NewFocus {
new_focus: NewFocus {
title: "".into(),
Expand Down Expand Up @@ -168,6 +223,30 @@ mod tests {
);
}

#[test]
fn new_focus_new_accepts_valid_title() {
let nf = NewFocus::new("Ship it", "the bug").unwrap();
assert_eq!(nf.title, "Ship it");
assert_eq!(nf.description, "the bug");
assert_eq!(nf.timer_preset, None);
}

#[test]
fn new_focus_new_rejects_empty_title() {
assert_eq!(
NewFocus::new("", "desc").unwrap_err(),
DomainError::EmptyTitle
);
}

#[test]
fn new_focus_new_rejects_whitespace_title() {
assert_eq!(
NewFocus::new(" ", "desc").unwrap_err(),
DomainError::EmptyTitle
);
}

#[test]
fn add_task_kind_serializes_with_tag() {
let p = ok_proposal(ProposalKind::AddTask {
Expand All @@ -183,11 +262,7 @@ mod tests {
#[test]
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(),
});
let json = serde_json::to_string(&original).unwrap();
let back: Proposal = serde_json::from_str(&json).unwrap();
Expand Down
Loading
Loading