Skip to main content

memstead_base/ingest/
guidance.rs

1//! Writing-guidance resolution — merge a schema's `default_writing_guidance`
2//! with a mem's per-mem `writeGuidance` into the goal/avoid prose the run
3//! brief renders.
4//!
5//! Engine-side port of the plugin's `lib/writing-guidance.mjs`
6//! `resolveWritingGuidance` (the goal/avoid merge). The plugin's YAML
7//! extractor (`extractDefaultWritingGuidance`) is **not** ported: it exists
8//! only because the skill reads schema YAML off disk, whereas the engine
9//! already holds a parsed schema and reads `default_writing_guidance`
10//! directly.
11//!
12//! Precedence, per field, mirrors the plugin's `mergeBlock`:
13//!   1. a **legacy** per-mem literal (`writeGuidance.goal` / `.avoid`,
14//!      pre-migration) wins verbatim and the schema default is ignored — a
15//!      half-finished migration must not silently lose the operator's prose;
16//!   2. otherwise the schema default and the per-mem `*_additions` combine:
17//!      default alone, additions alone, or `default + "\n\n" + additions`.
18//!
19//! An empty string is treated as absent everywhere.
20//!
21//! Pass-through `writeGuidance` keys (granularity, stack, language, …) and
22//! their fallback rendering (`renderResolvedGuidance`) are **not** modelled
23//! here yet — they land with the operative-data / fallback block that
24//! consumes them.
25
26/// A schema's `default_writing_guidance` — goal/avoid prose the schema
27/// author ships for every mem pinned to that schema.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct GuidanceDefaults {
30    /// The schema's default goal prose.
31    pub goal: Option<String>,
32    /// The schema's default failure-modes-to-avoid prose.
33    pub avoid: Option<String>,
34}
35
36/// A mem's `writeGuidance`: optional per-mem additions to the schema
37/// defaults and an optional legacy literal override (pre-migration).
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct MemGuidance {
40    /// Per-mem prose appended to the schema's default goal.
41    pub goal_additions: Option<String>,
42    /// Per-mem prose appended to the schema's default avoid.
43    pub avoid_additions: Option<String>,
44    /// A legacy pre-migration literal goal — wins verbatim if present.
45    pub legacy_goal: Option<String>,
46    /// A legacy pre-migration literal avoid — wins verbatim if present.
47    pub legacy_avoid: Option<String>,
48}
49
50/// The merged goal/avoid prose, ready for the brief's Goal / Failure-modes
51/// blocks. A field absent everywhere is `None` (the brief renders no header
52/// for it).
53#[derive(Debug, Clone, Default, PartialEq, Eq)]
54pub struct ResolvedGuidance {
55    /// The resolved goal prose, if any.
56    pub goal: Option<String>,
57    /// The resolved avoid prose, if any.
58    pub avoid: Option<String>,
59}
60
61/// An empty string counts as absent (matching the plugin's truthiness check).
62fn present(s: Option<&str>) -> Option<&str> {
63    s.filter(|x| !x.is_empty())
64}
65
66/// Merge one field (goal or avoid) from its schema default, per-mem
67/// additions, and any legacy literal. Mirrors the plugin's `mergeBlock`.
68pub fn merge_guidance_block(
69    default: Option<&str>,
70    additions: Option<&str>,
71    legacy: Option<&str>,
72) -> Option<String> {
73    let default = present(default);
74    let additions = present(additions);
75
76    // Legacy literal wins verbatim; the schema default is ignored for this
77    // field. The migration sweep removes these keys.
78    if let Some(legacy) = present(legacy) {
79        tracing::warn!(
80            "ingest guidance: mem carries a legacy writeGuidance literal; \
81             the schema's default_writing_guidance is ignored for this field \
82             (migrate the prose into the schema, or rename to *_additions)"
83        );
84        return Some(legacy.to_string());
85    }
86
87    match (default, additions) {
88        (None, None) => None,
89        (Some(default), None) => Some(default.to_string()),
90        (None, Some(additions)) => Some(additions.to_string()),
91        (Some(default), Some(additions)) => {
92            Some(format!("{}\n\n{}", default.trim_end(), additions))
93        }
94    }
95}
96
97/// Resolve a mem's writing guidance against its schema defaults.
98pub fn resolve_writing_guidance(
99    defaults: &GuidanceDefaults,
100    mem: &MemGuidance,
101) -> ResolvedGuidance {
102    ResolvedGuidance {
103        goal: merge_guidance_block(
104            defaults.goal.as_deref(),
105            mem.goal_additions.as_deref(),
106            mem.legacy_goal.as_deref(),
107        ),
108        avoid: merge_guidance_block(
109            defaults.avoid.as_deref(),
110            mem.avoid_additions.as_deref(),
111            mem.legacy_avoid.as_deref(),
112        ),
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    /// The four non-legacy merge shapes: neither, default-only, additions-
121    /// only, and default + additions joined by a blank line (with the
122    /// default's trailing whitespace trimmed before the join).
123    #[test]
124    fn merge_combines_default_and_additions() {
125        assert_eq!(merge_guidance_block(None, None, None), None);
126        assert_eq!(merge_guidance_block(Some(""), Some(""), None), None);
127        assert_eq!(
128            merge_guidance_block(Some("D"), None, None),
129            Some("D".to_string())
130        );
131        assert_eq!(
132            merge_guidance_block(None, Some("A"), None),
133            Some("A".to_string())
134        );
135        assert_eq!(
136            merge_guidance_block(Some("D\n"), Some("A"), None),
137            Some("D\n\nA".to_string()),
138            "default's trailing newline is trimmed, then joined by a blank line"
139        );
140    }
141
142    /// A legacy literal wins verbatim and suppresses the schema default and
143    /// the additions.
144    #[test]
145    fn legacy_literal_wins() {
146        assert_eq!(
147            merge_guidance_block(
148                Some("schema default"),
149                Some("additions"),
150                Some("legacy prose")
151            ),
152            Some("legacy prose".to_string())
153        );
154    }
155
156    /// The whole-object resolve wires goal and avoid independently and drops
157    /// fields absent everywhere.
158    #[test]
159    fn resolve_wires_goal_and_avoid() {
160        let defaults = GuidanceDefaults {
161            goal: Some("build coverage".to_string()),
162            avoid: None,
163        };
164        let mem = MemGuidance {
165            goal_additions: Some("prefer small entities".to_string()),
166            avoid_additions: None,
167            legacy_goal: None,
168            legacy_avoid: None,
169        };
170        let r = resolve_writing_guidance(&defaults, &mem);
171        assert_eq!(
172            r.goal.as_deref(),
173            Some("build coverage\n\nprefer small entities")
174        );
175        assert_eq!(r.avoid, None, "avoid absent everywhere is dropped");
176    }
177}