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
20 changes: 20 additions & 0 deletions crates/commands/src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,31 @@ mod tests {
fn delete_focus(&self, _focus_id: &str) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn rename_focus(&self, _focus_id: &str, _title: &str) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn append_task(&self, _focus_id: &str, _text: &str) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn delete_task(&self, _focus_id: &str, _index: usize) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn update_task(
&self,
_focus_id: &str,
_index: usize,
_text: &str,
) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn toggle_task(
&self,
_focus_id: &str,
_index: usize,
_done: bool,
) -> Result<(), FocusStoreError> {
unimplemented!()
}
}

fn focus_with_tasks(id: &str, count: usize) -> Focus {
Expand All @@ -166,6 +185,7 @@ mod tests {
.map(|i| Task {
id: format!("{id}:{i}"),
text: format!("t{i}"),
done: false,
})
.collect(),
timer: None,
Expand Down
109 changes: 109 additions & 0 deletions crates/commands/src/focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,38 @@ impl Commands {
Ok(())
}

pub fn rename_focus(&self, focus_id: &str, title: &str) -> Result<(), CommandError> {
let trimmed = title.trim();
if trimmed.is_empty() {
return Err(CommandError::from(
adhd_ranch_domain::DomainError::EmptyTitle,
));
}
self.store.rename_focus(focus_id, trimmed)?;
Ok(())
}

pub fn update_task(
&self,
focus_id: &str,
index: usize,
text: &str,
) -> Result<(), CommandError> {
let text = TaskText::new(text)?;
self.store.update_task(focus_id, index, text.as_str())?;
Ok(())
}

pub fn toggle_task(
&self,
focus_id: &str,
index: usize,
done: bool,
) -> Result<(), CommandError> {
self.store.toggle_task(focus_id, index, done)?;
Ok(())
}

pub fn caps(&self) -> Caps {
self.settings.caps
}
Expand Down Expand Up @@ -144,6 +176,83 @@ mod tests {
assert!(matches!(err, CommandError::BadRequest(_)));
}

#[test]
fn rename_focus_updates_title() {
let (commands, _dir) = build_commands(0);
let created = commands
.create_focus(CreateFocusInput {
title: "Old".into(),
description: String::new(),
timer_preset: None,
})
.unwrap();
commands.rename_focus(&created.id, "New").unwrap();
let focuses = commands.list_focuses().unwrap();
assert_eq!(focuses[0].title, "New");
}

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

#[test]
fn update_task_blank_text_returns_bad_request() {
let (commands, _dir) = build_commands(0);
let created = commands
.create_focus(CreateFocusInput {
title: "Has tasks".into(),
description: String::new(),
timer_preset: None,
})
.unwrap();
commands.append_task(&created.id, "first").unwrap();
let err = commands.update_task(&created.id, 0, " ").unwrap_err();
assert!(matches!(err, CommandError::BadRequest(_)));
}

#[test]
fn update_task_replaces_text() {
let (commands, _dir) = build_commands(0);
let created = commands
.create_focus(CreateFocusInput {
title: "Has tasks".into(),
description: String::new(),
timer_preset: None,
})
.unwrap();
commands.append_task(&created.id, "old").unwrap();
commands.update_task(&created.id, 0, "new").unwrap();
let focuses = commands.list_focuses().unwrap();
assert_eq!(focuses[0].tasks[0].text, "new");
}

#[test]
fn toggle_task_round_trip() {
let (commands, _dir) = build_commands(0);
let created = commands
.create_focus(CreateFocusInput {
title: "Has tasks".into(),
description: String::new(),
timer_preset: None,
})
.unwrap();
commands.append_task(&created.id, "thing").unwrap();
commands.toggle_task(&created.id, 0, true).unwrap();
commands.toggle_task(&created.id, 0, false).unwrap();
let focuses = commands.list_focuses().unwrap();
assert!(!focuses[0].tasks[0].done);
}
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
1 change: 1 addition & 0 deletions crates/domain/src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ mod tests {
.map(|i| Task {
id: format!("{id}:{i}"),
text: format!("t{i}"),
done: false,
})
.collect(),
timer: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/domain/src/focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub struct FocusId(pub String);
pub struct Task {
pub id: String,
pub text: String,
#[serde(default)]
pub done: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -73,6 +75,7 @@ mod tests {
tasks: vec![Task {
id: "abc:0".into(),
text: "step one".into(),
done: false,
}],
timer: None,
};
Expand Down
15 changes: 10 additions & 5 deletions crates/domain/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,24 @@ fn parse_tasks(body: &str, focus_id: &str) -> Vec<Task> {
body.lines()
.filter_map(|line| {
let line = line.trim_start();
let rest = line
.strip_prefix("- [ ]")
.or_else(|| line.strip_prefix("- [x]"))?;
let (done, rest) = if let Some(rest) = line.strip_prefix("- [x]") {
(true, rest)
} else if let Some(rest) = line.strip_prefix("- [ ]") {
(false, rest)
} else {
return None;
};
let text = rest.trim().to_string();
if text.is_empty() {
return None;
}
Some(text)
Some((done, text))
})
.enumerate()
.map(|(index, text)| Task {
.map(|(index, (done, text))| Task {
id: format!("{focus_id}:{index}"),
text,
done,
})
.collect()
}
Expand Down
52 changes: 50 additions & 2 deletions crates/http-api/src/router/focuses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ use super::{ApiError, AppState, FocusCatalogEntry};
pub(super) fn routes() -> Router<AppState> {
Router::new()
.route("/focuses", get(list).post(create))
.route("/focuses/:id", delete(delete_focus))
.route("/focuses/:id", delete(delete_focus).patch(rename_focus))
.route("/focuses/:id/tasks", post(append_task))
.route("/focuses/:id/tasks/:idx", delete(delete_task))
.route(
"/focuses/:id/tasks/:idx",
delete(delete_task).patch(patch_task),
)
}

async fn list(State(state): State<AppState>) -> Result<Json<Vec<FocusCatalogEntry>>, ApiError> {
Expand Down Expand Up @@ -72,3 +75,48 @@ async fn delete_task(
.map_err(ApiError::from)?;
Ok(StatusCode::NO_CONTENT)
}

#[derive(Debug, Deserialize)]
struct RenameFocusRequest {
title: String,
}

async fn rename_focus(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<RenameFocusRequest>,
) -> Result<StatusCode, ApiError> {
state
.commands
.rename_focus(&id, &req.title)
.map_err(ApiError::from)?;
Ok(StatusCode::NO_CONTENT)
}

#[derive(Debug, Deserialize)]
struct PatchTaskRequest {
#[serde(default)]
text: Option<String>,
#[serde(default)]
done: Option<bool>,
}

async fn patch_task(
State(state): State<AppState>,
Path((id, idx)): Path<(String, usize)>,
Json(req): Json<PatchTaskRequest>,
) -> Result<StatusCode, ApiError> {
if let Some(text) = req.text.as_deref() {
state
.commands
.update_task(&id, idx, text)
.map_err(ApiError::from)?;
}
if let Some(done) = req.done {
state
.commands
.toggle_task(&id, idx, done)
.map_err(ApiError::from)?;
}
Ok(StatusCode::NO_CONTENT)
}
97 changes: 97 additions & 0 deletions crates/http-api/src/router/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,103 @@ async fn post_empty(app: &Router, uri: &str) -> axum::http::Response<Body> {
.unwrap()
}

async fn patch_json(
app: &Router,
uri: &str,
body: serde_json::Value,
) -> axum::http::Response<Body> {
app.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap()
}

#[tokio::test]
async fn patch_focus_renames_title() {
let dir = TempDir::new().unwrap();
let h = make_app(dir.path());
write_focus(
&h.focuses_root,
"f1",
&focus_md("f1", "Old", "x", "2026-04-30T12:00:00Z"),
);
let resp = patch_json(&h.app, "/focuses/f1", serde_json::json!({"title": "New"})).await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap();
assert!(content.contains("title: New"));
assert!(!content.contains("title: Old"));
}

#[tokio::test]
async fn patch_focus_blank_title_returns_400() {
let dir = TempDir::new().unwrap();
let h = make_app(dir.path());
write_focus(
&h.focuses_root,
"f1",
&focus_md("f1", "Old", "x", "2026-04-30T12:00:00Z"),
);
let resp = patch_json(&h.app, "/focuses/f1", serde_json::json!({"title": " "})).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn patch_task_updates_text() {
let dir = TempDir::new().unwrap();
let h = make_app(dir.path());
write_focus(
&h.focuses_root,
"f1",
&focus_md("f1", "T", "x", "2026-04-30T12:00:00Z"),
);
let resp = patch_json(
&h.app,
"/focuses/f1/tasks/0",
serde_json::json!({"text": "rewrote"}),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap();
assert!(content.contains("- [ ] rewrote"));
}

#[tokio::test]
async fn patch_task_toggles_done() {
let dir = TempDir::new().unwrap();
let h = make_app(dir.path());
write_focus(
&h.focuses_root,
"f1",
&focus_md("f1", "T", "x", "2026-04-30T12:00:00Z"),
);
let resp = patch_json(
&h.app,
"/focuses/f1/tasks/0",
serde_json::json!({"done": true}),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap();
assert!(content.contains("- [x] one"));

let resp = patch_json(
&h.app,
"/focuses/f1/tasks/0",
serde_json::json!({"done": false}),
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap();
assert!(content.contains("- [ ] one"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[tokio::test]
async fn health_returns_ok() {
let dir = TempDir::new().unwrap();
Expand Down
Loading
Loading