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