truth_mirror/memory_skill/
render.rs1use super::types::MemorySkillCandidate;
2
3pub fn render_candidate_preview(candidate: &MemorySkillCandidate) -> String {
4 format!(
5 "# {}\n\n- id: {}\n- status: {}\n- kind: {}\n- source: {} {}\n- occurrences: {}\n\n{}\n",
6 markdown_inline(&candidate.title),
7 candidate.id,
8 candidate.status,
9 candidate.candidate_kind.as_str(),
10 candidate.source_commit,
11 candidate.truth_label,
12 candidate.occurrence_count,
13 markdown_paragraph(&candidate.description)
14 )
15}
16
17pub fn render_skill(candidate: &MemorySkillCandidate) -> String {
18 let mut output = String::new();
19 output.push_str("---\n");
20 output.push_str(&format!("name: {}\n", yaml_double_quoted(&candidate.slug)));
21 output.push_str(&format!(
22 "description: {}\n",
23 yaml_double_quoted(&candidate.description)
24 ));
25 output.push_str("---\n\n");
26 output.push_str(&format!("# {}\n\n", markdown_inline(&candidate.title)));
27 output.push_str("## When to Use\n\n");
28 for item in &candidate.when_to_use {
29 output.push_str(&format!("- {}\n", markdown_inline(item)));
30 }
31 output.push_str("\n## Procedure\n\n");
32 for (index, step) in candidate.procedure_steps.iter().enumerate() {
33 output.push_str(&format!("{}. {}\n", index + 1, markdown_inline(step)));
34 }
35 output.push_str("\n## Pitfalls\n\n");
36 for item in &candidate.pitfalls {
37 output.push_str(&format!("- {}\n", markdown_inline(item)));
38 }
39 output.push_str("\n## Verification\n\n");
40 for item in &candidate.verification_steps {
41 output.push_str(&format!("- {}\n", markdown_inline(item)));
42 }
43 output.push_str("\n## Evidence\n\n");
44 output.push_str(&format!("- source commit: {}\n", candidate.source_commit));
45 output.push_str(&format!(
46 "- ledger: {}\n",
47 candidate.ledger_entry_ref.display()
48 ));
49 output.push_str(&format!(
50 "- review run: {}\n",
51 candidate.source_run_ref.display()
52 ));
53 for occurrence in &candidate.occurrence_refs {
54 output.push_str(&format!("- occurrence: {}\n", occurrence.display()));
55 }
56 output
57}
58
59fn yaml_double_quoted(value: &str) -> String {
60 let mut quoted = String::from("\"");
61 for character in value.chars() {
62 match character {
63 '\\' => quoted.push_str("\\\\"),
64 '"' => quoted.push_str("\\\""),
65 '\n' => quoted.push_str("\\n"),
66 '\r' => quoted.push_str("\\r"),
67 '\t' => quoted.push_str("\\t"),
68 other if (other as u32) < 0x20 => {
69 quoted.push_str(&format!("\\u{:04x}", other as u32));
70 }
71 other => quoted.push(other),
72 }
73 }
74 quoted.push('"');
75 quoted
76}
77
78fn markdown_inline(value: &str) -> String {
79 markdown_text(value, true)
80}
81
82fn markdown_paragraph(value: &str) -> String {
83 markdown_text(value, false)
84}
85
86fn markdown_text(value: &str, single_line: bool) -> String {
87 let mut output = String::new();
88 let mut last_was_space = false;
89 for character in value.chars() {
90 match character {
91 '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '~' => {
92 output.push('\\');
93 output.push(character);
94 last_was_space = false;
95 }
96 '\n' | '\r' | '\u{2028}' | '\u{2029}' if single_line => {
97 if !last_was_space {
98 output.push(' ');
99 last_was_space = true;
100 }
101 }
102 '\n' | '\r' | '\u{2028}' | '\u{2029}' => {
103 if !output.ends_with('\n') {
104 output.push('\n');
105 }
106 last_was_space = false;
107 }
108 '\t' => {
109 if !last_was_space {
110 output.push(' ');
111 last_was_space = true;
112 }
113 }
114 other if (other as u32) < 0x20 => {
115 output.push_str(&format!("\\u{:04x}", other as u32));
116 last_was_space = false;
117 }
118 other => {
119 output.push(other);
120 last_was_space = other.is_whitespace();
121 }
122 }
123 }
124 let output = output.trim();
125 if single_line {
126 output.to_owned()
127 } else {
128 escape_list_prefixes(output)
129 }
130}
131
132fn escape_list_prefixes(value: &str) -> String {
133 value
134 .lines()
135 .map(escape_list_prefix)
136 .collect::<Vec<_>>()
137 .join("\n")
138}
139
140fn escape_list_prefix(line: &str) -> String {
141 let trimmed = line.trim_start_matches(' ');
142 let indent_len = line.len() - trimmed.len();
143 if trimmed.starts_with("- ") || trimmed.starts_with("+ ") {
144 return format!("{}\\{}", &line[..indent_len], trimmed);
145 }
146 if is_markdown_rule_line(trimmed) {
147 return format!("{}\\{}", &line[..indent_len], trimmed);
148 }
149 let digit_count = trimmed.bytes().take_while(u8::is_ascii_digit).count();
150 if digit_count > 0 {
151 let marker = trimmed.as_bytes().get(digit_count).copied();
152 if let Some(marker @ (b'.' | b')')) = marker {
153 return format!(
154 "{}{}\\{}{}",
155 &line[..indent_len],
156 &trimmed[..digit_count],
157 marker as char,
158 &trimmed[digit_count + 1..]
159 );
160 }
161 }
162 line.to_owned()
163}
164
165fn is_markdown_rule_line(trimmed: &str) -> bool {
166 let marker = trimmed.as_bytes().first().copied();
167 matches!(marker, Some(b'-' | b'='))
168 && !trimmed.is_empty()
169 && trimmed.as_bytes().iter().all(|byte| Some(*byte) == marker)
170}
171
172#[cfg(test)]
173mod tests {
174 use crate::{
175 config::MemorySkillCandidateKind,
176 ledger::Verdict,
177 memory_skill::{
178 CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
179 },
180 };
181
182 use super::{render_candidate_preview, render_skill};
183
184 #[test]
185 fn candidate_preview_sanitizes_markdown_control_text() {
186 let ledger_ref = EvidenceRef::ledger("abc123");
187 let candidate = MemorySkillCandidate {
188 schema_version: CANDIDATE_SCHEMA_VERSION,
189 id: "msk_1".to_owned(),
190 fingerprint: "sha256:v1:test".to_owned(),
191 learning_key: "test".to_owned(),
192 status: CandidateStatus::Pending,
193 candidate_kind: MemorySkillCandidateKind::HowToSkill,
194 scope: "project".to_owned(),
195 source_commit: "abc123".to_owned(),
196 source_claim: "CLAIM: test | verified: cargo test | evidence: tests:test".to_owned(),
197 truth_label: Verdict::Pass,
198 ledger_entry_ref: ledger_ref.clone(),
199 source_run_ref: EvidenceRef::run("run-1"),
200 occurrence_count: 1,
201 occurrence_refs: vec![ledger_ref.clone()],
202 slug: "test".to_owned(),
203 title: "Title\u{2028}# injected `code`".to_owned(),
204 description:
205 "line one\n-\n=\n- injected `item`\n+ plus\n---\n===\n2. numbered\n3) paren ~~gone~~\u{0007}"
206 .to_owned(),
207 when_to_use: Vec::new(),
208 procedure_steps: Vec::new(),
209 pitfalls: Vec::new(),
210 verification_steps: Vec::new(),
211 evidence: vec![ledger_ref],
212 created_at_unix: 100,
213 };
214
215 let preview = render_candidate_preview(&candidate);
216
217 assert!(preview.starts_with("# Title \\# injected \\`code\\`"));
218 assert!(preview.contains(
219 "line one\n\\-\n\\=\n\\- injected \\`item\\`\n\\+ plus\n\\---\n\\===\n2\\. numbered\n3\\) paren \\~\\~gone\\~\\~\\u0007"
220 ));
221 }
222
223 #[test]
224 fn rendered_skill_sanitizes_list_items() {
225 let ledger_ref = EvidenceRef::ledger("abc123");
226 let candidate = MemorySkillCandidate {
227 schema_version: CANDIDATE_SCHEMA_VERSION,
228 id: "msk_1".to_owned(),
229 fingerprint: "sha256:v1:test".to_owned(),
230 learning_key: "test".to_owned(),
231 status: CandidateStatus::Pending,
232 candidate_kind: MemorySkillCandidateKind::HowToSkill,
233 scope: "project".to_owned(),
234 source_commit: "abc123".to_owned(),
235 source_claim: "CLAIM: test | verified: cargo test | evidence: tests:test".to_owned(),
236 truth_label: Verdict::Pass,
237 ledger_entry_ref: ledger_ref.clone(),
238 source_run_ref: EvidenceRef::run("run-1"),
239 occurrence_count: 1,
240 occurrence_refs: vec![ledger_ref.clone()],
241 slug: "test".to_owned(),
242 title: "Title\n# injected".to_owned(),
243 description: "description".to_owned(),
244 when_to_use: vec!["when\n- injected".to_owned()],
245 procedure_steps: vec!["step one\n2. injected `code`".to_owned()],
246 pitfalls: vec!["pitfall\n# injected".to_owned()],
247 verification_steps: vec!["verify\n- injected".to_owned()],
248 evidence: vec![ledger_ref],
249 created_at_unix: 100,
250 };
251
252 let skill = render_skill(&candidate);
253
254 assert!(skill.contains("# Title \\# injected"));
255 assert!(skill.contains("- when - injected"));
256 assert!(skill.contains("1. step one 2. injected \\`code\\`"));
257 assert!(!skill.contains("\n2. injected"));
258 }
259}