Skip to main content

truth_mirror/memory_skill/
render.rs

1use 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}