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