use harness_core::{SkillError, SkillManifest};
use std::collections::BTreeMap;
const NAME_MAX: usize = 64;
const DESCRIPTION_MAX: usize = 1024;
const DESCRIPTION_MIN: usize = 60;
const ROUTING_CUES: &[&str] = &[
"use when",
"use for",
"use this skill",
"use after",
"use before",
"when the user",
"whenever the user",
"trigger when",
"trigger whenever",
"invoke when",
"applies when",
];
const STOPWORDS: &[&str] = &[
"that", "this", "with", "from", "into", "then", "than", "them", "they", "have", "been", "were",
"will", "would", "should", "could", "when", "what", "which", "while", "after", "before",
"about", "there", "their", "your", "user", "asks", "asked", "skill", "step", "steps", "using",
"used", "make", "makes", "does", "done",
];
pub(crate) fn slugify(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut pending_hyphen = false;
for ch in raw.chars() {
if ch.is_ascii_alphanumeric() {
if pending_hyphen && !out.is_empty() {
out.push('-');
}
pending_hyphen = false;
out.push(ch.to_ascii_lowercase());
} else {
pending_hyphen = true;
}
}
if out.len() > NAME_MAX {
out.truncate(NAME_MAX);
if let Some(i) = out.rfind('-') {
out.truncate(i);
}
}
out.trim_matches('-').to_string()
}
pub(crate) fn sanitize_description(raw: &str) -> String {
let mut s = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if s.len() > DESCRIPTION_MAX {
s.truncate(floor_char_boundary(&s, DESCRIPTION_MAX - 1));
s.push('…');
}
s
}
pub(crate) fn finalize_description(raw: &str, situation: &str, fallback: &str) -> String {
let mut s = sanitize_description(raw);
if s.is_empty() {
s = sanitize_description(fallback);
}
let situation = sanitize_description(situation);
if !ROUTING_CUES
.iter()
.any(|c| s.to_ascii_lowercase().contains(c))
{
let when = first_sentence(&situation, 160);
if !when.is_empty() {
s = format!("{} Use when {}.", end_sentence(&s), lower_first(&when));
}
}
if s.len() < DESCRIPTION_MIN && !situation.is_empty() {
s = format!(
"{} Distilled from a past run: {}.",
end_sentence(&s),
first_sentence(&situation, 200)
);
}
sanitize_description(&s)
}
fn end_sentence(s: &str) -> String {
let t = s.trim_end();
if t.is_empty() || t.ends_with(['.', '!', '?', ':', ';', '…']) {
t.to_string()
} else {
format!("{t}.")
}
}
fn first_sentence(text: &str, max: usize) -> String {
let end = text.find(['.', '!', '?', '\n']).map_or(text.len(), |i| i);
let mut s = text[..end].trim().to_string();
if s.len() > max {
s.truncate(floor_char_boundary(&s, max));
s = s.trim_end().to_string();
}
s
}
fn lower_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
Some(f) if f.is_ascii_uppercase() && !s.chars().take(2).all(|c| c.is_ascii_uppercase()) => {
f.to_ascii_lowercase().to_string() + c.as_str()
}
_ => s.to_string(),
}
}
fn floor_char_boundary(s: &str, i: usize) -> usize {
let mut i = i.min(s.len());
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
pub(crate) fn manifest(
name: &str,
description: &str,
metadata: BTreeMap<String, serde_json::Value>,
) -> SkillManifest {
SkillManifest {
name: name.to_string(),
description: description.to_string(),
license: None,
compatibility: None,
metadata,
allowed_tools: None,
}
}
pub(crate) fn render_skill_md(m: &SkillManifest, body: &str) -> String {
let yaml = serde_yaml::to_string(m).unwrap_or_else(|e| {
tracing::warn!(error = %e, "skill frontmatter serialization failed; emitting minimal frontmatter");
format!("name: {}\ndescription: {}\n", m.name, m.description)
});
let yaml = yaml.strip_prefix("---\n").unwrap_or(&yaml);
let yaml = if yaml.ends_with('\n') {
yaml.to_string()
} else {
format!("{yaml}\n")
};
format!("---\n{yaml}---\n{}\n", body.trim_end())
}
pub(crate) fn split_skill_md(raw: &str) -> Result<(SkillManifest, String), SkillError> {
if !raw.starts_with("---") {
return Err(SkillError::Invalid {
path: "<in-memory SKILL.md>".into(),
reason: "missing leading `---` frontmatter delimiter".into(),
});
}
let rest = &raw[3..];
let end = rest.find("\n---").ok_or_else(|| SkillError::Invalid {
path: "<in-memory SKILL.md>".into(),
reason: "missing closing `---` for frontmatter".into(),
})?;
let yaml_str = &rest[..end];
let after = &rest[end + 4..];
let body = after.strip_prefix('\n').unwrap_or(after);
let m: SkillManifest = serde_yaml::from_str(yaml_str).map_err(|e| SkillError::Invalid {
path: "<in-memory SKILL.md>".into(),
reason: format!("YAML schema: {e}"),
})?;
Ok((m, body.to_string()))
}
pub(crate) fn validate_and_lint(m: &SkillManifest, body: &str) -> Result<(), Vec<String>> {
if let Err(e) = harness_context::skills::validate(m) {
return Err(vec![e.to_string()]);
}
let candidate =
harness_context::skills::FileSkill::new(m.clone(), body.to_string(), Vec::new());
let complaints: Vec<String> = harness_context::skills::lint_skills(&[candidate])
.into_iter()
.filter(|f| f.severity != harness_context::skills::LintSeverity::Info)
.map(|f| format!("{:?}: {}", f.severity, f.message))
.collect();
if complaints.is_empty() {
Ok(())
} else {
Err(complaints)
}
}
fn tokens(text: &str) -> std::collections::HashSet<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 4)
.map(|w| w.to_ascii_lowercase())
.filter(|w| !STOPWORDS.contains(&w.as_str()))
.collect()
}
pub(crate) fn overlap_similarity(a: &str, b: &str) -> f32 {
let (ta, tb) = (tokens(a), tokens(b));
if ta.is_empty() || tb.is_empty() {
return 0.0;
}
let inter = ta.intersection(&tb).count();
inter as f32 / ta.len().min(tb.len()) as f32
}
pub(crate) fn utc_today() -> String {
let days = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| (d.as_secs() / 86_400) as i64)
.unwrap_or(0);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_produces_spec_legal_names() {
assert_eq!(slugify("Deploy Runbook (v2)"), "deploy-runbook-v2");
assert_eq!(slugify(" --Fix__Bug-- "), "fix-bug");
assert_eq!(slugify("PDF Processing"), "pdf-processing");
assert_eq!(
slugify("你好"),
"",
"no ASCII survivors → caller falls back"
);
for input in [
"Deploy Runbook (v2)",
"a".repeat(200).as_str(),
"one two three four five six seven eight nine ten eleven twelve thirteen",
] {
let s = slugify(input);
assert!(
harness_context::skills::validate_name(&s).is_ok(),
"slugify({input:?}) = {s:?} must be a legal skill name"
);
}
}
#[test]
fn finalize_description_adds_a_routing_cue_and_length() {
let d = finalize_description(
"Deploys the site.",
"the user asked to deploy the marketing site to production",
"fallback",
);
assert!(d.to_ascii_lowercase().contains("use when"), "{d}");
assert!(d.len() >= DESCRIPTION_MIN, "{} chars: {d}", d.len());
assert!(!d.contains('\n'));
}
#[test]
fn finalize_description_keeps_an_existing_cue() {
let original = "Deploy the marketing site to production. Use when the user asks to ship or release the site.";
assert_eq!(finalize_description(original, "whatever", "f"), original);
}
#[test]
fn render_and_split_round_trip_with_awkward_text() {
let desc = "Deploy: the site — see #ops, \"carefully\", 100% of the time. Use when asked.";
let mut meta = BTreeMap::new();
meta.insert("experience".into(), serde_json::json!({"revision": 3}));
let m = manifest("deploy-runbook", desc, meta);
let md = render_skill_md(&m, "# Deploy\n\n1. build\n");
let (back, body) = split_skill_md(&md).unwrap();
assert_eq!(back.name, "deploy-runbook");
assert_eq!(back.description, desc);
assert_eq!(back.metadata["experience"]["revision"], 3);
assert!(body.contains("1. build"));
}
#[test]
fn overlap_beats_jaccard_on_a_lopsided_pair() {
let situation = "deploy the website to production and verify it is live";
let description = "Deploy the website to production. Use when the user asks to ship, \
release, or push the site live.";
let sim = overlap_similarity(situation, description);
assert!(sim >= 0.6, "genuine duplicate scored only {sim}");
let unrelated = "Summarise a PDF invoice into a spreadsheet row.";
assert!(overlap_similarity(situation, unrelated) < 0.3);
}
#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_000), (2022, 1, 8));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
assert_eq!(civil_from_days(11_017), (2000, 3, 1));
}
#[test]
fn utc_today_is_a_plausible_iso_date() {
let t = utc_today();
assert_eq!(t.len(), 10, "{t}");
assert!(t.starts_with("20"), "{t}");
assert_eq!(t.matches('-').count(), 2, "{t}");
}
}