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 every consuming
10//! surface serves byte-identical content (the projection-brief
11//! precedent); there is deliberately no MCP tool — briefs are the
12//! CLI family.
13//!
14//! Read-only mounts participate: a due date in an installed
15//! compliance mem is precisely the multi-stakeholder case, and a
16//! brief is a read surface. Third-party entries carry their origin
17//! label and render as quoted data — a stranger's mem states a
18//! deadline, it does not instruct.
19
20use crate::engine::Engine;
21use crate::entity::MetadataValue;
22use crate::workspace::MountCapability;
23
24/// The default window applied when `--within` is omitted — stated in
25/// the CLI help and the changelog rather than unbounded.
26pub const DEFAULT_DUE_WINDOW: &str = "90d";
27
28/// A parsed relative window: `<N>d` days, `<N>m` calendar months,
29/// `<N>y` calendar years.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum DueWindow {
32    Days(u32),
33    Months(u32),
34    Years(u32),
35}
36
37/// Parse a relative window (`90d`, `6m`, `2y`). The error names the
38/// accepted forms — the caller wraps it in its surface's typed
39/// envelope.
40pub 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
57/// Civil-date helpers over ISO `YYYY-MM-DD` strings — exact calendar
58/// math without a date dependency (Howard Hinnant's `days_from_civil`
59/// algorithm). Entities store dates as ISO strings, which order
60/// lexically; arithmetic converts through day numbers.
61fn 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
100/// Public epoch-days → civil-date conversion for callers that derive
101/// "today" from `SystemTime`. Same algorithm the 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    /// Signed distance from today in days: negative past the date.
151    days_until: i64,
152}
153
154/// One row of the due brief: an open entity whose declared due date falls
155/// inside the window. The engine names the reading (`overdue` with the
156/// days past, or due within the window with the days until) and never
157/// judges it: no severity, no recommendation.
158#[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    /// Days past the due date (`overdue` rows only).
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub days_past: Option<u32>,
170    /// Days until the due date (`due_soon` rows only; 0 on the day).
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub days_until: Option<u32>,
173    /// The declared lead section, quoted when the entity carries it.
174    #[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/// The due brief as data: what the markdown renders, for callers that
185/// consume the reading rather than the prose.
186#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
187pub struct DueBrief {
188    pub today: String,
189    pub through: String,
190    /// Mems whose schema declares a due axis; empty when none does.
191    pub mems: Vec<String>,
192    pub overdue: Vec<DueRow>,
193    pub due_soon: Vec<DueRow>,
194}
195
196impl Engine {
197    /// Render the due-brief: every open entity whose declared due date
198    /// falls inside `(-∞, today + window]`, across every mem whose
199    /// pinned schema declares the axis (writable and read-only mounts
200    /// alike), optionally filtered to one mem. `today` is an ISO
201    /// `YYYY-MM-DD` string — the caller takes it once per invocation,
202    /// tests inject it. A workspace with no declaring schema renders
203    /// an honest empty brief, not an error.
204    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    /// The due brief as data ([`DueBrief`]): every open entity whose
222    /// declared due date lies before `today` under `overdue` with the
223    /// days past, every one due from today through the window's end
224    /// under `due_soon` with the days until; a type without a `due`
225    /// declaration contributes nothing and raises nothing.
226    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        // Mem → (schema, third_party) for every mount, capability
270        // included: read-only mounts participate as labelled quoted
271        // data.
272        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                // Dates are ISO strings — take the date part, refuse
310                // malformed values silently (they never entered via
311                // the validated write path).
312                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        // Deterministic order: overdue first; each block ascending by
342        // date; ties broken by entity id.
343        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                    // Third-party content is quoted data, never the
405                    // operator's own instruction.
406                    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    /// The single fixture of criterion 1: overdue / in-window /
474    /// out-of-window / closed-status / undeclared-type entities, plus
475    /// a read-only third-party mem. Asserts membership, order, and
476    /// entry content, deterministically at an injected date.
477    #[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        // The authoritative pin is the mem's own config, not the
486        // mount's expectation assertion.
487        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        // Same-date tiebreak pair (ids decide).
525        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        // Third-party read-only mem with an overdue entry.
536        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        // Membership: in-window + overdue present; out-of-window,
569        // closed, undeclared-type absent.
570        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        // Order: overdue ascending first, then in-window ascending,
584        // ties by id.
585        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        // Overdue marking, entry content, lead section.
595        assert!(brief.contains("**2026-07-01** **OVERDUE**"), "{brief}");
596        assert!(brief.contains("Handwerker beauftragen"), "{brief}");
597        assert!(brief.contains("status: offen"), "{brief}");
598
599        // Third-party labelling: origin label + quoted lead content.
600        assert!(brief.contains("[third-party]"), "{brief}");
601        assert!(brief.contains("vorlauf (third-party, quoted):"), "{brief}");
602        assert!(brief.contains("> Nur zur Kenntnis"), "{brief}");
603        // Own lead content is NOT quoted.
604        assert!(brief.contains("    Handwerker beauftragen"), "{brief}");
605
606        // Determinism: same inputs, same bytes.
607        let again = engine
608            .render_due_brief("2026-08-10", &DueWindow::Days(90), None)
609            .unwrap();
610        assert_eq!(brief, again);
611
612        // Mem filter narrows to one mem.
613        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    /// Window parsing and calendar arithmetic.
621    #[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        // Day clamping: Jan 31 + 1m = Feb 28 (2026 is not a leap year).
631        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    /// A workspace with no declaring schema renders the honest empty
654    /// brief, not an error.
655    #[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    /// Criterion 1 + 3: a mem pinned to the shipped builtin accepts a
738    /// conformant obligation, refuses a nonconformant one with the
739    /// standard envelope, and `render_due_brief` renders the fixture
740    /// correctly under the declared axis.
741    #[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        // Conformant — widened-grammar title with '&' and '.'.
748        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(); // done without completed_on → block (see below)
778
779        // Nonconformant: unknown enum value refuses with the standard
780        // recovery envelope.
781        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        // Due brief renders the two open entities, overdue first.
792        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    /// Criterion 4: the shipped `requires_when` pair and the
806    /// block-severity `required_outgoing` refuse exactly the
807    /// field-schema writes.
808    #[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        // done without completed_on → block-tier requires_when.
815        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        // criticality high without responsible → block-tier requires_when.
827        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        // No CONCERNS edge → block-severity required_outgoing refusal.
837        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        // Complements: done WITH completed_on lands; high WITH
849        // responsible lands.
850        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}