Skip to main content

things3_cloud/commands/
new.rs

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