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