use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use fairlead_core::config::{SkillRoute, Skills};
use super::skill::{self, Skill};
pub const MARK: &str = "# Generated by fairlead skills sync";
const CLAUDE_DIR: &str = ".claude/skills";
const AGENTS_DIR: &str = ".agents/skills";
const CURSOR_DIR: &str = ".cursor/rules";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub path: String,
pub text: String,
pub source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Missing,
Stale,
Fresh,
Conflict,
}
#[derive(Debug, Default)]
pub struct Plan {
pub files: Vec<(Target, State)>,
pub orphans: Vec<String>,
pub problems: Vec<String>,
pub not_copied: Vec<String>,
}
impl Plan {
pub fn drift(&self) -> Vec<String> {
let mut out = self.problems.clone();
for (t, state) in &self.files {
match state {
State::Missing => out.push(format!("missing {}", t.path)),
State::Stale => out.push(format!(
"stale {}: differs from what {} gives",
t.path, t.source
)),
State::Conflict => out.push(conflict(&t.path)),
State::Fresh => {}
}
}
for o in &self.orphans {
out.push(format!("orphaned {o}: no route writes it"));
}
out
}
}
pub fn conflict(path: &str) -> String {
format!("conflict {path}: exists without the generated header; left alone")
}
fn header(source: &str, eol: &str) -> String {
format!("{MARK} from {source}; edit that file, not this one.{eol}")
}
pub fn is_generated(text: &str) -> bool {
let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines();
lines.next().map(str::trim_end) == Some("---")
&& lines.next().is_some_and(|l| l.starts_with(MARK))
}
fn front_matter(text: &str) -> Option<(usize, usize)> {
let bom = if text.starts_with('\u{feff}') { 3 } else { 0 };
let rest = &text[bom..];
let open = if rest.starts_with("---\n") {
4
} else if rest.starts_with("---\r\n") {
5
} else {
return None;
};
let mut at = bom + open;
for line in text[at..].split_inclusive('\n') {
at += line.len();
if line.trim_end() == "---" {
return Some((bom + open, at));
}
}
None
}
pub fn copy(text: &str, source: &str, name: &str) -> String {
match front_matter(text) {
Some((open_end, _)) => {
let eol = if text[..open_end].ends_with("\r\n") {
"\r\n"
} else {
"\n"
};
format!(
"{}{}{}",
&text[..open_end],
header(source, eol),
&text[open_end..]
)
}
None => format!("---\n{}name: {name}\n---\n{text}", header(source, "\n")),
}
}
pub fn mdc(text: &str, source: &str, description: &str, globs: &[String], always: bool) -> String {
let body = front_matter(text).map_or(text, |(_, start)| &text[start..]);
let mut out = format!("---\n{}", header(source, "\n"));
if !description.is_empty() {
let quoted = serde_json::to_string(description).expect("a string serializes");
out.push_str(&format!("description: {quoted}\n"));
}
if !always && !globs.is_empty() {
out.push_str(&format!("globs: {}\n", globs.join(",")));
}
out.push_str(&format!("alwaysApply: {always}\n---\n{body}"));
out
}
pub fn globs(route: &SkillRoute, module_roots: &dyn Fn(&str) -> Vec<String>) -> Vec<String> {
let mut out = route.paths.clone();
for m in &route.modules {
for root in module_roots(m) {
let glob = if root.is_empty() {
"**".to_string()
} else {
format!("{root}/**")
};
if !out.contains(&glob) {
out.push(glob);
}
}
}
out
}
fn safe_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('.')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
fn home(source: &str) -> Option<&str> {
let source = source.trim_start_matches("./");
let (dir, _) = source.rsplit_once('/')?;
dir.rsplit_once('/').map(|(home, _)| home)
}
fn siblings(root: &Path, source: &str) -> Vec<String> {
let Some((dir, _)) = source.rsplit_once('/') else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
return Vec::new();
};
let mut out: Vec<String> = entries
.filter_map(Result::ok)
.filter(|e| e.file_name() != "SKILL.md")
.map(|e| {
let slash = if e.path().is_dir() { "/" } else { "" };
format!("{dir}/{}{slash}", e.file_name().to_string_lossy())
})
.collect();
out.sort();
out
}
fn render(
skill: &Skill,
route: &SkillRoute,
text: &str,
targets: &[String],
module_roots: &dyn Fn(&str) -> Vec<String>,
) -> Vec<Target> {
let mut out = Vec::new();
let source = &skill.path;
for target in targets {
let (dir, path, body) = match target.as_str() {
"claude" => (
CLAUDE_DIR,
format!("{CLAUDE_DIR}/{}/SKILL.md", skill.name),
copy(text, source, &skill.name),
),
"agents" => (
AGENTS_DIR,
format!("{AGENTS_DIR}/{}/SKILL.md", skill.name),
copy(text, source, &skill.name),
),
"cursor" => (
CURSOR_DIR,
format!("{CURSOR_DIR}/{}.mdc", skill.name),
mdc(
text,
source,
&skill.description,
&globs(route, module_roots),
route.always,
),
),
_ => continue,
};
if home(source) == Some(dir) {
continue;
}
out.push(Target {
path,
text: body,
source: source.clone(),
});
}
out
}
pub fn generated(root: &Path) -> Vec<String> {
let mut candidates = Vec::new();
for dir in [CLAUDE_DIR, AGENTS_DIR] {
if let Ok(entries) = std::fs::read_dir(root.join(dir)) {
for e in entries.filter_map(Result::ok) {
let name = e.file_name().to_string_lossy().into_owned();
candidates.push(format!("{dir}/{name}/SKILL.md"));
}
}
}
if let Ok(entries) = std::fs::read_dir(root.join(CURSOR_DIR)) {
for e in entries.filter_map(Result::ok) {
let name = e.file_name().to_string_lossy().into_owned();
if name.ends_with(".mdc") {
candidates.push(format!("{CURSOR_DIR}/{name}"));
}
}
}
let mut out: Vec<String> = candidates
.into_iter()
.filter(|p| std::fs::read_to_string(root.join(p)).is_ok_and(|t| is_generated(&t)))
.collect();
out.sort();
out
}
pub fn plan(root: &Path, skills: &Skills, module_roots: &dyn Fn(&str) -> Vec<String>) -> Plan {
let mut plan = Plan::default();
let (loaded, bad) = skill::load(root, skills);
for b in bad {
plan.problems
.push(format!("bad-skill {}: {}", b.path, b.reason));
}
let targets = skills.targets.items();
let mut wanted: BTreeMap<String, Target> = BTreeMap::new();
let mut noted = BTreeSet::new();
for s in &loaded {
let Some(route) = skills.routes.items().iter().find(|r| r.skill == s.path) else {
continue;
};
if !safe_name(&s.name) {
plan.problems.push(format!(
"bad-name {}: `{}` can't be a file name; use letters, digits, `-`, `_` and `.`",
s.path, s.name
));
continue;
}
let Ok(text) = std::fs::read_to_string(root.join(&s.path)) else {
continue;
};
for t in render(s, route, &text, targets, module_roots) {
if !t.path.ends_with(".mdc") && noted.insert(s.path.clone()) {
plan.not_copied.extend(siblings(root, &s.path));
}
if let Some(other) = wanted.get(&t.path) {
plan.problems.push(format!(
"clash {}: both {} and {} would write it; give one a different `name`",
t.path, other.source, t.source
));
continue;
}
wanted.insert(t.path.clone(), t);
}
}
for t in wanted.into_values() {
let state = match std::fs::read_to_string(root.join(&t.path)) {
Err(_) => State::Missing,
Ok(have) if !is_generated(&have) => State::Conflict,
Ok(have) if have == t.text => State::Fresh,
Ok(_) => State::Stale,
};
plan.files.push((t, state));
}
plan.orphans = generated(root)
.into_iter()
.filter(|p| !plan.files.iter().any(|(t, _)| &t.path == p))
.collect();
plan
}
#[cfg(test)]
mod tests {
use super::*;
use fairlead_core::config::List;
const SKILL: &str = "---\nname: forms\ndescription: How forms validate.\nallowed-tools: Read\n---\nUse the schema.\n";
fn route(paths: &[&str], modules: &[&str], always: bool) -> SkillRoute {
SkillRoute {
skill: "skills/forms/SKILL.md".into(),
paths: paths.iter().map(|s| s.to_string()).collect(),
modules: modules.iter().map(|s| s.to_string()).collect(),
always,
}
}
fn no_modules(_: &str) -> Vec<String> {
Vec::new()
}
#[test]
fn a_copy_keeps_the_front_matter_and_body_and_puts_the_header_first_inside_it() {
let out = copy(SKILL, "skills/forms/SKILL.md", "forms");
let mut lines = out.lines();
assert_eq!(lines.next(), Some("---"));
assert_eq!(
lines.next(),
Some("# Generated by fairlead skills sync from skills/forms/SKILL.md; edit that file, not this one.")
);
let without: String = out
.lines()
.filter(|l| !l.starts_with(MARK))
.map(|l| format!("{l}\n"))
.collect();
assert_eq!(without, SKILL, "byte for byte apart from the header");
assert!(is_generated(&out));
assert!(!is_generated(SKILL));
}
#[test]
fn a_copy_keeps_crlf_line_ends_and_a_source_without_front_matter_gets_one() {
let crlf = SKILL.replace('\n', "\r\n");
let out = copy(&crlf, "s/SKILL.md", "forms");
assert!(out.starts_with(&format!("---\r\n{MARK} from s/SKILL.md")));
assert_eq!(out.replacen(&header("s/SKILL.md", "\r\n"), "", 1), crlf);
assert!(is_generated(&out));
let bare = copy("Just a body\n", "s/SKILL.md", "bare");
assert!(bare.starts_with("---\n# Generated"), "{bare}");
assert!(bare.ends_with("name: bare\n---\nJust a body\n"), "{bare}");
}
#[test]
fn a_cursor_rule_has_the_description_the_route_globs_and_the_body() {
let out = mdc(
SKILL,
"skills/forms/SKILL.md",
"How forms validate.",
&["src/forms/**".into(), "**/*.form.ts".into()],
false,
);
assert_eq!(
out,
format!(
"---\n{}description: \"How forms validate.\"\nglobs: src/forms/**,**/*.form.ts\nalwaysApply: false\n---\nUse the schema.\n",
header("skills/forms/SKILL.md", "\n")
)
);
assert!(is_generated(&out));
}
#[test]
fn an_always_route_applies_always_and_a_route_with_no_globs_is_left_to_the_description() {
let always = mdc(SKILL, "s", "d", &["src/**".into()], true);
assert!(always.contains("alwaysApply: true\n"), "{always}");
assert!(!always.contains("globs:"), "{always}");
let unresolved = globs(&route(&[], &["core"], false), &no_modules);
assert!(unresolved.is_empty());
let requested = mdc(SKILL, "s", "d", &unresolved, false);
assert!(!requested.contains("globs:"), "{requested}");
assert!(requested.contains("alwaysApply: false\n"), "{requested}");
}
#[test]
fn module_routes_glob_their_module_roots_after_the_paths() {
let roots = |m: &str| match m {
"core" => vec!["packages/core".to_string()],
"top" => vec![String::new()],
_ => Vec::new(),
};
assert_eq!(
globs(
&route(&["docs/**"], &["core", "top", "gone"], false),
&roots
),
["docs/**", "packages/core/**", "**"]
);
}
#[test]
fn a_source_already_in_a_targets_place_is_not_copied_there() {
let dir = std::env::temp_dir().join(format!("fairlead-sync-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".claude/skills/forms/scripts")).unwrap();
std::fs::write(dir.join(".claude/skills/forms/SKILL.md"), SKILL).unwrap();
let skills = Skills {
routes: List::Items(vec![SkillRoute {
skill: ".claude/skills/forms/SKILL.md".into(),
..route(&["src/forms/**"], &[], false)
}]),
..Skills::default()
};
let plan = plan(&dir, &skills, &no_modules);
let paths: Vec<&str> = plan.files.iter().map(|(t, _)| t.path.as_str()).collect();
assert_eq!(
paths,
[".agents/skills/forms/SKILL.md", ".cursor/rules/forms.mdc"]
);
assert!(plan.files.iter().all(|(_, s)| *s == State::Missing));
assert_eq!(plan.not_copied, [".claude/skills/forms/scripts/"]);
assert!(plan.problems.is_empty(), "{:?}", plan.problems);
}
#[test]
fn a_name_that_cant_be_a_file_name_is_a_problem() {
assert!(safe_name("web-forms_2.x"));
for bad in ["", "../up", "a/b", ".hidden", "with space"] {
assert!(!safe_name(bad), "{bad}");
}
}
}