use std::path::{Path, PathBuf};
use std::process::Command;
fn command(dir: &Path, program: &str, args: &[&str]) -> Command {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(dir).env_clear();
for keep in ["PATH", "SYSTEMROOT"] {
if let Some(value) = std::env::var_os(keep) {
cmd.env(keep, value);
}
}
cmd.env("HOME", dir).env("GIT_CONFIG_NOSYSTEM", "1");
cmd
}
fn fairlead(dir: &Path, args: &[&str]) -> (i32, String) {
let out = command(dir, env!("CARGO_BIN_EXE_fairlead"), args)
.output()
.unwrap();
let text =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
(out.status.code().unwrap_or(-1), text)
}
const CONFIG: &str = "[[skills.routes]]\nskill = \".claude/skills/style/SKILL.md\"\nalways = true";
fn write(dir: &Path, rel: &str, text: &str) {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
fn repo(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("fairlead-import-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".git")).unwrap();
write(&dir, "fairlead.toml", CONFIG);
write(
&dir,
".claude/skills/style/SKILL.md",
"---\nname: style\n---\nTabs.\n",
);
write(
&dir,
".claude/rules/api.md",
"---\ndescription: How API handlers validate\npaths:\n - \"src/api/**\"\n---\n# API\nValidate first.\n",
);
write(&dir, ".claude/rules/notes.md", "# Notes\nNo scope here.\n");
write(
&dir,
".cursor/rules/react.mdc",
"---\ndescription: React components\nglobs: *.tsx, src/ui/**\nalwaysApply: false\n---\nUse hooks.\n",
);
write(
&dir,
".cursor/rules/house.mdc",
"---\nalwaysApply: true\n---\nShort functions. Small files.\n",
);
dir
}
fn read(dir: &Path, rel: &str) -> String {
std::fs::read_to_string(dir.join(rel)).unwrap()
}
#[test]
fn a_dry_run_prints_the_skills_and_routes_and_writes_nothing() {
let dir = repo("dry");
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules"]);
assert_eq!(code, 0, "{out}");
for want in [
"skill .claude/skills/api/SKILL.md from .claude/rules/api.md (src/api/**)",
"How API handlers validate",
"skill .claude/skills/notes/SKILL.md from .claude/rules/notes.md (always)",
"# fairlead import rules .claude/rules\n[[skills.routes]]\nskill = \".claude/skills/api/SKILL.md\"\npaths = [\"src/api/**\"]",
"nothing written",
"rule files stay where they are",
] {
assert!(out.contains(want), "missing {want:?} in:\n{out}");
}
assert!(!dir.join(".claude/skills/api").exists());
assert_eq!(read(&dir, "fairlead.toml"), CONFIG);
}
#[test]
fn write_adds_skills_and_routes_the_config_accepts_and_a_rerun_changes_nothing() {
let dir = repo("write");
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules", "--write"]);
assert_eq!(code, 0, "{out}");
let (code, out) = fairlead(&dir, &["import", "rules", ".cursor/rules", "--write"]);
assert_eq!(code, 0, "{out}");
assert_eq!(
read(&dir, ".claude/skills/api/SKILL.md"),
"---\nname: api\ndescription: \"How API handlers validate\"\n---\n# API\nValidate first.\n"
);
assert!(read(&dir, ".claude/skills/house/SKILL.md")
.contains("description: \"Short functions. Small files.\""));
let config = read(&dir, "fairlead.toml");
assert!(config.starts_with(CONFIG), "existing text kept:\n{config}");
for want in [
"# fairlead import rules .claude/rules\n",
"# fairlead import rules .cursor/rules\n",
"paths = [\"**/*.tsx\", \"src/ui/**\"]",
"skill = \".claude/skills/house/SKILL.md\"\nalways = true",
] {
assert!(config.contains(want), "missing {want:?} in:\n{config}");
}
assert!(dir.join(".claude/rules/api.md").exists(), "rule files stay");
let (code, out) = fairlead(&dir, &["config", "check"]);
assert_eq!(code, 0, "{out}");
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules", "--write"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("already routed: .claude/skills/api/SKILL.md"),
"{out}"
);
assert!(out.contains("nothing to import"), "{out}");
assert_eq!(
read(&dir, "fairlead.toml"),
config,
"a rerun appends nothing"
);
}
#[test]
fn a_different_skill_in_the_way_stops_the_write_before_anything_is_written() {
let dir = repo("clash");
write(&dir, ".claude/skills/react/SKILL.md", "someone else's\n");
let (code, out) = fairlead(&dir, &["import", "rules", ".cursor/rules", "--write"]);
assert_eq!(code, 1, "{out}");
assert!(
out.contains(".claude/skills/react/SKILL.md exists with other content"),
"{out}"
);
assert!(!dir.join(".claude/skills/house").exists());
assert_eq!(read(&dir, "fairlead.toml"), CONFIG);
}
#[test]
fn an_edited_rule_refreshes_its_skill_body_and_keeps_the_front_matter() {
let dir = repo("refresh");
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules", "--write"]);
assert_eq!(code, 0, "{out}");
let skill = ".claude/skills/api/SKILL.md";
let hand = read(&dir, skill).replace("How API handlers validate", "Edited by hand");
write(&dir, skill, &hand);
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules", "--check"]);
assert_eq!(code, 0, "an unchanged rule passes the check:\n{out}");
let rule = read(&dir, ".claude/rules/api.md");
write(
&dir,
".claude/rules/api.md",
&format!("{rule}Then authorize.\n"),
);
let config = read(&dir, "fairlead.toml");
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules", "--check"]);
assert_eq!(code, 1, "{out}");
assert!(
out.contains("lags its rule: .claude/skills/api/SKILL.md"),
"{out}"
);
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("dry run: --write refreshes 1 skill(s)"),
"{out}"
);
assert_eq!(read(&dir, skill), hand, "a dry run writes nothing");
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules", "--write"]);
assert_eq!(code, 0, "{out}");
assert!(
out.contains("refreshed .claude/skills/api/SKILL.md"),
"{out}"
);
let refreshed = read(&dir, skill);
assert!(
refreshed.contains("description: \"Edited by hand\""),
"{refreshed}"
);
assert!(
refreshed.ends_with("Validate first.\nThen authorize.\n"),
"{refreshed}"
);
assert_eq!(
read(&dir, "fairlead.toml"),
config,
"the routes stay as they are"
);
let (code, out) = fairlead(&dir, &["import", "rules", ".claude/rules", "--write"]);
assert_eq!(code, 0, "{out}");
assert!(out.contains("nothing to import"), "{out}");
}