1use std::collections::BTreeMap;
2
3use anyhow::Result;
4use clap::Args;
5
6use crate::{
7 app::Cli,
8 commands::Command,
9 common::{DIM, GREEN, ICONS, colored, day_to_timestamp, parse_day},
10 wire::{
11 task::{TaskPatch, TaskStart},
12 wire_object::{EntityType, WireObject},
13 },
14};
15
16#[derive(Debug, Args)]
17#[command(about = "Set when and deadline")]
18pub struct ScheduleArgs {
19 pub task_id: String,
21 #[arg(
22 long,
23 short = 'w',
24 help = "When: anytime, today, evening, someday, or YYYY-MM-DD"
25 )]
26 pub when: Option<String>,
27 #[arg(long = "deadline", short = 'd', help = "Deadline date (YYYY-MM-DD)")]
28 pub deadline_date: Option<String>,
29 #[arg(long = "clear-deadline", short = 'D', help = "Clear deadline")]
30 pub clear_deadline: bool,
31}
32
33#[derive(Debug, Clone)]
34struct SchedulePlan {
35 task: crate::store::Task,
36 update: TaskPatch,
37 labels: Vec<String>,
38}
39
40fn build_schedule_plan(
41 args: &ScheduleArgs,
42 store: &crate::store::ThingsStore,
43 now: f64,
44 today_ts: i64,
45) -> std::result::Result<SchedulePlan, String> {
46 let (task_opt, err, _) = store.resolve_mark_identifier(&args.task_id);
47 let Some(task) = task_opt else {
48 return Err(err);
49 };
50
51 let mut update = TaskPatch::default();
52 let mut when_label: Option<String> = None;
53
54 if let Some(when_raw) = &args.when {
55 let when = when_raw.trim();
56 let when_l = when.to_lowercase();
57 if when_l == "anytime" {
58 update.start_location = Some(TaskStart::Anytime);
59 update.scheduled_date = Some(None);
60 update.today_index_reference = Some(None);
61 update.evening_bit = Some(0);
62 when_label = Some("anytime".to_string());
63 } else if when_l == "today" {
64 update.start_location = Some(TaskStart::Anytime);
65 update.scheduled_date = Some(Some(today_ts));
66 update.today_index_reference = Some(Some(today_ts));
67 update.evening_bit = Some(0);
68 when_label = Some("today".to_string());
69 } else if when_l == "evening" {
70 update.start_location = Some(TaskStart::Anytime);
71 update.scheduled_date = Some(Some(today_ts));
72 update.today_index_reference = Some(Some(today_ts));
73 update.evening_bit = Some(1);
74 when_label = Some("evening".to_string());
75 } else if when_l == "someday" {
76 update.start_location = Some(TaskStart::Someday);
77 update.scheduled_date = Some(None);
78 update.today_index_reference = Some(None);
79 update.evening_bit = Some(0);
80 when_label = Some("someday".to_string());
81 } else {
82 let when_day = match parse_day(Some(when), "--when") {
83 Ok(Some(day)) => day,
84 Ok(None) => {
85 return Err(
86 "--when requires anytime, someday, today, or YYYY-MM-DD".to_string()
87 );
88 }
89 Err(e) => return Err(e),
90 };
91 let day_ts = day_to_timestamp(when_day);
92 if day_ts <= today_ts {
93 update.start_location = Some(TaskStart::Anytime);
94 } else {
95 update.start_location = Some(TaskStart::Someday);
96 }
97 update.scheduled_date = Some(Some(day_ts));
98 update.today_index_reference = Some(Some(day_ts));
99 update.evening_bit = Some(0);
100 when_label = Some(format!("when={when}"));
101 }
102 }
103
104 if let Some(deadline) = &args.deadline_date {
105 let day = match parse_day(Some(deadline), "--deadline") {
106 Ok(Some(day)) => day,
107 Ok(None) => return Err("--deadline requires YYYY-MM-DD".to_string()),
108 Err(e) => return Err(e),
109 };
110 update.deadline = Some(Some(day_to_timestamp(day) as f64));
111 }
112 if args.clear_deadline {
113 update.deadline = Some(None);
114 }
115
116 if update.is_empty() {
117 return Err("No schedule changes requested.".to_string());
118 }
119
120 update.modification_date = Some(now);
121
122 let mut labels = Vec::new();
123 if update.start_location.is_some() {
124 labels.push(when_label.unwrap_or_else(|| "when".to_string()));
125 }
126 if update.deadline.is_some() {
127 if update.deadline == Some(None) {
128 labels.push("deadline=none".to_string());
129 } else {
130 labels.push(format!(
131 "deadline={}",
132 args.deadline_date.clone().unwrap_or_default()
133 ));
134 }
135 }
136
137 Ok(SchedulePlan {
138 task,
139 update,
140 labels,
141 })
142}
143
144impl Command for ScheduleArgs {
145 fn run_with_ctx(
146 &self,
147 cli: &Cli,
148 out: &mut dyn std::io::Write,
149 ctx: &mut dyn crate::cmd_ctx::CmdCtx,
150 ) -> Result<()> {
151 let store = cli.load_store()?;
152 let plan =
153 match build_schedule_plan(self, &store, ctx.now_timestamp(), ctx.today_timestamp()) {
154 Ok(plan) => plan,
155 Err(err) => {
156 eprintln!("{err}");
157 return Ok(());
158 }
159 };
160
161 let mut changes = BTreeMap::new();
162 changes.insert(
163 plan.task.uuid.to_string(),
164 WireObject::update(
165 EntityType::from(plan.task.entity.clone()),
166 plan.update.clone(),
167 ),
168 );
169
170 if let Err(e) = ctx.commit_changes(changes, None) {
171 eprintln!("Failed to schedule item: {e}");
172 return Ok(());
173 }
174
175 writeln!(
176 out,
177 "{} {} {} {}",
178 colored(&format!("{} Scheduled", ICONS.done), &[GREEN], cli.no_color),
179 plan.task.title,
180 colored(&plan.task.uuid, &[DIM], cli.no_color),
181 colored(
182 &format!("({})", plan.labels.join(", ")),
183 &[DIM],
184 cli.no_color
185 )
186 )?;
187
188 Ok(())
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use serde_json::json;
195
196 use super::*;
197 use crate::{
198 store::{ThingsStore, fold_items},
199 wire::{
200 task::{TaskProps, TaskStart, TaskStatus, TaskType},
201 wire_object::{EntityType, WireItem, WireObject},
202 },
203 };
204
205 const NOW: f64 = 1_700_000_333.0;
206 const TASK_UUID: &str = "A7h5eCi24RvAWKC3Hv3muf";
207 const TODAY: i64 = 1_700_000_000;
208
209 fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
210 let mut item: WireItem = BTreeMap::new();
211 for (uuid, obj) in entries {
212 item.insert(uuid, obj);
213 }
214 ThingsStore::from_raw_state(&fold_items([item]))
215 }
216
217 fn task(uuid: &str, title: &str) -> (String, WireObject) {
218 (
219 uuid.to_string(),
220 WireObject::create(
221 EntityType::Task6,
222 TaskProps {
223 title: title.to_string(),
224 item_type: TaskType::Todo,
225 status: TaskStatus::Incomplete,
226 start_location: TaskStart::Inbox,
227 sort_index: 0,
228 creation_date: Some(1.0),
229 modification_date: Some(1.0),
230 ..Default::default()
231 },
232 ),
233 )
234 }
235
236 #[test]
237 fn schedule_when_variants_payloads() {
238 let store = build_store(vec![task(TASK_UUID, "Schedule me")]);
239 let future_ts = day_to_timestamp(
240 parse_day(Some("2099-05-10"), "--when")
241 .expect("parse")
242 .expect("day"),
243 );
244 let cases = [
245 (
246 "today",
247 json!({"st":1,"sr":TODAY,"tir":TODAY,"sb":0,"md":NOW}),
248 ),
249 (
250 "someday",
251 json!({"st":2,"sr":null,"tir":null,"sb":0,"md":NOW}),
252 ),
253 (
254 "anytime",
255 json!({"st":1,"sr":null,"tir":null,"sb":0,"md":NOW}),
256 ),
257 (
258 "evening",
259 json!({"st":1,"sr":TODAY,"tir":TODAY,"sb":1,"md":NOW}),
260 ),
261 (
262 "2099-05-10",
263 json!({"st":2,"sr":future_ts,"tir":future_ts,"sb":0,"md":NOW}),
264 ),
265 ];
266
267 for (when, expected) in cases {
268 let plan = build_schedule_plan(
269 &ScheduleArgs {
270 task_id: TASK_UUID.to_string(),
271 when: Some(when.to_string()),
272 deadline_date: None,
273 clear_deadline: false,
274 },
275 &store,
276 NOW,
277 TODAY,
278 )
279 .expect("schedule plan");
280 assert_eq!(
281 serde_json::to_value(plan.update).expect("to value"),
282 expected
283 );
284 }
285 }
286
287 #[test]
288 fn schedule_deadline_and_clear_payloads() {
289 let store = build_store(vec![task(TASK_UUID, "Schedule me")]);
290 let deadline_ts = day_to_timestamp(
291 parse_day(Some("2034-02-01"), "--deadline")
292 .expect("parse")
293 .expect("day"),
294 );
295
296 let deadline = build_schedule_plan(
297 &ScheduleArgs {
298 task_id: TASK_UUID.to_string(),
299 when: None,
300 deadline_date: Some("2034-02-01".to_string()),
301 clear_deadline: false,
302 },
303 &store,
304 NOW,
305 TODAY,
306 )
307 .expect("deadline plan");
308 assert_eq!(
309 serde_json::to_value(deadline.update).expect("to value"),
310 json!({"dd": deadline_ts as f64, "md": NOW})
311 );
312
313 let clear = build_schedule_plan(
314 &ScheduleArgs {
315 task_id: TASK_UUID.to_string(),
316 when: None,
317 deadline_date: None,
318 clear_deadline: true,
319 },
320 &store,
321 NOW,
322 TODAY,
323 )
324 .expect("clear plan");
325 assert_eq!(
326 serde_json::to_value(clear.update).expect("to value"),
327 json!({"dd": null, "md": NOW})
328 );
329 }
330
331 #[test]
332 fn schedule_rejections() {
333 let store = build_store(vec![task(TASK_UUID, "A")]);
334 let no_changes = build_schedule_plan(
335 &ScheduleArgs {
336 task_id: TASK_UUID.to_string(),
337 when: None,
338 deadline_date: None,
339 clear_deadline: false,
340 },
341 &store,
342 NOW,
343 TODAY,
344 )
345 .expect_err("no changes");
346 assert_eq!(no_changes, "No schedule changes requested.");
347
348 let invalid_when = build_schedule_plan(
349 &ScheduleArgs {
350 task_id: TASK_UUID.to_string(),
351 when: Some("2024-02-31".to_string()),
352 deadline_date: None,
353 clear_deadline: false,
354 },
355 &store,
356 NOW,
357 TODAY,
358 )
359 .expect_err("invalid when");
360 assert_eq!(
361 invalid_when,
362 "Invalid --when date: 2024-02-31 (expected YYYY-MM-DD)"
363 );
364 }
365}