1use crate::app::Cli;
2use crate::commands::Command;
3use crate::common::{
4 DIM, GREEN, ICONS, colored, day_to_timestamp, parse_day, resolve_tag_ids, task6_note,
5};
6use crate::store::Task;
7use crate::wire::task::{TaskProps, TaskStart, TaskStatus, TaskType};
8use crate::wire::wire_object::{EntityType, WireObject};
9use anyhow::Result;
10use chrono::{TimeZone, Utc};
11use clap::Args;
12use serde_json::json;
13use std::cmp::Reverse;
14use std::collections::BTreeMap;
15
16#[derive(Debug, Args)]
17#[command(about = "Create a new task")]
18pub struct NewArgs {
19 pub title: String,
21 #[arg(long = "in", default_value = "inbox", help = "Container: inbox, clear, project UUID/prefix, or area UUID/prefix")]
22 pub in_target: String,
23 #[arg(long, help = "Schedule: anytime, someday, today, evening, or YYYY-MM-DD")]
24 pub when: Option<String>,
25 #[arg(long = "before", help = "Insert before this sibling task UUID/prefix")]
26 pub before_id: Option<String>,
27 #[arg(long = "after", help = "Insert after this sibling task UUID/prefix")]
28 pub after_id: Option<String>,
29 #[arg(long, default_value = "", help = "Task notes")]
30 pub notes: String,
31 #[arg(long, help = "Comma-separated tags (titles or UUID prefixes)")]
32 pub tags: Option<String>,
33 #[arg(long = "deadline", help = "Deadline date (YYYY-MM-DD)")]
34 pub deadline_date: Option<String>,
35}
36
37fn base_new_props(title: &str, now: f64) -> TaskProps {
38 TaskProps {
39 title: title.to_string(),
40 item_type: TaskType::Todo,
41 status: TaskStatus::Incomplete,
42 start_location: TaskStart::Inbox,
43 creation_date: Some(now),
44 modification_date: Some(now),
45 conflict_overrides: Some(json!({"_t": "oo", "sn": {}})),
46 ..Default::default()
47 }
48}
49
50fn task_bucket(task: &Task, store: &crate::store::ThingsStore) -> Vec<String> {
51 if task.is_heading() {
52 return vec![
53 "heading".to_string(),
54 task.project.clone().map(|v| v.to_string()).unwrap_or_default(),
55 ];
56 }
57 if task.is_project() {
58 return vec![
59 "project".to_string(),
60 task.area.clone().map(|v| v.to_string()).unwrap_or_default(),
61 ];
62 }
63 if let Some(project_uuid) = store.effective_project_uuid(task) {
64 return vec![
65 "task-project".to_string(),
66 project_uuid.to_string(),
67 task.action_group
68 .clone()
69 .map(|v| v.to_string())
70 .unwrap_or_default(),
71 ];
72 }
73 if let Some(area_uuid) = store.effective_area_uuid(task) {
74 return vec![
75 "task-area".to_string(),
76 area_uuid.to_string(),
77 i32::from(task.start).to_string(),
78 ];
79 }
80 vec!["task-root".to_string(), i32::from(task.start).to_string()]
81}
82
83fn props_bucket(props: &TaskProps) -> Vec<String> {
84 if let Some(project_uuid) = props.parent_project_ids.first() {
85 return vec![
86 "task-project".to_string(),
87 project_uuid.to_string(),
88 String::new(),
89 ];
90 }
91 if let Some(area_uuid) = props.area_ids.first() {
92 let st = i32::from(props.start_location);
93 return vec![
94 "task-area".to_string(),
95 area_uuid.to_string(),
96 st.to_string(),
97 ];
98 }
99 let st = i32::from(props.start_location);
100 vec!["task-root".to_string(), st.to_string()]
101}
102
103fn plan_ix_insert(ordered: &[Task], insert_at: usize) -> (i32, Vec<(String, i32, String)>) {
104 let prev_ix = if insert_at > 0 {
105 Some(ordered[insert_at - 1].index)
106 } else {
107 None
108 };
109 let next_ix = if insert_at < ordered.len() {
110 Some(ordered[insert_at].index)
111 } else {
112 None
113 };
114 let mut updates = Vec::new();
115
116 if prev_ix.is_none() && next_ix.is_none() {
117 return (0, updates);
118 }
119 if prev_ix.is_none() {
120 return (next_ix.unwrap_or(0) - 1, updates);
121 }
122 if next_ix.is_none() {
123 return (prev_ix.unwrap_or(0) + 1, updates);
124 }
125 if prev_ix.unwrap_or(0) + 1 < next_ix.unwrap_or(0) {
126 return ((prev_ix.unwrap_or(0) + next_ix.unwrap_or(0)) / 2, updates);
127 }
128
129 let stride = 1024;
130 let mut new_index = stride;
131 let mut idx = 1;
132 for i in 0..=ordered.len() {
133 let target_ix = idx * stride;
134 if i == insert_at {
135 new_index = target_ix;
136 idx += 1;
137 continue;
138 }
139 let source_idx = if i < insert_at { i } else { i - 1 };
140 if source_idx < ordered.len() {
141 let entry = &ordered[source_idx];
142 if entry.index != target_ix {
143 updates.push((entry.uuid.to_string(), target_ix, entry.entity.clone()));
144 }
145 idx += 1;
146 }
147 }
148 (new_index, updates)
149}
150
151#[derive(Debug, Clone)]
152struct NewPlan {
153 new_uuid: String,
154 changes: BTreeMap<String, WireObject>,
155 title: String,
156}
157
158fn build_new_plan(
159 args: &NewArgs,
160 store: &crate::store::ThingsStore,
161 now: f64,
162 today_ts: i64,
163 next_id: &mut dyn FnMut() -> String,
164) -> std::result::Result<NewPlan, String> {
165 let today = Utc
166 .timestamp_opt(today_ts, 0)
167 .single()
168 .unwrap_or_else(Utc::now)
169 .date_naive()
170 .and_hms_opt(0, 0, 0)
171 .map(|d| Utc.from_utc_datetime(&d))
172 .unwrap_or_else(Utc::now);
173 let title = args.title.trim();
174 if title.is_empty() {
175 return Err("Task title cannot be empty.".to_string());
176 }
177
178 let mut props = base_new_props(title, now);
179 if !args.notes.is_empty() {
180 props.notes = Some(task6_note(&args.notes));
181 }
182
183 let anchor_id = args.before_id.as_ref().or(args.after_id.as_ref());
184 let mut anchor: Option<Task> = None;
185 if let Some(anchor_id) = anchor_id {
186 let (task, err, _ambiguous) = store.resolve_task_identifier(anchor_id);
187 if task.is_none() {
188 return Err(err);
189 }
190 anchor = task;
191 }
192
193 let in_target = args.in_target.trim();
194 if !in_target.eq_ignore_ascii_case("inbox") {
195 let (project, _, _) = store.resolve_mark_identifier(in_target);
196 let (area, _, _) = store.resolve_area_identifier(in_target);
197 let project_uuid = project.as_ref().and_then(|p| {
198 if p.is_project() {
199 Some(p.uuid.clone())
200 } else {
201 None
202 }
203 });
204 let area_uuid = area.map(|a| a.uuid);
205
206 if project_uuid.is_some() && area_uuid.is_some() {
207 return Err(format!(
208 "Ambiguous --in target '{}' (matches project and area).",
209 in_target
210 ));
211 }
212
213 if project.is_some() && project_uuid.is_none() {
214 return Err("--in target must be inbox, a project ID, or an area ID.".to_string());
215 }
216
217 if let Some(project_uuid) = project_uuid {
218 props.parent_project_ids = vec![project_uuid.into()];
219 props.start_location = TaskStart::Anytime;
220 } else if let Some(area_uuid) = area_uuid {
221 props.area_ids = vec![area_uuid.into()];
222 props.start_location = TaskStart::Anytime;
223 } else {
224 return Err(format!("Container not found: {}", in_target));
225 }
226 }
227
228 if let Some(when_raw) = &args.when {
229 let when = when_raw.trim();
230 if when.eq_ignore_ascii_case("anytime") {
231 props.start_location = TaskStart::Anytime;
232 props.scheduled_date = None;
233 } else if when.eq_ignore_ascii_case("someday") {
234 props.start_location = TaskStart::Someday;
235 props.scheduled_date = None;
236 } else if when.eq_ignore_ascii_case("today") {
237 props.start_location = TaskStart::Anytime;
238 props.scheduled_date = Some(today_ts);
239 props.today_index_reference = Some(today_ts);
240 } else {
241 let parsed = match parse_day(Some(when), "--when") {
242 Ok(Some(day)) => day,
243 Ok(None) => {
244 return Err("--when requires anytime, someday, today, or YYYY-MM-DD".to_string());
245 }
246 Err(err) => return Err(err),
247 };
248 let day_ts = day_to_timestamp(parsed);
249 props.start_location = TaskStart::Someday;
250 props.scheduled_date = Some(day_ts);
251 props.today_index_reference = Some(day_ts);
252 }
253 }
254
255 if let Some(tags) = &args.tags {
256 let (tag_ids, tag_err) = resolve_tag_ids(store, tags);
257 if !tag_err.is_empty() {
258 return Err(tag_err);
259 }
260 props.tag_ids = tag_ids;
261 }
262
263 if let Some(deadline_date) = &args.deadline_date {
264 let parsed = match parse_day(Some(deadline_date), "--deadline") {
265 Ok(Some(day)) => day,
266 Ok(None) => return Err("--deadline requires YYYY-MM-DD".to_string()),
267 Err(err) => return Err(err),
268 };
269 props.deadline = Some(day_to_timestamp(parsed) as i64);
270 }
271
272 let anchor_is_today = anchor
273 .as_ref()
274 .map(|a| a.start == TaskStart::Anytime && (a.is_today(&today) || a.evening))
275 .unwrap_or(false);
276 let target_bucket = props_bucket(&props);
277
278 if let Some(anchor) = &anchor
279 && !anchor_is_today && task_bucket(anchor, store) != target_bucket
280 {
281 return Err("Cannot place new task relative to an item in a different container/list.".to_string());
282 }
283
284 let mut index_updates: Vec<(String, i32, String)> = Vec::new();
285 let mut siblings = store
286 .tasks_by_uuid
287 .values()
288 .filter(|t| !t.trashed && t.status == TaskStatus::Incomplete && task_bucket(t, store) == target_bucket)
289 .cloned()
290 .collect::<Vec<_>>();
291 siblings.sort_by_key(|t| (t.index, t.uuid.clone()));
292
293 let mut structural_insert_at = 0usize;
294 if let Some(anchor) = &anchor
295 && task_bucket(anchor, store) == target_bucket
296 {
297 let anchor_pos = siblings.iter().position(|t| t.uuid == anchor.uuid);
298 let Some(anchor_pos) = anchor_pos else {
299 return Err("Anchor not found in target list.".to_string());
300 };
301 structural_insert_at = if args.before_id.is_some() { anchor_pos } else { anchor_pos + 1 };
302 }
303
304 let (structural_ix, structural_updates) = plan_ix_insert(&siblings, structural_insert_at);
305 props.sort_index = structural_ix;
306 index_updates.extend(structural_updates);
307
308 let new_is_today = props.start_location == TaskStart::Anytime
309 && props.scheduled_date.map_or(false, |sr| sr <= today_ts);
310 if new_is_today && anchor_is_today {
311 let mut section_evening = if props.evening_bit != 0 {
312 1
313 } else {
314 0
315 };
316
317 if anchor_is_today
318 && let Some(anchor) = &anchor
319 {
320 section_evening = if anchor.evening { 1 } else { 0 };
321 props.evening_bit = section_evening;
322 }
323
324 let mut today_siblings = store
325 .tasks_by_uuid
326 .values()
327 .filter(|t| {
328 !t.trashed
329 && t.status == TaskStatus::Incomplete
330 && t.start == TaskStart::Anytime
331 && (t.is_today(&today) || t.evening)
332 && (if t.evening { 1 } else { 0 }) == section_evening
333 })
334 .cloned()
335 .collect::<Vec<_>>();
336 today_siblings.sort_by_key(|task| {
337 let tir = task.today_index_reference.unwrap_or(0);
338 (Reverse(tir), task.today_index, Reverse(task.index))
339 });
340
341 let mut today_insert_at = 0usize;
342 if anchor_is_today
343 && let Some(anchor) = &anchor
344 && (if anchor.evening { 1 } else { 0 }) == section_evening
345 && let Some(anchor_pos) = today_siblings.iter().position(|t| t.uuid == anchor.uuid)
346 {
347 today_insert_at = if args.before_id.is_some() { anchor_pos } else { anchor_pos + 1 };
348 }
349
350 let prev_today = if today_insert_at > 0 {
351 today_siblings.get(today_insert_at - 1)
352 } else {
353 None
354 };
355 let next_today = today_siblings.get(today_insert_at);
356
357 if let Some(next_today) = next_today {
358 let next_tir = next_today.today_index_reference.unwrap_or(today_ts);
359 props.today_index_reference = Some(next_tir);
360 props.today_sort_index = next_today.today_index - 1;
361 } else if let Some(prev_today) = prev_today {
362 let prev_tir = prev_today.today_index_reference.unwrap_or(today_ts);
363 props.today_index_reference = Some(prev_tir);
364 props.today_sort_index = prev_today.today_index + 1;
365 } else {
366 props.today_index_reference = Some(today_ts);
367 props.today_sort_index = 0;
368 }
369 }
370
371 let new_uuid = next_id();
372
373 let mut changes = BTreeMap::new();
374 changes.insert(
375 new_uuid.clone(),
376 WireObject::create(EntityType::Task6, props.clone()),
377 );
378
379 for (task_uuid, task_index, task_entity) in index_updates {
380 use crate::wire::task::TaskPatch;
381 changes.insert(
382 task_uuid,
383 WireObject::update(EntityType::from(task_entity), TaskPatch {
384 sort_index: Some(task_index),
385 modification_date: Some(now),
386 ..Default::default()
387 }),
388 );
389 }
390
391 Ok(NewPlan {
392 new_uuid,
393 changes,
394 title: title.to_string(),
395 })
396}
397
398impl Command for NewArgs {
399 fn run_with_ctx(
400 &self,
401 cli: &Cli,
402 out: &mut dyn std::io::Write,
403 ctx: &mut dyn crate::cmd_ctx::CmdCtx,
404 ) -> Result<()> {
405 let store = cli.load_store()?;
406 let now = ctx.now_timestamp();
407 let today = ctx.today_timestamp();
408 let mut id_gen = || ctx.next_id();
409 let plan = match build_new_plan(self, &store, now, today, &mut id_gen) {
410 Ok(plan) => plan,
411 Err(err) => {
412 eprintln!("{err}");
413 return Ok(());
414 }
415 };
416
417 if let Err(e) = ctx.commit_changes(plan.changes, None) {
418 eprintln!("Failed to create task: {e}");
419 return Ok(());
420 }
421
422 writeln!(
423 out,
424 "{} {} {}",
425 colored(&format!("{} Created", ICONS.done), &[GREEN], cli.no_color),
426 plan.title,
427 colored(&plan.new_uuid, &[DIM], cli.no_color)
428 )?;
429 Ok(())
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::store::{ThingsStore, fold_items};
437 use crate::wire::area::AreaProps;
438 use crate::wire::tags::TagProps;
439 use crate::wire::task::{TaskProps, TaskStart, TaskStatus, TaskType};
440 use serde_json::json;
441
442 const NOW: f64 = 1_700_000_000.0;
443 const NEW_UUID: &str = "MpkEei6ybkFS2n6SXvwfLf";
444 const INBOX_ANCHOR_UUID: &str = "A7h5eCi24RvAWKC3Hv3muf";
445 const INBOX_OTHER_UUID: &str = "KGvAPpMrzHAKMdgMiERP1V";
446 const PROJECT_UUID: &str = "JFdhhhp37fpryAKu8UXwzK";
447 const AREA_UUID: &str = "74rgJf6Qh9wYp2TcVk8mNB";
448 const TAG_A_UUID: &str = "By8mN2qRk5Wv7Xc9Dt3HpL";
449 const TAG_B_UUID: &str = "Cv9nP3sTk6Xw8Yd4Eu5JqM";
450 const TODAY: i64 = 1_700_000_000;
451
452 fn build_store(entries: Vec<(String, WireObject)>) -> ThingsStore {
453 let mut item = BTreeMap::new();
454 for (uuid, obj) in entries {
455 item.insert(uuid, obj);
456 }
457 ThingsStore::from_raw_state(&fold_items([item]))
458 }
459
460 fn task(uuid: &str, title: &str, st: i32, ix: i32, sr: Option<i64>, tir: Option<i64>, ti: i32) -> (String, WireObject) {
461 (
462 uuid.to_string(),
463 WireObject::create(
464 EntityType::Task6,
465 TaskProps {
466 title: title.to_string(),
467 item_type: TaskType::Todo,
468 status: TaskStatus::Incomplete,
469 start_location: TaskStart::from(st),
470 sort_index: ix,
471 scheduled_date: sr,
472 today_index_reference: tir,
473 today_sort_index: ti,
474 creation_date: Some(1.0),
475 modification_date: Some(1.0),
476 ..Default::default()
477 },
478 ),
479 )
480 }
481
482 fn project(uuid: &str, title: &str) -> (String, WireObject) {
483 (
484 uuid.to_string(),
485 WireObject::create(
486 EntityType::Task6,
487 TaskProps {
488 title: title.to_string(),
489 item_type: TaskType::Project,
490 status: TaskStatus::Incomplete,
491 start_location: TaskStart::Anytime,
492 sort_index: 0,
493 creation_date: Some(1.0),
494 modification_date: Some(1.0),
495 ..Default::default()
496 },
497 ),
498 )
499 }
500
501 fn area(uuid: &str, title: &str) -> (String, WireObject) {
502 (
503 uuid.to_string(),
504 WireObject::create(
505 EntityType::Area3,
506 AreaProps {
507 title: title.to_string(),
508 sort_index: 0,
509 ..Default::default()
510 },
511 ),
512 )
513 }
514
515 fn tag(uuid: &str, title: &str) -> (String, WireObject) {
516 (
517 uuid.to_string(),
518 WireObject::create(
519 EntityType::Tag4,
520 TagProps {
521 title: title.to_string(),
522 sort_index: 0,
523 ..Default::default()
524 },
525 ),
526 )
527 }
528
529 #[test]
530 fn new_payload_parity_cases() {
531 let mut id_gen = || NEW_UUID.to_string();
532
533 let bare = build_new_plan(
534 &NewArgs {
535 title: "Ship release".to_string(),
536 in_target: "inbox".to_string(),
537 when: None,
538 before_id: None,
539 after_id: None,
540 notes: String::new(),
541 tags: None,
542 deadline_date: None,
543 },
544 &build_store(vec![]),
545 NOW,
546 TODAY,
547 &mut id_gen,
548 )
549 .expect("bare");
550 let bare_json = serde_json::to_value(bare.changes).expect("to value");
551 assert_eq!(bare_json[NEW_UUID]["t"], json!(0));
552 assert_eq!(bare_json[NEW_UUID]["e"], json!("Task6"));
553 assert_eq!(bare_json[NEW_UUID]["p"]["tt"], json!("Ship release"));
554 assert_eq!(bare_json[NEW_UUID]["p"]["st"], json!(0));
555 assert_eq!(bare_json[NEW_UUID]["p"]["cd"], json!(NOW));
556 assert_eq!(bare_json[NEW_UUID]["p"]["md"], json!(NOW));
557
558 let when_today = build_new_plan(
559 &NewArgs {
560 title: "Task today".to_string(),
561 in_target: "inbox".to_string(),
562 when: Some("today".to_string()),
563 before_id: None,
564 after_id: None,
565 notes: String::new(),
566 tags: None,
567 deadline_date: None,
568 },
569 &build_store(vec![]),
570 NOW,
571 TODAY,
572 &mut id_gen,
573 )
574 .expect("today");
575 let p = &serde_json::to_value(when_today.changes).expect("to value")[NEW_UUID]["p"];
576 assert_eq!(p["st"], json!(1));
577 assert_eq!(p["sr"], json!(TODAY));
578 assert_eq!(p["tir"], json!(TODAY));
579
580 let full_store = build_store(vec![
581 project(PROJECT_UUID, "Roadmap"),
582 area(AREA_UUID, "Work"),
583 tag(TAG_A_UUID, "urgent"),
584 tag(TAG_B_UUID, "backend"),
585 ]);
586 let in_project = build_new_plan(
587 &NewArgs {
588 title: "Project task".to_string(),
589 in_target: PROJECT_UUID.to_string(),
590 when: None,
591 before_id: None,
592 after_id: None,
593 notes: "line one".to_string(),
594 tags: Some("urgent,backend".to_string()),
595 deadline_date: Some("2032-05-06".to_string()),
596 },
597 &full_store,
598 NOW,
599 TODAY,
600 &mut id_gen,
601 )
602 .expect("in project");
603 let p = &serde_json::to_value(in_project.changes).expect("to value")[NEW_UUID]["p"];
604 let deadline_ts = day_to_timestamp(
605 parse_day(Some("2032-05-06"), "--deadline")
606 .expect("parse")
607 .expect("day"),
608 );
609 assert_eq!(p["pr"], json!([PROJECT_UUID]));
610 assert_eq!(p["st"], json!(1));
611 assert_eq!(p["tg"], json!([TAG_A_UUID, TAG_B_UUID]));
612 assert_eq!(p["dd"], json!(deadline_ts));
613 }
614
615 #[test]
616 fn new_after_gap_and_rebalance() {
617 let mut id_gen = || NEW_UUID.to_string();
618 let gap_store = build_store(vec![
619 task(INBOX_ANCHOR_UUID, "Anchor", 0, 1024, None, None, 0),
620 task(INBOX_OTHER_UUID, "Other", 0, 2048, None, None, 0),
621 ]);
622 let gap = build_new_plan(
623 &NewArgs {
624 title: "Inserted".to_string(),
625 in_target: "inbox".to_string(),
626 when: None,
627 before_id: None,
628 after_id: Some(INBOX_ANCHOR_UUID.to_string()),
629 notes: String::new(),
630 tags: None,
631 deadline_date: None,
632 },
633 &gap_store,
634 NOW,
635 TODAY,
636 &mut id_gen,
637 )
638 .expect("gap");
639 assert_eq!(
640 serde_json::to_value(gap.changes).expect("to value")[NEW_UUID]["p"]["ix"],
641 json!(1536)
642 );
643
644 let rebalance_store = build_store(vec![
645 task(INBOX_ANCHOR_UUID, "Anchor", 0, 1024, None, None, 0),
646 task(INBOX_OTHER_UUID, "Other", 0, 1025, None, None, 0),
647 ]);
648 let rebalance = build_new_plan(
649 &NewArgs {
650 title: "Inserted".to_string(),
651 in_target: "inbox".to_string(),
652 when: None,
653 before_id: None,
654 after_id: Some(INBOX_ANCHOR_UUID.to_string()),
655 notes: String::new(),
656 tags: None,
657 deadline_date: None,
658 },
659 &rebalance_store,
660 NOW,
661 TODAY,
662 &mut id_gen,
663 )
664 .expect("rebalance");
665 let rb = serde_json::to_value(rebalance.changes).expect("to value");
666 assert_eq!(rb[NEW_UUID]["p"]["ix"], json!(2048));
667 assert_eq!(rb[INBOX_OTHER_UUID]["p"], json!({"ix":3072,"md":NOW}));
668 }
669
670 #[test]
671 fn new_rejections() {
672 let mut id_gen = || NEW_UUID.to_string();
673 let empty_title = build_new_plan(
674 &NewArgs {
675 title: " ".to_string(),
676 in_target: "inbox".to_string(),
677 when: None,
678 before_id: None,
679 after_id: None,
680 notes: String::new(),
681 tags: None,
682 deadline_date: None,
683 },
684 &build_store(vec![]),
685 NOW,
686 TODAY,
687 &mut id_gen,
688 )
689 .expect_err("empty title");
690 assert_eq!(empty_title, "Task title cannot be empty.");
691
692 let unknown_container = build_new_plan(
693 &NewArgs {
694 title: "Ship".to_string(),
695 in_target: "nope".to_string(),
696 when: None,
697 before_id: None,
698 after_id: None,
699 notes: String::new(),
700 tags: None,
701 deadline_date: None,
702 },
703 &build_store(vec![]),
704 NOW,
705 TODAY,
706 &mut id_gen,
707 )
708 .expect_err("unknown container");
709 assert_eq!(unknown_container, "Container not found: nope");
710 }
711}