Skip to main content

things3_cloud/commands/
new.rs

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    /// Task title
20    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}