1use crate::engine::Engine;
21use crate::entity::MetadataValue;
22use crate::workspace::MountCapability;
23
24pub const DEFAULT_DUE_WINDOW: &str = "90d";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum DueWindow {
32 Days(u32),
33 Months(u32),
34 Years(u32),
35}
36
37pub fn parse_due_window(input: &str) -> Result<DueWindow, String> {
41 let err = || {
42 format!(
43 "invalid window {input:?}: expected <N>d (days), <N>m (months), or <N>y (years) — \
44 e.g. 90d, 6m, 2y"
45 )
46 };
47 let (num, unit) = input.split_at(input.len().saturating_sub(1));
48 let n: u32 = num.parse().map_err(|_| err())?;
49 match unit {
50 "d" => Ok(DueWindow::Days(n)),
51 "m" => Ok(DueWindow::Months(n)),
52 "y" => Ok(DueWindow::Years(n)),
53 _ => Err(err()),
54 }
55}
56
57pub fn pinned_days_since_epoch(s: &str) -> Option<u64> {
65 let (y, m, d) = parse_ymd(s)?;
66 u64::try_from(days_from_civil(y, m, d)).ok()
67}
68
69fn parse_ymd(s: &str) -> Option<(i64, u32, u32)> {
70 let mut it = s.splitn(3, '-');
71 let y: i64 = it.next()?.parse().ok()?;
72 let m: u32 = it.next()?.parse().ok()?;
73 let d: u32 = it
74 .next()?
75 .get(..2)
76 .unwrap_or(it.next().unwrap_or(""))
77 .parse()
78 .ok()?;
79 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
80 return None;
81 }
82 Some((y, m, d))
83}
84
85fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
86 let y = if m <= 2 { y - 1 } else { y };
87 let era = if y >= 0 { y } else { y - 399 } / 400;
88 let yoe = y - era * 400;
89 let mp = ((m + 9) % 12) as i64;
90 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
91 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
92 era * 146097 + doe - 719468
93}
94
95fn civil_from_days(z: i64) -> (i64, u32, u32) {
96 let z = z + 719468;
97 let era = if z >= 0 { z } else { z - 146096 } / 146097;
98 let doe = z - era * 146097;
99 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
100 let y = yoe + era * 400;
101 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
102 let mp = (5 * doy + 2) / 153;
103 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
104 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
105 (if m <= 2 { y + 1 } else { y }, m, d)
106}
107
108pub fn civil_from_days_pub(days_since_epoch: i64) -> (i64, u32, u32) {
111 civil_from_days(days_since_epoch)
112}
113
114fn last_day_of_month(y: i64, m: u32) -> u32 {
115 match m {
116 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
117 4 | 6 | 9 | 11 => 30,
118 _ => {
119 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 {
120 29
121 } else {
122 28
123 }
124 }
125 }
126}
127
128fn window_end(today: (i64, u32, u32), window: &DueWindow) -> String {
131 let (y, m, d) = today;
132 let (ey, em, ed) = match window {
133 DueWindow::Days(n) => civil_from_days(days_from_civil(y, m, d) + *n as i64),
134 DueWindow::Months(n) => {
135 let total = (y * 12 + (m as i64 - 1)) + *n as i64;
136 let ny = total.div_euclid(12);
137 let nm = (total.rem_euclid(12) + 1) as u32;
138 (ny, nm, d.min(last_day_of_month(ny, nm)))
139 }
140 DueWindow::Years(n) => {
141 let ny = y + *n as i64;
142 (ny, m, d.min(last_day_of_month(ny, m)))
143 }
144 };
145 format!("{ey:04}-{em:02}-{ed:02}")
146}
147
148struct DueEntry {
150 mem: String,
151 third_party: bool,
152 id: String,
153 title: String,
154 date: String,
155 status: String,
156 lead: Option<(String, String)>,
157 overdue: bool,
158 days_until: i64,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
167pub struct DueRow {
168 pub mem: String,
169 pub id: String,
170 pub title: String,
171 pub date: String,
172 pub status: String,
173 #[serde(skip_serializing_if = "std::ops::Not::not")]
174 pub third_party: bool,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub days_past: Option<u32>,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub days_until: Option<u32>,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub lead: Option<DueLead>,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
187pub struct DueLead {
188 pub section: String,
189 pub body: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
195pub struct DueBrief {
196 pub today: String,
197 pub through: String,
198 pub mems: Vec<String>,
200 pub overdue: Vec<DueRow>,
201 pub due_soon: Vec<DueRow>,
202}
203
204impl Engine {
205 pub fn render_due_brief(
213 &self,
214 today: &str,
215 window: &DueWindow,
216 mem_filter: Option<&str>,
217 ) -> Result<String, String> {
218 let (today_iso, end, declaring_mems, entries) =
219 self.collect_due(today, window, mem_filter)?;
220 Ok(render_due_entries(
221 &today_iso,
222 &end,
223 window,
224 &declaring_mems,
225 &entries,
226 ))
227 }
228
229 pub fn due_brief(
235 &self,
236 today: &str,
237 window: &DueWindow,
238 mem_filter: Option<&str>,
239 ) -> Result<DueBrief, String> {
240 let (today_iso, end, mems, entries) = self.collect_due(today, window, mem_filter)?;
241 let row = |e: &DueEntry| DueRow {
242 mem: e.mem.clone(),
243 id: e.id.clone(),
244 title: e.title.clone(),
245 date: e.date.clone(),
246 status: e.status.clone(),
247 third_party: e.third_party,
248 days_past: e.overdue.then_some((-e.days_until) as u32),
249 days_until: (!e.overdue).then_some(e.days_until as u32),
250 lead: e.lead.as_ref().map(|(section, body)| DueLead {
251 section: section.clone(),
252 body: body.clone(),
253 }),
254 };
255 Ok(DueBrief {
256 today: today_iso,
257 through: end,
258 mems,
259 overdue: entries.iter().filter(|e| e.overdue).map(row).collect(),
260 due_soon: entries.iter().filter(|e| !e.overdue).map(row).collect(),
261 })
262 }
263
264 #[allow(clippy::type_complexity)]
265 fn collect_due(
266 &self,
267 today: &str,
268 window: &DueWindow,
269 mem_filter: Option<&str>,
270 ) -> Result<(String, String, Vec<String>, Vec<DueEntry>), String> {
271 let today_ymd = parse_ymd(today)
272 .ok_or_else(|| format!("invalid date {today:?}: expected YYYY-MM-DD"))?;
273 let today_iso = format!("{:04}-{:02}-{:02}", today_ymd.0, today_ymd.1, today_ymd.2);
274 let today_days = days_from_civil(today_ymd.0, today_ymd.1, today_ymd.2);
275 let end = window_end(today_ymd, window);
276
277 let mut entries: Vec<DueEntry> = Vec::new();
281 let mut declaring_mems: Vec<String> = Vec::new();
282 for mounted in &self.mounts {
283 let mem = mounted.mount.mem.as_str();
284 if let Some(filter) = mem_filter
285 && mem != filter
286 {
287 continue;
288 }
289 let Some(schema) = self.schemas.get(mem) else {
290 continue;
291 };
292 let declares = schema.types.values().any(|t| t.due.is_some());
293 if !declares {
294 continue;
295 }
296 declaring_mems.push(mem.to_string());
297 let third_party = mounted.mount.capability == MountCapability::ReadOnly;
298 for entity in self.store.all_entities() {
299 if entity.mem != mem || entity.stub {
300 continue;
301 }
302 let Some(td) = schema.types.get(&entity.entity_type) else {
303 continue;
304 };
305 let Some(due) = &td.due else { continue };
306 let status = match entity.metadata.get(&due.status_field) {
307 Some(MetadataValue::String(s)) => s.clone(),
308 _ => continue,
309 };
310 if !due.open_values.contains(&status) {
311 continue;
312 }
313 let date = match entity.metadata.get(&due.date_field) {
314 Some(MetadataValue::String(s)) => s.clone(),
315 _ => continue,
316 };
317 let date_part = date.get(..10).unwrap_or(&date).to_string();
321 let Some((dy, dm, dd)) = parse_ymd(&date_part) else {
322 continue;
323 };
324 if date_part.as_str() > end.as_str() {
325 continue;
326 }
327 let days_until = days_from_civil(dy, dm, dd) - today_days;
328 let lead = due.lead_section.as_ref().and_then(|key| {
329 entity
330 .sections
331 .get(key)
332 .filter(|body| !body.trim().is_empty())
333 .map(|body| (key.clone(), body.trim().to_string()))
334 });
335 entries.push(DueEntry {
336 mem: mem.to_string(),
337 third_party,
338 id: entity.id.to_string(),
339 title: entity.title.clone(),
340 date: date_part.clone(),
341 status,
342 lead,
343 overdue: date_part.as_str() < today_iso.as_str(),
344 days_until,
345 });
346 }
347 }
348
349 entries.sort_by(|a, b| {
352 b.overdue
353 .cmp(&a.overdue)
354 .then_with(|| a.date.cmp(&b.date))
355 .then_with(|| a.id.cmp(&b.id))
356 });
357 declaring_mems.sort();
358 declaring_mems.dedup();
359 Ok((today_iso, end, declaring_mems, entries))
360 }
361}
362
363fn render_due_entries(
364 today_iso: &str,
365 end: &str,
366 window: &DueWindow,
367 declaring_mems: &[String],
368 entries: &[DueEntry],
369) -> String {
370 {
371 let window_label = match window {
372 DueWindow::Days(n) => format!("{n}d"),
373 DueWindow::Months(n) => format!("{n}m"),
374 DueWindow::Years(n) => format!("{n}y"),
375 };
376 let mut out = String::new();
377 out.push_str(&format!(
378 "# Due brief — {today_iso}, window {window_label} (through {end})\n\n"
379 ));
380 if declaring_mems.is_empty() {
381 out.push_str(
382 "No mounted mem's schema declares a due axis (`due:` on a type). \
383 Nothing to render.\n",
384 );
385 return out;
386 }
387 out.push_str(&format!("Mems: {}\n\n", declaring_mems.join(", ")));
388 if entries.is_empty() {
389 out.push_str("Nothing open is due in this window.\n");
390 return out;
391 }
392 let overdue_count = entries.iter().filter(|e| e.overdue).count();
393 out.push_str(&format!(
394 "{} entr{} ({} overdue)\n\n",
395 entries.len(),
396 if entries.len() == 1 { "y" } else { "ies" },
397 overdue_count
398 ));
399 for e in entries {
400 let marker = if e.overdue {
401 format!(" **OVERDUE** ({} days past)", -e.days_until)
402 } else {
403 format!(" (in {} days)", e.days_until)
404 };
405 let origin = if e.third_party { " [third-party]" } else { "" };
406 out.push_str(&format!(
407 "- `{}` — {} — **{}**{} (status: {}, mem: {}{})\n",
408 e.id, e.title, e.date, marker, e.status, e.mem, origin
409 ));
410 if let Some((key, body)) = &e.lead {
411 if e.third_party {
412 out.push_str(&format!(" - {key} (third-party, quoted):\n"));
415 for line in body.lines() {
416 out.push_str(&format!(" > {line}\n"));
417 }
418 } else {
419 out.push_str(&format!(" - {key}:\n"));
420 for line in body.lines() {
421 out.push_str(&format!(" {line}\n"));
422 }
423 }
424 }
425 }
426 out
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use std::path::Path;
434 use tempfile::TempDir;
435
436 use crate::backend::MemBackend;
437 use crate::storage::FilesystemMemWriter;
438 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
439
440 fn frist_schema_dir(root: &Path) {
441 let d = root.join("schemas").join("frist-schema");
442 std::fs::create_dir_all(d.join("types")).unwrap();
443 std::fs::write(
444 d.join("schema.yaml"),
445 "name: frist\nversion: 0.1.0\ndescription: t\nwhen_to_use: due tests\ntypes:\n - obligation\n - note\nrelationships:\n mode: strict\n definitions:\n - name: PART_OF\n description: h\n default_weight: 3.0\n - name: _default\n description: d\n default_weight: 1.0\ncommunity:\n resolution: 1.0\n seed: 42\n",
446 )
447 .unwrap();
448 std::fs::write(
449 d.join("types").join("obligation.yaml"),
450 "name: obligation\ndescription: dated obligation\nwhen_to_use: due tests\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\n - key: vorlauf\n heading: Vorlauf\n search_weight: 1.0\n write_rules: []\nmetadata_fields:\n - key: faellig_am\n description: due date\n field_type: date\n required: true\n - key: status\n description: state\n field_type: string\n required: true\n default_value: offen\n enum_values: [offen, in_arbeit, erledigt]\ndue:\n date_field: faellig_am\n status_field: status\n open_values: [offen, in_arbeit]\n lead_section: vorlauf\ntitle_weight: 100.0\ntext_fields: [body]\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields: [title, body, status, faellig_am]\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
451 )
452 .unwrap();
453 std::fs::write(
454 d.join("types").join("note.yaml"),
455 "name: note\ndescription: undeclared type\nwhen_to_use: due tests\nsections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields: [body]\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields: [title, body]\nhealth_required_fields: []\nstaleness_threshold_days: 90\nwrite_rules: []\n",
456 )
457 .unwrap();
458 }
459
460 fn obligation_md(title: &str, date: &str, status: &str, vorlauf: Option<&str>) -> String {
461 let lead = vorlauf
462 .map(|v| format!("\n## Vorlauf\n\n{v}\n"))
463 .unwrap_or_default();
464 format!(
465 "---\ntype: obligation\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nfaellig_am: {date}\nstatus: {status}\n---\n# {title}\n\n## Body\n\nB.\n{lead}"
466 )
467 }
468
469 fn mount_with(mem: &str, path: std::path::PathBuf, capability: MountCapability) -> Mount {
470 Mount {
471 mem: mem.to_string(),
472 schema: Some("frist@0.1.0".parse().unwrap()),
473 storage: MountStorage::Folder { path },
474 capability,
475 lifecycle: MountLifecycle::Eager,
476 cross_linkable: true,
477 migration_target: None,
478 }
479 }
480
481 #[test]
486 fn due_brief_membership_order_and_labels() {
487 let tmp = TempDir::new().unwrap();
488 frist_schema_dir(tmp.path());
489 let own = tmp.path().join("own");
490 let foreign = tmp.path().join("foreign");
491 std::fs::create_dir_all(own.join(".memstead")).unwrap();
492 std::fs::create_dir_all(foreign.join(".memstead")).unwrap();
493 for dir in [&own, &foreign] {
496 std::fs::write(
497 dir.join(".memstead/config.json"),
498 "{\n \"version\": \"1.0.0\",\n \"description\": \"due fixture\",\n \"schema\": \"frist@0.1.0\"\n}",
499 )
500 .unwrap();
501 }
502 std::fs::write(
503 own.join("wartung.md"),
504 obligation_md(
505 "Wartung",
506 "2026-09-01",
507 "offen",
508 Some("Handwerker beauftragen"),
509 ),
510 )
511 .unwrap();
512 std::fs::write(
513 own.join("frist-alt.md"),
514 obligation_md("Frist Alt", "2026-07-01", "in_arbeit", None),
515 )
516 .unwrap();
517 std::fs::write(
518 own.join("weit-weg.md"),
519 obligation_md("Weit Weg", "2027-06-01", "offen", None),
520 )
521 .unwrap();
522 std::fs::write(
523 own.join("erledigt.md"),
524 obligation_md("Erledigt", "2026-08-20", "erledigt", None),
525 )
526 .unwrap();
527 std::fs::write(
528 own.join("plain-note.md"),
529 "---\ntype: note\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Plain Note\n\n## Body\n\nB.\n",
530 )
531 .unwrap();
532 std::fs::write(
534 own.join("b-gleich.md"),
535 obligation_md("B Gleich", "2026-09-10", "offen", None),
536 )
537 .unwrap();
538 std::fs::write(
539 own.join("a-gleich.md"),
540 obligation_md("A Gleich", "2026-09-10", "offen", None),
541 )
542 .unwrap();
543 std::fs::write(
545 foreign.join("fremd-frist.md"),
546 obligation_md(
547 "Fremd Frist",
548 "2026-06-15",
549 "offen",
550 Some("Nur zur Kenntnis"),
551 ),
552 )
553 .unwrap();
554
555 let own_writer = FilesystemMemWriter::new(own.clone());
556 let foreign_writer = FilesystemMemWriter::new(foreign.clone());
557 let engine = Engine::from_mounts_with_schemas_dir(
558 vec![
559 (
560 mount_with("own", own, MountCapability::Write),
561 Box::new(own_writer) as Box<dyn MemBackend>,
562 ),
563 (
564 mount_with("foreign", foreign, MountCapability::ReadOnly),
565 Box::new(foreign_writer) as Box<dyn MemBackend>,
566 ),
567 ],
568 Some(&tmp.path().join("schemas")),
569 )
570 .unwrap();
571
572 let brief = engine
573 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
574 .unwrap();
575
576 for present in [
579 "own--wartung",
580 "own--frist-alt",
581 "foreign--fremd-frist",
582 "own--a-gleich",
583 "own--b-gleich",
584 ] {
585 assert!(brief.contains(present), "{present} missing:\n{brief}");
586 }
587 for absent in ["weit-weg", "erledigt", "plain-note"] {
588 assert!(!brief.contains(absent), "{absent} leaked:\n{brief}");
589 }
590
591 let pos = |needle: &str| brief.find(needle).unwrap();
594 assert!(
595 pos("foreign--fremd-frist") < pos("own--frist-alt"),
596 "{brief}"
597 );
598 assert!(pos("own--frist-alt") < pos("own--wartung"), "{brief}");
599 assert!(pos("own--wartung") < pos("own--a-gleich"), "{brief}");
600 assert!(pos("own--a-gleich") < pos("own--b-gleich"), "{brief}");
601
602 assert!(brief.contains("**2026-07-01** **OVERDUE**"), "{brief}");
604 assert!(brief.contains("Handwerker beauftragen"), "{brief}");
605 assert!(brief.contains("status: offen"), "{brief}");
606
607 assert!(brief.contains("[third-party]"), "{brief}");
609 assert!(brief.contains("vorlauf (third-party, quoted):"), "{brief}");
610 assert!(brief.contains("> Nur zur Kenntnis"), "{brief}");
611 assert!(brief.contains(" Handwerker beauftragen"), "{brief}");
613
614 let again = engine
616 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
617 .unwrap();
618 assert_eq!(brief, again);
619
620 let own_only = engine
622 .render_due_brief("2026-08-10", &DueWindow::Days(90), Some("own"))
623 .unwrap();
624 assert!(!own_only.contains("foreign--fremd-frist"), "{own_only}");
625 assert!(own_only.contains("own--wartung"), "{own_only}");
626 }
627
628 #[test]
630 fn window_parse_and_calendar_math() {
631 assert_eq!(parse_due_window("90d").unwrap(), DueWindow::Days(90));
632 assert_eq!(parse_due_window("6m").unwrap(), DueWindow::Months(6));
633 assert_eq!(parse_due_window("2y").unwrap(), DueWindow::Years(2));
634 for bad in ["", "d", "90", "90w", "-1d", "1.5m"] {
635 let err = parse_due_window(bad).unwrap_err();
636 assert!(err.contains("<N>d"), "error names accepted forms: {err}");
637 }
638 assert_eq!(
640 window_end((2026, 1, 31), &DueWindow::Months(1)),
641 "2026-02-28"
642 );
643 assert_eq!(
644 window_end((2024, 1, 31), &DueWindow::Months(1)),
645 "2024-02-29"
646 );
647 assert_eq!(
648 window_end((2026, 8, 10), &DueWindow::Days(90)),
649 "2026-11-08"
650 );
651 assert_eq!(
652 window_end((2026, 11, 15), &DueWindow::Months(2)),
653 "2027-01-15"
654 );
655 assert_eq!(
656 window_end((2024, 2, 29), &DueWindow::Years(1)),
657 "2025-02-28"
658 );
659 }
660
661 #[test]
664 fn no_declaring_schema_renders_honest_empty_brief() {
665 let tmp = TempDir::new().unwrap();
666 let mem_dir = tmp.path().to_path_buf();
667 let writer = FilesystemMemWriter::new(mem_dir.clone());
668 let engine = Engine::from_mounts(vec![(
669 crate::engine::test_helpers::folder_mount("specs", mem_dir),
670 Box::new(writer) as Box<dyn MemBackend>,
671 )])
672 .unwrap();
673 let brief = engine
674 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
675 .unwrap();
676 assert!(
677 brief.contains("No mounted mem's schema declares a due axis"),
678 "{brief}"
679 );
680 }
681}
682
683#[cfg(test)]
684mod obligation_builtin_tests {
685 use super::*;
686 use tempfile::TempDir;
687
688 use crate::backend::MemBackend;
689 use crate::engine::test_helpers::{cli_actor, empty_create_args};
690 use crate::storage::FilesystemMemWriter;
691 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
692
693 fn obligation_mount(mem: &str, path: std::path::PathBuf) -> Mount {
694 Mount {
695 mem: mem.to_string(),
696 schema: Some("obligation@0.1.0".parse().unwrap()),
697 storage: MountStorage::Folder { path },
698 capability: MountCapability::Write,
699 lifecycle: MountLifecycle::Eager,
700 cross_linkable: true,
701 migration_target: None,
702 }
703 }
704
705 fn obligation_engine(tmp: &TempDir) -> Engine {
706 let mem_dir = tmp.path().join("duties");
707 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
708 std::fs::write(
709 mem_dir.join(".memstead/config.json"),
710 "{\n \"version\": \"1.0.0\",\n \"description\": \"obligation fixture\",\n \"schema\": \"obligation@0.1.0\"\n}",
711 )
712 .unwrap();
713 let writer = FilesystemMemWriter::new(mem_dir.clone());
714 Engine::from_mounts(vec![(
715 obligation_mount("duties", mem_dir),
716 Box::new(writer) as Box<dyn MemBackend>,
717 )])
718 .unwrap()
719 }
720
721 fn obligation_args(
722 mem: &str,
723 title: &str,
724 due_date: &str,
725 status: &str,
726 ) -> crate::engine::CreateEntityArgs {
727 let mut args = empty_create_args(mem, title);
728 args.entity_type = "obligation".to_string();
729 args.sections = indexmap::IndexMap::from_iter([
730 ("duty".to_string(), "Who owes what.".to_string()),
731 ("consequence".to_string(), "What forfeits.".to_string()),
732 ]);
733 args.metadata = indexmap::IndexMap::from_iter([
734 ("due_date".to_string(), due_date.to_string()),
735 ("status".to_string(), status.to_string()),
736 ]);
737 args.relations = vec![crate::ops::RelateArg {
738 target: crate::entity::EntityId::new(mem, "some-subject"),
739 rel_type: "CONCERNS".to_string(),
740 description: None,
741 }];
742 args
743 }
744
745 #[test]
750 fn shipped_obligation_schema_accepts_refuses_and_renders_due() {
751 let tmp = TempDir::new().unwrap();
752 let mut engine = obligation_engine(&tmp);
753 let (actor, client) = cli_actor();
754
755 engine
757 .create_entity(
758 obligation_args(
759 "duties",
760 "Renew Registration No. 4711 & File Proof",
761 "2026-09-01",
762 "open",
763 ),
764 actor,
765 Some(&client),
766 None,
767 )
768 .expect("conformant obligation lands");
769 engine
770 .create_entity(
771 obligation_args("duties", "Overdue Filing", "2026-07-01", "in_progress"),
772 actor,
773 Some(&client),
774 None,
775 )
776 .expect("second obligation lands");
777 engine
778 .create_entity(
779 obligation_args("duties", "Done Duty", "2026-08-01", "done"),
780 actor,
781 Some(&client),
782 None,
783 )
784 .map(|_| ())
785 .unwrap_err(); let err = engine
790 .create_entity(
791 obligation_args("duties", "Bad Status", "2026-09-01", "unknown"),
792 actor,
793 Some(&client),
794 None,
795 )
796 .unwrap_err();
797 assert_eq!(err.code(), "INVALID_ENUM_VALUE", "{err}");
798
799 let brief = engine
801 .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
802 .unwrap();
803 let pos = |n: &str| brief.find(n).unwrap_or(usize::MAX);
804 assert!(brief.contains("duties--overdue-filing"), "{brief}");
805 assert!(
806 brief.contains("duties--renew-registration-no-4711-file-proof"),
807 "{brief}"
808 );
809 assert!(pos("duties--overdue-filing") < pos("duties--renew-registration-no-4711"));
810 assert!(brief.contains("**OVERDUE**"), "{brief}");
811 }
812
813 #[test]
817 fn shipped_constraints_refuse_like_the_field_schema() {
818 let tmp = TempDir::new().unwrap();
819 let mut engine = obligation_engine(&tmp);
820 let (actor, client) = cli_actor();
821
822 let err = engine
824 .create_entity(
825 obligation_args("duties", "Done Without Date", "2026-08-01", "done"),
826 actor,
827 Some(&client),
828 None,
829 )
830 .unwrap_err();
831 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED", "{err}");
832 assert!(err.to_string().contains("completed_on"), "{err}");
833
834 let mut args = obligation_args("duties", "Critical Unowned", "2026-09-01", "open");
836 args.metadata
837 .insert("criticality".to_string(), "high".to_string());
838 let err = engine
839 .create_entity(args, actor, Some(&client), None)
840 .unwrap_err();
841 assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED", "{err}");
842 assert!(err.to_string().contains("responsible"), "{err}");
843
844 let mut args = obligation_args("duties", "About Nothing", "2026-09-01", "open");
846 args.relations.clear();
847 let err = engine
848 .create_entity(args, actor, Some(&client), None)
849 .unwrap_err();
850 assert!(
851 err.to_string().contains("CONCERNS") || err.code().contains("REQUIRED_OUTGOING"),
852 "required_outgoing must refuse: {err} ({})",
853 err.code()
854 );
855
856 let mut args = obligation_args("duties", "Done Properly", "2026-08-01", "done");
859 args.metadata
860 .insert("completed_on".to_string(), "2026-08-01".to_string());
861 engine
862 .create_entity(args, actor, Some(&client), None)
863 .expect("done with completed_on lands");
864 let mut args = obligation_args("duties", "Critical Owned", "2026-09-01", "open");
865 args.metadata
866 .insert("criticality".to_string(), "high".to_string());
867 args.metadata
868 .insert("responsible".to_string(), "Operations".to_string());
869 engine
870 .create_entity(args, actor, Some(&client), None)
871 .expect("high with responsible lands");
872 }
873}