Skip to main content

eval_magic/adapters/opencode/
mod.rs

1//! OpenCode harness support.
2//!
3//! The declarative half of this harness lives in `harnesses/opencode.toml`
4//! (including the stage-name rules, expressed as a regex + length cap); this
5//! file keeps only the code-backed `opencode` slug capability — sanitization
6//! and truncation the descriptor's format-string template cannot express.
7
8/// True when `name` satisfies OpenCode's skill-name rules:
9/// - 1–64 characters
10/// - lowercase alphanumeric with single-hyphen separators
11/// - no leading/trailing/consecutive hyphens
12fn is_valid_opencode_name(name: &str) -> bool {
13    if name.is_empty() || name.len() > 64 {
14        return false;
15    }
16    let mut prev = '-';
17    for ch in name.chars() {
18        if ch == '-' {
19            if prev == '-' {
20                return false;
21            }
22        } else if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() {
23            return false;
24        }
25        prev = ch;
26    }
27    !name.starts_with('-') && !name.ends_with('-')
28}
29
30/// Sanitize an arbitrary identifier so it is a valid OpenCode skill name.
31fn sanitize_opencode_name(name: &str) -> String {
32    let mut out = String::new();
33    let mut prev_hyphen = false;
34    for ch in name.to_ascii_lowercase().chars() {
35        if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
36            out.push(ch);
37            prev_hyphen = false;
38        } else if !prev_hyphen {
39            out.push('-');
40            prev_hyphen = true;
41        }
42    }
43    while out.ends_with('-') {
44        out.pop();
45    }
46    while out.starts_with('-') {
47        out.remove(0);
48    }
49    if out.is_empty() {
50        out.push_str("skill");
51    }
52    if out.len() > 64 {
53        out.truncate(64);
54        while out.ends_with('-') {
55            out.pop();
56        }
57    }
58    out
59}
60
61/// Build a slug that is valid for OpenCode's skill directory + frontmatter name
62/// constraints. `prefix` is the conspicuous staged-skill prefix, preserved so
63/// cleanup prefix-scans still find it. `pub(crate)` because the `opencode`
64/// named slug capability dispatches here.
65pub(crate) fn opencode_slug(
66    prefix: &str,
67    iteration: u32,
68    condition: &str,
69    skill_name: &str,
70) -> String {
71    let condition = sanitize_opencode_name(condition);
72    let skill = sanitize_opencode_name(skill_name);
73    let base = format!("{prefix}{iteration}-{condition}-{skill}");
74    if base.len() <= 64 && is_valid_opencode_name(&base) {
75        return base;
76    }
77    // If the combined slug is too long, truncate the skill portion.
78    let prefix = format!("{prefix}{iteration}-{condition}-");
79    let budget = 64usize.saturating_sub(prefix.len());
80    let mut truncated = skill.clone();
81    truncated.truncate(budget);
82    while truncated.ends_with('-') {
83        truncated.pop();
84    }
85    if truncated.is_empty() {
86        truncated.push_str("skill");
87    }
88    format!("{prefix}{truncated}")
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    const PREFIX: &str = "slow-powers-eval-";
96
97    #[test]
98    fn opencode_slug_sanitizes_underscores_and_special_characters() {
99        assert_eq!(
100            opencode_slug(PREFIX, 1, "with_skill", "My_Skill!"),
101            "slow-powers-eval-1-with-skill-my-skill"
102        );
103        assert_eq!(
104            opencode_slug(PREFIX, 2, "without_skill", "snake_case"),
105            "slow-powers-eval-2-without-skill-snake-case"
106        );
107    }
108
109    #[test]
110    fn opencode_slug_truncates_to_valid_max_length() {
111        let very_long = "a".repeat(200);
112        let slug = opencode_slug(PREFIX, 1, "with_skill", &very_long);
113        assert!(slug.len() <= 64);
114        assert!(is_valid_opencode_name(&slug));
115        assert!(slug.starts_with("slow-powers-eval-1-with-skill-"));
116    }
117}