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}