-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_mutator.py
More file actions
342 lines (276 loc) · 10.8 KB
/
Copy path_mutator.py
File metadata and controls
342 lines (276 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
"""Task mutation logic."""
from __future__ import annotations
import pendulum
from taskmark._dates import today as get_today
from taskmark._recurrence import next_occurrence
from taskmark._types import (
PendingChange,
RejectedChange,
StateMachine,
StateTransition,
Task,
TaskChanges,
TaskStore,
UpdateResult,
UpdateStatus,
_Remove,
)
# ─────────────────────────────────────────────────────────────
# State Transition Actions
# ─────────────────────────────────────────────────────────────
def _enter_done(task: Task, today: str) -> Task | None:
"""Action when entering 'done' state.
Sets done_date if not already set.
Handles recurrence by creating a new task for the next occurrence.
"""
if task.done_date is None:
task.done_date = today
# Handle recurrence - create next occurrence
if task.recurrence:
new_task = _handle_recurrence(task, today)
task.recurrence = None # Completed task loses recurrence
return new_task
return None
def _exit_done(task: Task, today: str) -> None:
"""Action when leaving 'done' state. Clears done_date."""
task.done_date = None
def _enter_blocked(task: Task, today: str) -> None:
"""Action when entering 'blocked' state. Sets paused_date."""
if task.paused_date is None:
task.paused_date = today
def _exit_blocked(task: Task, today: str) -> None:
"""Action when leaving 'blocked' state. Clears paused_date."""
task.paused_date = None
# ─────────────────────────────────────────────────────────────
# Default State Machine
# ─────────────────────────────────────────────────────────────
DEFAULT_STATE_MACHINE = StateMachine(
transitions=[
# Any state → done: set done_date, handle recurrence
StateTransition("*", "done", on_enter=_enter_done),
# done → open: clear done_date
StateTransition("done", "open", on_exit=_exit_done),
# Any state → blocked: set paused_date
StateTransition("*", "blocked", on_enter=_enter_blocked),
# blocked → open: clear paused_date
StateTransition("blocked", "open", on_exit=_exit_blocked),
# Any state → cancelled: no special handling
StateTransition("*", "cancelled"),
# Any state → open: no special handling (unless from done/blocked)
StateTransition("*", "open"),
]
)
def find_task(
store: TaskStore,
title: str,
project_path: str | None,
) -> tuple[Task | None, str | None]:
"""Find a task by (title, project_path) key.
Returns:
(task, None) if exactly one match
(None, error_message) if not found or ambiguous
"""
matches: list[Task] = []
def search(tasks: list[Task]) -> None:
for task in tasks:
if task.title == title:
if project_path is None:
if task.project_path is None:
matches.append(task)
elif task.project_path == project_path:
matches.append(task)
search(task.subtasks)
search(store.tasks)
if len(matches) == 0:
return None, "task not found"
if len(matches) > 1:
return None, f"ambiguous: {len(matches)} matches"
return matches[0], None
def apply_changes(
task: Task,
changes: TaskChanges,
store: TaskStore,
today: str,
state_machine: StateMachine | None = None,
) -> tuple[UpdateStatus, list[RejectedChange], Task | None]:
"""Apply changes to a task.
Args:
task: Task to modify
changes: Changes to apply
store: TaskStore (for context)
today: Today's date string
state_machine: State machine for transitions (uses DEFAULT_STATE_MACHINE if None)
Returns:
(status, rejected_changes, new_recurring_task)
"""
if state_machine is None:
state_machine = DEFAULT_STATE_MACHINE
rejected: list[RejectedChange] = []
new_recurring_task: Task | None = None
# Track if state changed for business logic
old_state = task.state
new_state: str = old_state # Will be updated if valid state change
# Apply scalar fields
scalar_fields = [
"title",
"priority",
"estimate_minutes",
"due_date",
"done_date",
"planned_date",
"paused_date",
"created_date",
"started_date",
"recurrence",
]
for field_name in scalar_fields:
value = getattr(changes, field_name)
if value is None:
continue # No change requested
if isinstance(value, _Remove):
if field_name == "title":
rejected.append(RejectedChange(field_name, "title cannot be removed", value))
continue
setattr(task, field_name, None)
else:
setattr(task, field_name, value)
# Apply state (special handling for REMOVE)
if changes.state is not None:
if isinstance(changes.state, _Remove):
rejected.append(RejectedChange("state", "state cannot be removed", changes.state))
else:
new_state = changes.state # Type narrowed to str here
task.state = new_state
# Apply list fields (these set explicit_ values only)
if changes.tags is not None:
task.explicit_tags = list(changes.tags)
task.tags = list(dict.fromkeys(task.inherited_tags + task.explicit_tags))
if changes.assignees is not None:
task.explicit_assignees = list(changes.assignees)
task.assignees = list(dict.fromkeys(task.inherited_assignees + task.explicit_assignees))
if changes.custom_fields is not None:
task.explicit_custom_fields = dict(changes.custom_fields)
task.custom_fields = {
**task.inherited_custom_fields,
**task.explicit_custom_fields,
}
if changes.subtasks is not None:
task.subtasks = list(changes.subtasks)
# State transition via state machine
if new_state != old_state:
new_recurring_task = state_machine.apply(task, old_state, new_state, today)
# Determine status
if rejected:
status = UpdateStatus.PARTIAL
else:
status = UpdateStatus.SUCCESS
return status, rejected, new_recurring_task
def _handle_recurrence(task: Task, today: str) -> Task:
"""Create a new recurring task instance.
Args:
task: The task being completed (still has recurrence field)
today: Today's date string
Returns:
New task instance for the next occurrence
"""
# task.recurrence is guaranteed non-None by caller (_enter_done checks if task.recurrence)
recurrence_pattern: str = task.recurrence # type: ignore[assignment]
# Calculate next occurrence date
base_date = task.planned_date or task.due_date or today
next_date, _warning = next_occurrence(recurrence_pattern, base_date)
# Calculate due date offset if both planned and due were set
next_due: str | None = None
if task.planned_date and task.due_date:
next_due = _calculate_due_offset(task.planned_date, task.due_date, next_date)
elif task.due_date and not task.planned_date:
# Due date without planned - use due as the recurring reference
next_due, _warning = next_occurrence(recurrence_pattern, task.due_date)
# Create new task - starts fresh, inherits heading context
new_task = Task(
state="open",
title=task.title,
priority=task.priority,
estimate_minutes=task.estimate_minutes,
recurrence=task.recurrence, # Keep the recurrence pattern
planned_date=next_date,
due_date=next_due,
# Heading context preserved
project_path=task.inherited_project_path,
inherited_project_path=task.inherited_project_path,
explicit_project_path=None,
tags=list(task.inherited_tags),
inherited_tags=list(task.inherited_tags),
explicit_tags=[],
assignees=list(task.inherited_assignees),
inherited_assignees=list(task.inherited_assignees),
explicit_assignees=[],
custom_fields=dict(task.inherited_custom_fields),
inherited_custom_fields=dict(task.inherited_custom_fields),
explicit_custom_fields={},
# Location - same file, line will be set by writer
file=task.file,
line=task.line, # Placeholder, writer handles insertion
indent=task.indent,
subtasks=[], # Fresh start
)
return new_task
def _calculate_due_offset(planned_date: str, due_date: str, next_planned: str) -> str | None:
"""Calculate the due date offset from planned date.
If original had planned=Jan 1 and due=Jan 5, and next planned=Feb 1,
then next due=Feb 5 (same offset).
"""
from typing import cast
from pendulum import DateTime
try:
planned = cast(DateTime, pendulum.parse(planned_date)).date()
due = cast(DateTime, pendulum.parse(due_date)).date()
next_p = cast(DateTime, pendulum.parse(next_planned)).date()
offset = (due - planned).days
return next_p.add(days=offset).to_date_string()
except Exception:
return None
def record_pending(
store: TaskStore,
task: Task,
new_recurring_task: Task | None,
) -> None:
"""Record a pending change in the store.
Args:
store: TaskStore to record in
task: Modified task
new_recurring_task: Optional new recurring task to insert
"""
if task.file is None or task.line is None:
return # Can't write tasks without location
key = (task.file, task.line)
store._pending[key] = PendingChange(
task=task,
new_recurring_task=new_recurring_task,
)
def update_task(
store: TaskStore,
title: str,
project: str | None,
changes: TaskChanges,
today: str | None = None,
) -> UpdateResult:
"""Update a single task.
This is the internal implementation of the public update() function.
"""
# Find the task
task, error = find_task(store, title, project)
if error:
return UpdateResult(status=UpdateStatus.FAIL, error=error)
assert task is not None # Guaranteed by find_task
# Get today's date
if today is None:
today = get_today()
# Apply changes
status, rejected, new_recurring = apply_changes(task, changes, store, today)
# Record pending change
record_pending(store, task, new_recurring)
return UpdateResult(
status=status,
task=task,
rejected=rejected,
)