use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SkillSource {
User,
Agents,
Vendor { name: String },
ExtraPath,
}
impl SkillSource {
pub fn as_str(&self) -> &str {
match self {
Self::User => "user",
Self::Agents => "agents",
Self::Vendor { name } => name.as_str(),
Self::ExtraPath => "extra",
}
}
pub fn wire_name(&self) -> String {
self.as_str().to_owned()
}
pub fn serialize_wire<S: serde::Serializer>(
src: &Self,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(src.as_str())
}
pub fn deserialize_host<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Self, D::Error> {
struct HostVisitor;
impl<'de> serde::de::Visitor<'de> for HostVisitor {
type Value = SkillSource;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a source wire token or serde SkillSource")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<SkillSource, E> {
SkillSource::from_host_token(v).ok_or_else(|| {
E::custom(format!(
"unknown source token: {}",
crate::sanitize_error_token(v)
))
})
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
map: M,
) -> Result<SkillSource, M::Error> {
SkillSource::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map_err(
|e| serde::de::Error::custom(crate::sanitize_error_token(&e.to_string())),
)
}
}
deserializer.deserialize_any(HostVisitor)
}
pub fn from_host_token(token: &str) -> Option<Self> {
if let Ok(Some(name)) = Self::parse_vendor_token(token) {
return Some(Self::Vendor { name });
}
match token.trim() {
"user" => Some(Self::User),
"agents" => Some(Self::Agents),
"extra" | "extraPath" | "extra_path" | "config" => Some(Self::ExtraPath),
_ => None,
}
}
pub const VENDOR_TOKENS: &'static [&'static str] = &["bline", "claude", "cursor", "grok"];
pub const COMPAT_VENDOR_TOKENS: &'static [&'static str] = &["claude", "cursor", "grok"];
pub fn empty_triggers_not_always_active(&self) -> bool {
match self {
Self::Vendor { name } => Self::COMPAT_VENDOR_TOKENS.contains(&name.as_str()),
_ => false,
}
}
pub fn vendor_choice_list() -> String {
match Self::VENDOR_TOKENS {
[] => String::new(),
[one] => (*one).to_owned(),
[a, b] => format!("{a} or {b}"),
tokens => {
let (last, rest) = tokens.split_last().expect("VENDOR_TOKENS len>=3");
format!("{}, or {last}", rest.join(", "))
}
}
}
pub fn parse_vendor_token(token: &str) -> Result<Option<String>, String> {
let trimmed = token.trim();
if trimmed.is_empty() {
return Ok(None);
}
let stripped = trimmed.strip_prefix('.').unwrap_or(trimmed);
let lower = stripped.to_ascii_lowercase();
if Self::VENDOR_TOKENS.contains(&lower.as_str()) {
return Ok(Some(lower));
}
Err(format!(
"unknown vendor: {} (use {})",
crate::sanitize_error_token(trimmed),
Self::vendor_choice_list()
))
}
pub fn parse_vendor_roots<I, S>(tokens: I) -> Result<Vec<String>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut out = Vec::new();
for token in tokens {
let Some(name) = Self::parse_vendor_token(token.as_ref())? else {
continue;
};
if !out.iter().any(|e| e == &name) {
out.push(name);
}
}
Ok(out)
}
}
impl fmt::Display for SkillSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::SkillSource;
#[test]
fn as_str_labels() {
assert_eq!(SkillSource::User.as_str(), "user");
assert_eq!(SkillSource::Agents.as_str(), "agents");
assert_eq!(SkillSource::ExtraPath.as_str(), "extra");
assert_eq!(
SkillSource::Vendor {
name: "bline".to_owned()
}
.as_str(),
"bline"
);
assert_eq!(
SkillSource::Vendor {
name: "claude".to_owned()
}
.as_str(),
"claude"
);
}
#[test]
fn serde_unit_variants_are_camel_case_strings() {
let user = serde_json::to_string(&SkillSource::User).expect("ser");
let agents = serde_json::to_string(&SkillSource::Agents).expect("ser");
let extra = serde_json::to_string(&SkillSource::ExtraPath).expect("ser");
assert_eq!(user, "\"user\"");
assert_eq!(agents, "\"agents\"");
assert_eq!(extra, "\"extraPath\"");
assert_eq!(
serde_json::from_str::<SkillSource>(&user).expect("de"),
SkillSource::User
);
assert_eq!(
serde_json::from_str::<SkillSource>(&agents).expect("de"),
SkillSource::Agents
);
assert_eq!(
serde_json::from_str::<SkillSource>(&extra).expect("de"),
SkillSource::ExtraPath
);
}
#[test]
fn serde_vendor_is_externally_tagged() {
let src = SkillSource::Vendor {
name: "cursor".to_owned(),
};
let json = serde_json::to_string(&src).expect("ser");
assert_eq!(json, r#"{"vendor":{"name":"cursor"}}"#);
let back: SkillSource = serde_json::from_str(&json).expect("de");
assert_eq!(back, src);
}
#[test]
fn v1_has_no_project_variant() {
let src = SkillSource::Agents;
match src {
SkillSource::User
| SkillSource::Agents
| SkillSource::Vendor { .. }
| SkillSource::ExtraPath => {}
}
}
#[test]
fn compat_vendor_tokens_are_a_named_subset() {
for token in SkillSource::COMPAT_VENDOR_TOKENS {
assert!(
SkillSource::VENDOR_TOKENS.contains(token),
"compat vendor {token} must be a VENDOR_TOKENS member"
);
assert!(
SkillSource::Vendor {
name: (*token).to_owned()
}
.empty_triggers_not_always_active(),
"{token} empty triggers must not auto-inject"
);
}
assert!(
!SkillSource::COMPAT_VENDOR_TOKENS.contains(&"bline"),
"bline stays always-active when triggers are empty"
);
assert!(
!SkillSource::Vendor {
name: "bline".to_owned()
}
.empty_triggers_not_always_active()
);
assert!(!SkillSource::Agents.empty_triggers_not_always_active());
assert!(!SkillSource::User.empty_triggers_not_always_active());
assert!(!SkillSource::ExtraPath.empty_triggers_not_always_active());
}
#[test]
fn extra_as_str_is_not_serde_token() {
assert_eq!(SkillSource::ExtraPath.as_str(), "extra");
assert_eq!(SkillSource::ExtraPath.wire_name(), "extra");
let json = serde_json::to_string(&SkillSource::ExtraPath).expect("ser");
assert_eq!(json, "\"extraPath\"");
}
#[test]
fn host_json_source_is_wire_name_and_accepts_old_enum() {
#[derive(serde::Serialize, serde::Deserialize)]
struct Row {
#[serde(
serialize_with = "SkillSource::serialize_wire",
deserialize_with = "SkillSource::deserialize_host"
)]
source: SkillSource,
}
let extra = serde_json::to_value(&Row {
source: SkillSource::ExtraPath,
})
.expect("ser extra");
assert_eq!(extra["source"], "extra", "{extra}");
let vendor = serde_json::to_value(&Row {
source: SkillSource::Vendor {
name: "claude".to_owned(),
},
})
.expect("ser vendor");
assert_eq!(vendor["source"], "claude", "{vendor}");
let old_extra: Row = serde_json::from_str(r#"{"source":"extraPath"}"#).expect("old extra");
assert_eq!(old_extra.source, SkillSource::ExtraPath);
let snake_extra: Row =
serde_json::from_str(r#"{"source":"extra_path"}"#).expect("snake extra");
assert_eq!(snake_extra.source, SkillSource::ExtraPath);
let old_vendor: Row =
serde_json::from_str(r#"{"source":{"vendor":{"name":"claude"}}}"#).expect("old vendor");
assert_eq!(old_vendor.source.as_str(), "claude");
}
#[test]
fn from_host_token_maps_list_and_tui_tokens() {
assert_eq!(
SkillSource::from_host_token("user"),
Some(SkillSource::User)
);
assert_eq!(
SkillSource::from_host_token("agents"),
Some(SkillSource::Agents)
);
assert_eq!(
SkillSource::from_host_token("extra"),
Some(SkillSource::ExtraPath)
);
assert_eq!(
SkillSource::from_host_token("extraPath"),
Some(SkillSource::ExtraPath)
);
assert_eq!(
SkillSource::from_host_token("extra_path"),
Some(SkillSource::ExtraPath)
);
assert_eq!(
SkillSource::from_host_token("config"),
Some(SkillSource::ExtraPath)
);
assert_eq!(
SkillSource::from_host_token("bline"),
Some(SkillSource::Vendor {
name: "bline".to_owned()
})
);
assert_eq!(
SkillSource::from_host_token("claude"),
Some(SkillSource::Vendor {
name: "claude".to_owned()
})
);
assert_eq!(
SkillSource::from_host_token("cursor"),
Some(SkillSource::Vendor {
name: "cursor".to_owned()
})
);
assert_eq!(
SkillSource::from_host_token("grok"),
Some(SkillSource::Vendor {
name: "grok".to_owned()
})
);
assert_eq!(SkillSource::from_host_token("project"), None);
assert_eq!(SkillSource::from_host_token("community"), None);
assert_eq!(SkillSource::from_host_token("Project"), None);
assert_eq!(
SkillSource::from_host_token(".claude"),
Some(SkillSource::Vendor {
name: "claude".to_owned()
})
);
assert_eq!(
SkillSource::from_host_token(".bline"),
Some(SkillSource::Vendor {
name: "bline".to_owned()
})
);
assert_eq!(SkillSource::from_host_token(".user"), None);
assert_eq!(SkillSource::from_host_token(".extra"), None);
for token in [".Claude", "Claude", " CLAUDE ", ".BLINE", "Cursor"] {
let parsed =
SkillSource::parse_vendor_roots([token]).unwrap_or_else(|e| panic!("{token}: {e}"));
assert_eq!(
SkillSource::from_host_token(token),
Some(SkillSource::Vendor {
name: parsed[0].clone()
}),
"from_host_token({token:?}) must match parse_vendor_roots"
);
}
}
#[test]
fn parse_vendor_roots_accepts_dot_and_rejects_unknown() {
assert_eq!(
SkillSource::parse_vendor_roots([".claude", "Bline", " claude "]).expect("ok"),
["claude", "bline"]
);
assert!(
SkillSource::parse_vendor_roots(["", " "])
.expect("empty items")
.is_empty()
);
let err = SkillSource::parse_vendor_roots(["nope"]).expect_err("unknown");
assert!(err.contains("unknown vendor: nope"), "{err}");
let listed = SkillSource::vendor_choice_list();
assert!(
err.contains(&listed),
"unknown-vendor error must name vendor_choice_list: {err}"
);
for token in SkillSource::VENDOR_TOKENS {
assert!(
listed.contains(token),
"vendor_choice_list must name {token}: {listed}"
);
}
let extra = SkillSource::parse_vendor_roots(["extra"]).expect_err("extra");
assert!(
extra.contains("unknown vendor: extra"),
"extra is --path, not a vendor: {extra}"
);
let injected = SkillSource::parse_vendor_roots(["no\npe"]).expect_err("control");
assert!(
injected.contains("unknown vendor: no?pe"),
"vendor errors must stay one line: {injected}"
);
assert!(!injected.contains('\n'), "{injected}");
}
#[test]
fn deserialize_host_unknown_source_token_is_sanitized() {
#[derive(Debug, serde::Deserialize)]
struct Row {
#[serde(deserialize_with = "SkillSource::deserialize_host")]
#[allow(dead_code)]
source: SkillSource,
}
let json = serde_json::json!({ "source": "foo\u{2028}bar\u{2014}" }).to_string();
let err = serde_json::from_str::<Row>(&json).expect_err("unknown source");
let msg = err.to_string();
assert!(
msg.contains("unknown source token:"),
"host source errors must name the token: {msg}"
);
assert_eq!(
msg.lines().count(),
1,
"unknown source token must stay one line: {msg:?}"
);
assert!(
!msg.contains('\u{2028}'),
"U+2028 must not leak from source token: {msg:?}"
);
assert!(
!msg.contains('\u{2014}'),
"em dash must not leak from source token: {msg:?}"
);
assert!(
msg.contains("foo?bar-"),
"hostile source token must be sanitized in place: {msg}"
);
}
#[test]
fn deserialize_host_unknown_source_map_token_is_sanitized() {
#[derive(Debug, serde::Deserialize)]
struct Row {
#[serde(deserialize_with = "SkillSource::deserialize_host")]
#[allow(dead_code)]
source: SkillSource,
}
let json = serde_json::json!({
"source": { "foo\u{2028}bar\u{2014}": { "name": "x" } }
})
.to_string();
let err = serde_json::from_str::<Row>(&json).expect_err("unknown source map");
let msg = err.to_string();
assert!(
msg.contains("unknown variant"),
"host source map errors must name the token: {msg}"
);
assert_eq!(
msg.lines().count(),
1,
"unknown source map token must stay one line: {msg:?}"
);
assert!(
!msg.contains('\u{2028}'),
"U+2028 must not leak from source map token: {msg:?}"
);
assert!(
!msg.contains('\u{2014}'),
"em dash must not leak from source map token: {msg:?}"
);
assert!(
msg.contains("foo?bar-"),
"hostile source map token must be sanitized in place: {msg}"
);
}
}