Skip to main content

memstead_base/engine/
due.rs

1//! The due-brief (first-author-path plan 08): a schema declares its
2//! deadline axis ([`memstead_schema::DueAxis`]), the engine renders
3//! "what is due next" as one deterministic markdown brief.
4//!
5//! Deterministic given (store, today): no model call, no scoring —
6//! filter, sort, render. The only environmental input is the current
7//! date, taken once per invocation and passed in (injectable in
8//! tests). Ordering: overdue first, then ascending by date, ties
9//! broken by entity id. The renderer lives here so the CLI and UniFFI
10//! serve byte-identical content (the projection-brief precedent);
11//! there is deliberately no MCP tool — briefs are the CLI/app family.
12//!
13//! Read-only mounts participate: a due date in an installed
14//! compliance mem is precisely the multi-stakeholder case, and a
15//! brief is a read surface. Third-party entries carry their origin
16//! label and render as quoted data — a stranger's mem states a
17//! deadline, it does not instruct.
18
19use crate::engine::Engine;
20use crate::entity::MetadataValue;
21use crate::workspace::MountCapability;
22
23/// The default window applied when `--within` is omitted — stated in
24/// the CLI help and the changelog rather than unbounded.
25pub const DEFAULT_DUE_WINDOW: &str = "90d";
26
27/// A parsed relative window: `<N>d` days, `<N>m` calendar months,
28/// `<N>y` calendar years.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum DueWindow {
31    Days(u32),
32    Months(u32),
33    Years(u32),
34}
35
36/// Parse a relative window (`90d`, `6m`, `2y`). The error names the
37/// accepted forms — the caller wraps it in its surface's typed
38/// envelope.
39pub fn parse_due_window(input: &str) -> Result<DueWindow, String> {
40    let err = || {
41        format!(
42            "invalid window {input:?}: expected <N>d (days), <N>m (months), or <N>y (years) — \
43             e.g. 90d, 6m, 2y"
44        )
45    };
46    let (num, unit) = input.split_at(input.len().saturating_sub(1));
47    let n: u32 = num.parse().map_err(|_| err())?;
48    match unit {
49        "d" => Ok(DueWindow::Days(n)),
50        "m" => Ok(DueWindow::Months(n)),
51        "y" => Ok(DueWindow::Years(n)),
52        _ => Err(err()),
53    }
54}
55
56/// Civil-date helpers over ISO `YYYY-MM-DD` strings — exact calendar
57/// math without a date dependency (Howard Hinnant's `days_from_civil`
58/// algorithm). Entities store dates as ISO strings, which order
59/// lexically; arithmetic converts through day numbers.
60fn parse_ymd(s: &str) -> Option<(i64, u32, u32)> {
61    let mut it = s.splitn(3, '-');
62    let y: i64 = it.next()?.parse().ok()?;
63    let m: u32 = it.next()?.parse().ok()?;
64    let d: u32 = it
65        .next()?
66        .get(..2)
67        .unwrap_or(it.next().unwrap_or(""))
68        .parse()
69        .ok()?;
70    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
71        return None;
72    }
73    Some((y, m, d))
74}
75
76fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
77    let y = if m <= 2 { y - 1 } else { y };
78    let era = if y >= 0 { y } else { y - 399 } / 400;
79    let yoe = y - era * 400;
80    let mp = ((m + 9) % 12) as i64;
81    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
82    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
83    era * 146097 + doe - 719468
84}
85
86fn civil_from_days(z: i64) -> (i64, u32, u32) {
87    let z = z + 719468;
88    let era = if z >= 0 { z } else { z - 146096 } / 146097;
89    let doe = z - era * 146097;
90    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
91    let y = yoe + era * 400;
92    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
93    let mp = (5 * doy + 2) / 153;
94    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
95    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
96    (if m <= 2 { y + 1 } else { y }, m, d)
97}
98
99/// Public epoch-days → civil-date conversion for callers that derive
100/// "today" from `SystemTime` (the UniFFI surface). Same algorithm the
101/// window math uses.
102pub 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
120/// `today + window`, as an ISO date string. Month/year addition
121/// clamps the day-of-month (Jan 31 + 1m = Feb 28/29).
122fn 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
140/// One brief entry, collected before rendering.
141struct 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}
151
152impl Engine {
153    /// Render the due-brief: every open entity whose declared due date
154    /// falls inside `(-∞, today + window]`, across every mem whose
155    /// pinned schema declares the axis (writable and read-only mounts
156    /// alike), optionally filtered to one mem. `today` is an ISO
157    /// `YYYY-MM-DD` string — the caller takes it once per invocation,
158    /// tests inject it. A workspace with no declaring schema renders
159    /// an honest empty brief, not an error.
160    pub fn render_due_brief(
161        &self,
162        today: &str,
163        window: &DueWindow,
164        mem_filter: Option<&str>,
165    ) -> Result<String, String> {
166        let today_ymd = parse_ymd(today)
167            .ok_or_else(|| format!("invalid date {today:?}: expected YYYY-MM-DD"))?;
168        let today_iso = format!("{:04}-{:02}-{:02}", today_ymd.0, today_ymd.1, today_ymd.2);
169        let end = window_end(today_ymd, window);
170
171        // Mem → (schema, third_party) for every mount, capability
172        // included: read-only mounts participate as labelled quoted
173        // data.
174        let mut entries: Vec<DueEntry> = Vec::new();
175        let mut declaring_mems: Vec<String> = Vec::new();
176        for mounted in &self.mounts {
177            let mem = mounted.mount.mem.as_str();
178            if let Some(filter) = mem_filter
179                && mem != filter
180            {
181                continue;
182            }
183            let Some(schema) = self.schemas.get(mem) else {
184                continue;
185            };
186            let declares = schema.types.values().any(|t| t.due.is_some());
187            if !declares {
188                continue;
189            }
190            declaring_mems.push(mem.to_string());
191            let third_party = mounted.mount.capability == MountCapability::ReadOnly;
192            for entity in self.store.all_entities() {
193                if entity.mem != mem || entity.stub {
194                    continue;
195                }
196                let Some(td) = schema.types.get(&entity.entity_type) else {
197                    continue;
198                };
199                let Some(due) = &td.due else { continue };
200                let status = match entity.metadata.get(&due.status_field) {
201                    Some(MetadataValue::String(s)) => s.clone(),
202                    _ => continue,
203                };
204                if !due.open_values.contains(&status) {
205                    continue;
206                }
207                let date = match entity.metadata.get(&due.date_field) {
208                    Some(MetadataValue::String(s)) => s.clone(),
209                    _ => continue,
210                };
211                // Dates are ISO strings — take the date part, refuse
212                // malformed values silently (they never entered via
213                // the validated write path).
214                let date_part = date.get(..10).unwrap_or(&date).to_string();
215                if parse_ymd(&date_part).is_none() {
216                    continue;
217                }
218                if date_part.as_str() > end.as_str() {
219                    continue;
220                }
221                let lead = due.lead_section.as_ref().and_then(|key| {
222                    entity
223                        .sections
224                        .get(key)
225                        .filter(|body| !body.trim().is_empty())
226                        .map(|body| (key.clone(), body.trim().to_string()))
227                });
228                entries.push(DueEntry {
229                    mem: mem.to_string(),
230                    third_party,
231                    id: entity.id.to_string(),
232                    title: entity.title.clone(),
233                    date: date_part.clone(),
234                    status,
235                    lead,
236                    overdue: date_part.as_str() < today_iso.as_str(),
237                });
238            }
239        }
240
241        // Deterministic order: overdue first; each block ascending by
242        // date; ties broken by entity id.
243        entries.sort_by(|a, b| {
244            b.overdue
245                .cmp(&a.overdue)
246                .then_with(|| a.date.cmp(&b.date))
247                .then_with(|| a.id.cmp(&b.id))
248        });
249
250        let window_label = match window {
251            DueWindow::Days(n) => format!("{n}d"),
252            DueWindow::Months(n) => format!("{n}m"),
253            DueWindow::Years(n) => format!("{n}y"),
254        };
255        let mut out = String::new();
256        out.push_str(&format!(
257            "# Due brief — {today_iso}, window {window_label} (through {end})\n\n"
258        ));
259        if declaring_mems.is_empty() {
260            out.push_str(
261                "No mounted mem's schema declares a due axis (`due:` on a type). \
262                 Nothing to render.\n",
263            );
264            return Ok(out);
265        }
266        declaring_mems.sort();
267        declaring_mems.dedup();
268        out.push_str(&format!("Mems: {}\n\n", declaring_mems.join(", ")));
269        if entries.is_empty() {
270            out.push_str("Nothing open is due in this window.\n");
271            return Ok(out);
272        }
273        let overdue_count = entries.iter().filter(|e| e.overdue).count();
274        out.push_str(&format!(
275            "{} entr{} ({} overdue)\n\n",
276            entries.len(),
277            if entries.len() == 1 { "y" } else { "ies" },
278            overdue_count
279        ));
280        for e in &entries {
281            let marker = if e.overdue { " **OVERDUE**" } else { "" };
282            let origin = if e.third_party { " [third-party]" } else { "" };
283            out.push_str(&format!(
284                "- `{}` — {} — **{}**{} (status: {}, mem: {}{})\n",
285                e.id, e.title, e.date, marker, e.status, e.mem, origin
286            ));
287            if let Some((key, body)) = &e.lead {
288                if e.third_party {
289                    // Third-party content is quoted data, never the
290                    // operator's own instruction.
291                    out.push_str(&format!("  - {key} (third-party, quoted):\n"));
292                    for line in body.lines() {
293                        out.push_str(&format!("    > {line}\n"));
294                    }
295                } else {
296                    out.push_str(&format!("  - {key}:\n"));
297                    for line in body.lines() {
298                        out.push_str(&format!("    {line}\n"));
299                    }
300                }
301            }
302        }
303        Ok(out)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use std::path::Path;
311    use tempfile::TempDir;
312
313    use crate::backend::MemBackend;
314    use crate::storage::FilesystemMemWriter;
315    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
316
317    fn frist_schema_dir(root: &Path) {
318        let d = root.join("schemas").join("frist-schema");
319        std::fs::create_dir_all(d.join("types")).unwrap();
320        std::fs::write(
321            d.join("schema.yaml"),
322            "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",
323        )
324        .unwrap();
325        std::fs::write(
326            d.join("types").join("obligation.yaml"),
327            "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",
328        )
329        .unwrap();
330        std::fs::write(
331            d.join("types").join("note.yaml"),
332            "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",
333        )
334        .unwrap();
335    }
336
337    fn obligation_md(title: &str, date: &str, status: &str, vorlauf: Option<&str>) -> String {
338        let lead = vorlauf
339            .map(|v| format!("\n## Vorlauf\n\n{v}\n"))
340            .unwrap_or_default();
341        format!(
342            "---\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}"
343        )
344    }
345
346    fn mount_with(mem: &str, path: std::path::PathBuf, capability: MountCapability) -> Mount {
347        Mount {
348            mem: mem.to_string(),
349            schema: Some("frist@0.1.0".parse().unwrap()),
350            storage: MountStorage::Folder { path },
351            capability,
352            lifecycle: MountLifecycle::Eager,
353            cross_linkable: true,
354            migration_target: None,
355        }
356    }
357
358    /// The single fixture of criterion 1: overdue / in-window /
359    /// out-of-window / closed-status / undeclared-type entities, plus
360    /// a read-only third-party mem. Asserts membership, order, and
361    /// entry content, deterministically at an injected date.
362    #[test]
363    fn due_brief_membership_order_and_labels() {
364        let tmp = TempDir::new().unwrap();
365        frist_schema_dir(tmp.path());
366        let own = tmp.path().join("own");
367        let foreign = tmp.path().join("foreign");
368        std::fs::create_dir_all(own.join(".memstead")).unwrap();
369        std::fs::create_dir_all(foreign.join(".memstead")).unwrap();
370        // The authoritative pin is the mem's own config, not the
371        // mount's expectation assertion.
372        for dir in [&own, &foreign] {
373            std::fs::write(
374                dir.join(".memstead/config.json"),
375                "{\n  \"version\": \"1.0.0\",\n  \"description\": \"due fixture\",\n  \"schema\": \"frist@0.1.0\"\n}",
376            )
377            .unwrap();
378        }
379        std::fs::write(
380            own.join("wartung.md"),
381            obligation_md(
382                "Wartung",
383                "2026-09-01",
384                "offen",
385                Some("Handwerker beauftragen"),
386            ),
387        )
388        .unwrap();
389        std::fs::write(
390            own.join("frist-alt.md"),
391            obligation_md("Frist Alt", "2026-07-01", "in_arbeit", None),
392        )
393        .unwrap();
394        std::fs::write(
395            own.join("weit-weg.md"),
396            obligation_md("Weit Weg", "2027-06-01", "offen", None),
397        )
398        .unwrap();
399        std::fs::write(
400            own.join("erledigt.md"),
401            obligation_md("Erledigt", "2026-08-20", "erledigt", None),
402        )
403        .unwrap();
404        std::fs::write(
405            own.join("plain-note.md"),
406            "---\ntype: note\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\n---\n# Plain Note\n\n## Body\n\nB.\n",
407        )
408        .unwrap();
409        // Same-date tiebreak pair (ids decide).
410        std::fs::write(
411            own.join("b-gleich.md"),
412            obligation_md("B Gleich", "2026-09-10", "offen", None),
413        )
414        .unwrap();
415        std::fs::write(
416            own.join("a-gleich.md"),
417            obligation_md("A Gleich", "2026-09-10", "offen", None),
418        )
419        .unwrap();
420        // Third-party read-only mem with an overdue entry.
421        std::fs::write(
422            foreign.join("fremd-frist.md"),
423            obligation_md(
424                "Fremd Frist",
425                "2026-06-15",
426                "offen",
427                Some("Nur zur Kenntnis"),
428            ),
429        )
430        .unwrap();
431
432        let own_writer = FilesystemMemWriter::new(own.clone());
433        let foreign_writer = FilesystemMemWriter::new(foreign.clone());
434        let engine = Engine::from_mounts_with_schemas_dir(
435            vec![
436                (
437                    mount_with("own", own, MountCapability::Write),
438                    Box::new(own_writer) as Box<dyn MemBackend>,
439                ),
440                (
441                    mount_with("foreign", foreign, MountCapability::ReadOnly),
442                    Box::new(foreign_writer) as Box<dyn MemBackend>,
443                ),
444            ],
445            Some(&tmp.path().join("schemas")),
446        )
447        .unwrap();
448
449        let brief = engine
450            .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
451            .unwrap();
452
453        // Membership: in-window + overdue present; out-of-window,
454        // closed, undeclared-type absent.
455        for present in [
456            "own--wartung",
457            "own--frist-alt",
458            "foreign--fremd-frist",
459            "own--a-gleich",
460            "own--b-gleich",
461        ] {
462            assert!(brief.contains(present), "{present} missing:\n{brief}");
463        }
464        for absent in ["weit-weg", "erledigt", "plain-note"] {
465            assert!(!brief.contains(absent), "{absent} leaked:\n{brief}");
466        }
467
468        // Order: overdue ascending first, then in-window ascending,
469        // ties by id.
470        let pos = |needle: &str| brief.find(needle).unwrap();
471        assert!(
472            pos("foreign--fremd-frist") < pos("own--frist-alt"),
473            "{brief}"
474        );
475        assert!(pos("own--frist-alt") < pos("own--wartung"), "{brief}");
476        assert!(pos("own--wartung") < pos("own--a-gleich"), "{brief}");
477        assert!(pos("own--a-gleich") < pos("own--b-gleich"), "{brief}");
478
479        // Overdue marking, entry content, lead section.
480        assert!(brief.contains("**2026-07-01** **OVERDUE**"), "{brief}");
481        assert!(brief.contains("Handwerker beauftragen"), "{brief}");
482        assert!(brief.contains("status: offen"), "{brief}");
483
484        // Third-party labelling: origin label + quoted lead content.
485        assert!(brief.contains("[third-party]"), "{brief}");
486        assert!(brief.contains("vorlauf (third-party, quoted):"), "{brief}");
487        assert!(brief.contains("> Nur zur Kenntnis"), "{brief}");
488        // Own lead content is NOT quoted.
489        assert!(brief.contains("    Handwerker beauftragen"), "{brief}");
490
491        // Determinism: same inputs, same bytes.
492        let again = engine
493            .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
494            .unwrap();
495        assert_eq!(brief, again);
496
497        // Mem filter narrows to one mem.
498        let own_only = engine
499            .render_due_brief("2026-08-10", &DueWindow::Days(90), Some("own"))
500            .unwrap();
501        assert!(!own_only.contains("foreign--fremd-frist"), "{own_only}");
502        assert!(own_only.contains("own--wartung"), "{own_only}");
503    }
504
505    /// Window parsing and calendar arithmetic.
506    #[test]
507    fn window_parse_and_calendar_math() {
508        assert_eq!(parse_due_window("90d").unwrap(), DueWindow::Days(90));
509        assert_eq!(parse_due_window("6m").unwrap(), DueWindow::Months(6));
510        assert_eq!(parse_due_window("2y").unwrap(), DueWindow::Years(2));
511        for bad in ["", "d", "90", "90w", "-1d", "1.5m"] {
512            let err = parse_due_window(bad).unwrap_err();
513            assert!(err.contains("<N>d"), "error names accepted forms: {err}");
514        }
515        // Day clamping: Jan 31 + 1m = Feb 28 (2026 is not a leap year).
516        assert_eq!(
517            window_end((2026, 1, 31), &DueWindow::Months(1)),
518            "2026-02-28"
519        );
520        assert_eq!(
521            window_end((2024, 1, 31), &DueWindow::Months(1)),
522            "2024-02-29"
523        );
524        assert_eq!(
525            window_end((2026, 8, 10), &DueWindow::Days(90)),
526            "2026-11-08"
527        );
528        assert_eq!(
529            window_end((2026, 11, 15), &DueWindow::Months(2)),
530            "2027-01-15"
531        );
532        assert_eq!(
533            window_end((2024, 2, 29), &DueWindow::Years(1)),
534            "2025-02-28"
535        );
536    }
537
538    /// A workspace with no declaring schema renders the honest empty
539    /// brief, not an error.
540    #[test]
541    fn no_declaring_schema_renders_honest_empty_brief() {
542        let tmp = TempDir::new().unwrap();
543        let mem_dir = tmp.path().to_path_buf();
544        let writer = FilesystemMemWriter::new(mem_dir.clone());
545        let engine = Engine::from_mounts(vec![(
546            crate::engine::test_helpers::folder_mount("specs", mem_dir),
547            Box::new(writer) as Box<dyn MemBackend>,
548        )])
549        .unwrap();
550        let brief = engine
551            .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
552            .unwrap();
553        assert!(
554            brief.contains("No mounted mem's schema declares a due axis"),
555            "{brief}"
556        );
557    }
558}
559
560#[cfg(test)]
561mod obligation_builtin_tests {
562    use super::*;
563    use tempfile::TempDir;
564
565    use crate::backend::MemBackend;
566    use crate::engine::test_helpers::{cli_actor, empty_create_args};
567    use crate::storage::FilesystemMemWriter;
568    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
569
570    fn obligation_mount(mem: &str, path: std::path::PathBuf) -> Mount {
571        Mount {
572            mem: mem.to_string(),
573            schema: Some("obligation@0.1.0".parse().unwrap()),
574            storage: MountStorage::Folder { path },
575            capability: MountCapability::Write,
576            lifecycle: MountLifecycle::Eager,
577            cross_linkable: true,
578            migration_target: None,
579        }
580    }
581
582    fn obligation_engine(tmp: &TempDir) -> Engine {
583        let mem_dir = tmp.path().join("duties");
584        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
585        std::fs::write(
586            mem_dir.join(".memstead/config.json"),
587            "{\n  \"version\": \"1.0.0\",\n  \"description\": \"obligation fixture\",\n  \"schema\": \"obligation@0.1.0\"\n}",
588        )
589        .unwrap();
590        let writer = FilesystemMemWriter::new(mem_dir.clone());
591        Engine::from_mounts(vec![(
592            obligation_mount("duties", mem_dir),
593            Box::new(writer) as Box<dyn MemBackend>,
594        )])
595        .unwrap()
596    }
597
598    fn obligation_args(
599        mem: &str,
600        title: &str,
601        due_date: &str,
602        status: &str,
603    ) -> crate::engine::CreateEntityArgs {
604        let mut args = empty_create_args(mem, title);
605        args.entity_type = "obligation".to_string();
606        args.sections = indexmap::IndexMap::from_iter([
607            ("duty".to_string(), "Who owes what.".to_string()),
608            ("consequence".to_string(), "What forfeits.".to_string()),
609        ]);
610        args.metadata = indexmap::IndexMap::from_iter([
611            ("due_date".to_string(), due_date.to_string()),
612            ("status".to_string(), status.to_string()),
613        ]);
614        args.relations = vec![crate::ops::RelateArg {
615            to: crate::entity::EntityId::new(mem, "some-subject"),
616            rel_type: "CONCERNS".to_string(),
617            description: None,
618        }];
619        args
620    }
621
622    /// Criterion 1 + 3: a mem pinned to the shipped builtin accepts a
623    /// conformant obligation, refuses a nonconformant one with the
624    /// standard envelope, and `render_due_brief` renders the fixture
625    /// correctly under the declared axis.
626    #[test]
627    fn shipped_obligation_schema_accepts_refuses_and_renders_due() {
628        let tmp = TempDir::new().unwrap();
629        let mut engine = obligation_engine(&tmp);
630        let (actor, client) = cli_actor();
631
632        // Conformant — widened-grammar title with '&' and '.'.
633        engine
634            .create_entity(
635                obligation_args(
636                    "duties",
637                    "Renew Registration No. 4711 & File Proof",
638                    "2026-09-01",
639                    "open",
640                ),
641                actor,
642                Some(&client),
643                None,
644            )
645            .expect("conformant obligation lands");
646        engine
647            .create_entity(
648                obligation_args("duties", "Overdue Filing", "2026-07-01", "in_progress"),
649                actor,
650                Some(&client),
651                None,
652            )
653            .expect("second obligation lands");
654        engine
655            .create_entity(
656                obligation_args("duties", "Done Duty", "2026-08-01", "done"),
657                actor,
658                Some(&client),
659                None,
660            )
661            .map(|_| ())
662            .unwrap_err(); // done without completed_on → block (see below)
663
664        // Nonconformant: unknown enum value refuses with the standard
665        // recovery envelope.
666        let err = engine
667            .create_entity(
668                obligation_args("duties", "Bad Status", "2026-09-01", "unknown"),
669                actor,
670                Some(&client),
671                None,
672            )
673            .unwrap_err();
674        assert_eq!(err.code(), "INVALID_ENUM_VALUE", "{err}");
675
676        // Due brief renders the two open entities, overdue first.
677        let brief = engine
678            .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
679            .unwrap();
680        let pos = |n: &str| brief.find(n).unwrap_or(usize::MAX);
681        assert!(brief.contains("duties--overdue-filing"), "{brief}");
682        assert!(
683            brief.contains("duties--renew-registration-no-4711-file-proof"),
684            "{brief}"
685        );
686        assert!(pos("duties--overdue-filing") < pos("duties--renew-registration-no-4711"));
687        assert!(brief.contains("**OVERDUE**"), "{brief}");
688    }
689
690    /// Criterion 4: the shipped `requires_when` pair and the
691    /// block-severity `required_outgoing` refuse exactly the
692    /// field-schema writes.
693    #[test]
694    fn shipped_constraints_refuse_like_the_field_schema() {
695        let tmp = TempDir::new().unwrap();
696        let mut engine = obligation_engine(&tmp);
697        let (actor, client) = cli_actor();
698
699        // done without completed_on → block-tier requires_when.
700        let err = engine
701            .create_entity(
702                obligation_args("duties", "Done Without Date", "2026-08-01", "done"),
703                actor,
704                Some(&client),
705                None,
706            )
707            .unwrap_err();
708        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED", "{err}");
709        assert!(err.to_string().contains("completed_on"), "{err}");
710
711        // criticality high without responsible → block-tier requires_when.
712        let mut args = obligation_args("duties", "Critical Unowned", "2026-09-01", "open");
713        args.metadata
714            .insert("criticality".to_string(), "high".to_string());
715        let err = engine
716            .create_entity(args, actor, Some(&client), None)
717            .unwrap_err();
718        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED", "{err}");
719        assert!(err.to_string().contains("responsible"), "{err}");
720
721        // No CONCERNS edge → block-severity required_outgoing refusal.
722        let mut args = obligation_args("duties", "About Nothing", "2026-09-01", "open");
723        args.relations.clear();
724        let err = engine
725            .create_entity(args, actor, Some(&client), None)
726            .unwrap_err();
727        assert!(
728            err.to_string().contains("CONCERNS") || err.code().contains("REQUIRED_OUTGOING"),
729            "required_outgoing must refuse: {err} ({})",
730            err.code()
731        );
732
733        // Complements: done WITH completed_on lands; high WITH
734        // responsible lands.
735        let mut args = obligation_args("duties", "Done Properly", "2026-08-01", "done");
736        args.metadata
737            .insert("completed_on".to_string(), "2026-08-01".to_string());
738        engine
739            .create_entity(args, actor, Some(&client), None)
740            .expect("done with completed_on lands");
741        let mut args = obligation_args("duties", "Critical Owned", "2026-09-01", "open");
742        args.metadata
743            .insert("criticality".to_string(), "high".to_string());
744        args.metadata
745            .insert("responsible".to_string(), "Operations".to_string());
746        engine
747            .create_entity(args, actor, Some(&client), None)
748            .expect("high with responsible lands");
749    }
750}