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