use super::types::MemorySkillCandidate;
pub fn render_candidate_preview(candidate: &MemorySkillCandidate) -> String {
format!(
"# {}\n\n- id: {}\n- status: {}\n- kind: {}\n- source: {} {}\n- occurrences: {}\n\n{}\n",
markdown_inline(&candidate.title),
candidate.id,
candidate.status,
candidate.candidate_kind.as_str(),
candidate.source_commit,
candidate.truth_label,
candidate.occurrence_count,
markdown_paragraph(&candidate.description)
)
}
pub fn render_skill(candidate: &MemorySkillCandidate) -> String {
let mut output = String::new();
output.push_str("---\n");
output.push_str(&format!("name: {}\n", yaml_double_quoted(&candidate.slug)));
output.push_str(&format!(
"description: {}\n",
yaml_double_quoted(&candidate.description)
));
output.push_str("---\n\n");
output.push_str(&format!("# {}\n\n", markdown_inline(&candidate.title)));
output.push_str("## When to Use\n\n");
for item in &candidate.when_to_use {
output.push_str(&format!("- {}\n", markdown_inline(item)));
}
output.push_str("\n## Procedure\n\n");
for (index, step) in candidate.procedure_steps.iter().enumerate() {
output.push_str(&format!("{}. {}\n", index + 1, markdown_inline(step)));
}
output.push_str("\n## Pitfalls\n\n");
for item in &candidate.pitfalls {
output.push_str(&format!("- {}\n", markdown_inline(item)));
}
output.push_str("\n## Verification\n\n");
for item in &candidate.verification_steps {
output.push_str(&format!("- {}\n", markdown_inline(item)));
}
output.push_str("\n## Evidence\n\n");
output.push_str(&format!("- source commit: {}\n", candidate.source_commit));
output.push_str(&format!(
"- ledger: {}\n",
candidate.ledger_entry_ref.display()
));
output.push_str(&format!(
"- review run: {}\n",
candidate.source_run_ref.display()
));
for occurrence in &candidate.occurrence_refs {
output.push_str(&format!("- occurrence: {}\n", occurrence.display()));
}
output
}
fn yaml_double_quoted(value: &str) -> String {
let mut quoted = String::from("\"");
for character in value.chars() {
match character {
'\\' => quoted.push_str("\\\\"),
'"' => quoted.push_str("\\\""),
'\n' => quoted.push_str("\\n"),
'\r' => quoted.push_str("\\r"),
'\t' => quoted.push_str("\\t"),
other if (other as u32) < 0x20 => {
quoted.push_str(&format!("\\u{:04x}", other as u32));
}
other => quoted.push(other),
}
}
quoted.push('"');
quoted
}
fn markdown_inline(value: &str) -> String {
markdown_text(value, true)
}
fn markdown_paragraph(value: &str) -> String {
markdown_text(value, false)
}
fn markdown_text(value: &str, single_line: bool) -> String {
let mut output = String::new();
let mut last_was_space = false;
for character in value.chars() {
match character {
'\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '~' => {
output.push('\\');
output.push(character);
last_was_space = false;
}
'\n' | '\r' | '\u{2028}' | '\u{2029}' if single_line => {
if !last_was_space {
output.push(' ');
last_was_space = true;
}
}
'\n' | '\r' | '\u{2028}' | '\u{2029}' => {
if !output.ends_with('\n') {
output.push('\n');
}
last_was_space = false;
}
'\t' => {
if !last_was_space {
output.push(' ');
last_was_space = true;
}
}
other if (other as u32) < 0x20 => {
output.push_str(&format!("\\u{:04x}", other as u32));
last_was_space = false;
}
other => {
output.push(other);
last_was_space = other.is_whitespace();
}
}
}
let output = output.trim();
if single_line {
output.to_owned()
} else {
escape_list_prefixes(output)
}
}
fn escape_list_prefixes(value: &str) -> String {
value
.lines()
.map(escape_list_prefix)
.collect::<Vec<_>>()
.join("\n")
}
fn escape_list_prefix(line: &str) -> String {
let trimmed = line.trim_start_matches(' ');
let indent_len = line.len() - trimmed.len();
if trimmed.starts_with("- ") || trimmed.starts_with("+ ") {
return format!("{}\\{}", &line[..indent_len], trimmed);
}
if is_markdown_rule_line(trimmed) {
return format!("{}\\{}", &line[..indent_len], trimmed);
}
let digit_count = trimmed.bytes().take_while(u8::is_ascii_digit).count();
if digit_count > 0 {
let marker = trimmed.as_bytes().get(digit_count).copied();
if let Some(marker @ (b'.' | b')')) = marker {
return format!(
"{}{}\\{}{}",
&line[..indent_len],
&trimmed[..digit_count],
marker as char,
&trimmed[digit_count + 1..]
);
}
}
line.to_owned()
}
fn is_markdown_rule_line(trimmed: &str) -> bool {
let marker = trimmed.as_bytes().first().copied();
matches!(marker, Some(b'-' | b'='))
&& !trimmed.is_empty()
&& trimmed.as_bytes().iter().all(|byte| Some(*byte) == marker)
}
#[cfg(test)]
mod tests {
use crate::{
config::MemorySkillCandidateKind,
ledger::Verdict,
memory_skill::{
CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
},
};
use super::{render_candidate_preview, render_skill};
#[test]
fn candidate_preview_sanitizes_markdown_control_text() {
let ledger_ref = EvidenceRef::ledger("abc123");
let candidate = MemorySkillCandidate {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "msk_1".to_owned(),
fingerprint: "sha256:v1:test".to_owned(),
learning_key: "test".to_owned(),
status: CandidateStatus::Pending,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
scope: "project".to_owned(),
source_commit: "abc123".to_owned(),
source_claim: "CLAIM: test | verified: cargo test | evidence: tests:test".to_owned(),
truth_label: Verdict::Pass,
ledger_entry_ref: ledger_ref.clone(),
source_run_ref: EvidenceRef::run("run-1"),
occurrence_count: 1,
occurrence_refs: vec![ledger_ref.clone()],
slug: "test".to_owned(),
title: "Title\u{2028}# injected `code`".to_owned(),
description:
"line one\n-\n=\n- injected `item`\n+ plus\n---\n===\n2. numbered\n3) paren ~~gone~~\u{0007}"
.to_owned(),
when_to_use: Vec::new(),
procedure_steps: Vec::new(),
pitfalls: Vec::new(),
verification_steps: Vec::new(),
evidence: vec![ledger_ref],
created_at_unix: 100,
};
let preview = render_candidate_preview(&candidate);
assert!(preview.starts_with("# Title \\# injected \\`code\\`"));
assert!(preview.contains(
"line one\n\\-\n\\=\n\\- injected \\`item\\`\n\\+ plus\n\\---\n\\===\n2\\. numbered\n3\\) paren \\~\\~gone\\~\\~\\u0007"
));
}
#[test]
fn rendered_skill_sanitizes_list_items() {
let ledger_ref = EvidenceRef::ledger("abc123");
let candidate = MemorySkillCandidate {
schema_version: CANDIDATE_SCHEMA_VERSION,
id: "msk_1".to_owned(),
fingerprint: "sha256:v1:test".to_owned(),
learning_key: "test".to_owned(),
status: CandidateStatus::Pending,
candidate_kind: MemorySkillCandidateKind::HowToSkill,
scope: "project".to_owned(),
source_commit: "abc123".to_owned(),
source_claim: "CLAIM: test | verified: cargo test | evidence: tests:test".to_owned(),
truth_label: Verdict::Pass,
ledger_entry_ref: ledger_ref.clone(),
source_run_ref: EvidenceRef::run("run-1"),
occurrence_count: 1,
occurrence_refs: vec![ledger_ref.clone()],
slug: "test".to_owned(),
title: "Title\n# injected".to_owned(),
description: "description".to_owned(),
when_to_use: vec!["when\n- injected".to_owned()],
procedure_steps: vec!["step one\n2. injected `code`".to_owned()],
pitfalls: vec!["pitfall\n# injected".to_owned()],
verification_steps: vec!["verify\n- injected".to_owned()],
evidence: vec![ledger_ref],
created_at_unix: 100,
};
let skill = render_skill(&candidate);
assert!(skill.contains("# Title \\# injected"));
assert!(skill.contains("- when - injected"));
assert!(skill.contains("1. step one 2. injected \\`code\\`"));
assert!(!skill.contains("\n2. injected"));
}
}