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