use std::ops::Range;
pub const BEGIN: &str = "<!-- fairlead:begin -->";
pub const END: &str = "<!-- fairlead:end -->";
pub const MAX_LINES: usize = 40;
pub struct Lists {
pub lessons: Vec<String>,
pub skills: Vec<String>,
}
pub fn item(text: &str) -> String {
let one_line = text.split_whitespace().collect::<Vec<_>>().join(" ");
format!("- {}", one_line.replace("<!--", "<!--"))
}
fn loop_lines(lessons_dir: &str) -> Vec<String> {
vec![
BEGIN.to_string(),
format!(
"<!-- Generated by `fairlead agents sync`: edit fairlead.toml or {lessons_dir} instead, then sync again. -->"
),
"**Fairlead: the change loop**".to_string(),
"- Before an edit: `fairlead brief <paths>` names what they reach, the tests that will run, and the lessons and skills that apply.".to_string(),
"- Before you finish: `fairlead done` runs the gate the change has to pass.".to_string(),
"- After it: `fairlead receipt` compares the change with its brief, and `fairlead next` names the step that's due.".to_string(),
"- The pull request: open it as a draft, `gh pr create --draft`, and mark it ready, `gh pr ready`, once `fairlead done` passes.".to_string(),
"- `fairlead find <words>` searches the lessons, skills, docs headings and symbols.".to_string(),
"- `fairlead learn` records a lesson a change taught, with its scope and evidence.".to_string(),
]
}
fn section_len(total: usize, shown: usize) -> usize {
match total {
0 => 0,
_ => 2 + shown + usize::from(shown < total),
}
}
fn section(out: &mut Vec<String>, label: &str, items: &[String], shown: usize, more: &str) {
if items.is_empty() {
return;
}
out.push(String::new());
out.push(label.to_string());
out.extend(items[..shown].iter().cloned());
if shown < items.len() {
out.push(format!("- … {} more: {more}", items.len() - shown));
}
}
pub fn render(lessons_dir: &str, lists: &Lists) -> Vec<String> {
let mut out = loop_lines(lessons_dir);
let (total_l, total_s) = (lists.lessons.len(), lists.skills.len());
let (mut shown_l, mut shown_s) = (total_l, total_s);
let size =
|l: usize, s: usize| out.len() + 1 + section_len(total_l, l) + section_len(total_s, s);
while size(shown_l, shown_s) > MAX_LINES && shown_l + shown_s > 0 {
if shown_l > shown_s {
shown_l -= 1;
} else {
shown_s -= 1;
}
}
section(
&mut out,
"**Lessons for every change**",
&lists.lessons,
shown_l,
"`fairlead lessons list`",
);
section(
&mut out,
"**Skills: load the one for the code you change**",
&lists.skills,
shown_s,
"`fairlead brief --all`",
);
out.push(END.to_string());
out
}
pub fn eol(text: &str) -> &'static str {
match text.find('\n') {
Some(i) if i > 0 && text.as_bytes()[i - 1] == b'\r' => "\r\n",
_ => "\n",
}
}
pub fn locate(text: &str) -> Result<Option<Range<usize>>, String> {
let begins: Vec<usize> = text.match_indices(BEGIN).map(|(i, _)| i).collect();
let ends: Vec<usize> = text.match_indices(END).map(|(i, _)| i).collect();
match (begins.as_slice(), ends.as_slice()) {
([], []) => Ok(None),
([b], [e]) if b < e => Ok(Some(*b..*e + END.len())),
([_], [_]) => Err("its end marker comes before its begin marker".into()),
([_], []) => Err(format!("it has `{BEGIN}` but no `{END}`")),
([], [_]) => Err(format!("it has `{END}` but no `{BEGIN}`")),
_ if begins.len() > 1 => Err(format!(
"it has {} begin markers; keep one block",
begins.len()
)),
_ => Err(format!("it has {} end markers; keep one block", ends.len())),
}
}
pub fn splice(text: Option<&str>, lines: &[String]) -> Result<String, String> {
let Some(text) = text else {
return Ok(lines.join("\n") + "\n");
};
let eol = eol(text);
let block = lines.join(eol);
Ok(match locate(text)? {
Some(r) => format!("{}{block}{}", &text[..r.start], &text[r.end..]),
None if text.is_empty() => block,
None if text.ends_with('\n') => format!("{text}{eol}{block}{eol}"),
None => format!("{text}{eol}{eol}{block}"),
})
}
fn strip_eol(text: &str) -> Option<&str> {
text.strip_suffix("\r\n")
.or_else(|| text.strip_suffix('\n'))
}
pub fn clean(text: &str) -> Result<Option<String>, String> {
let Some(r) = locate(text)? else {
return Ok(None);
};
let mut before = &text[..r.start];
let rest = &text[r.end..];
let after = rest
.strip_prefix("\r\n")
.or_else(|| rest.strip_prefix('\n'));
if let Some(shorter) = strip_eol(before).filter(|b| b.ends_with('\n')) {
before = shorter;
}
if after.is_none() && rest.is_empty() {
before = strip_eol(before).unwrap_or(before);
}
Ok(Some(format!("{before}{}", after.unwrap_or(rest))))
}
#[cfg(test)]
mod tests {
use super::*;
fn lists(lessons: usize, skills: usize) -> Lists {
Lists {
lessons: (0..lessons)
.map(|i| item(&format!("Lesson {i} (l-{i})")))
.collect(),
skills: (0..skills)
.map(|i| item(&format!("s{i}: does {i} (.claude/skills/s{i}/SKILL.md)")))
.collect(),
}
}
struct Lcg(u64);
impl Lcg {
fn next(&mut self, n: usize) -> usize {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((self.0 >> 33) as usize) % n
}
}
const PIECES: [&str; 9] = [
"# Working here",
"Ünïcödé ✓ 日本語 — straße",
"\tindented with a tab",
"<!-- a comment of the team's own -->",
"",
"- a list item",
"trailing spaces ",
"fairlead:begin without the comment",
"```",
];
fn text(rng: &mut Lcg, eol: &str) -> String {
let n = rng.next(6);
let lines: Vec<&str> = (0..n).map(|_| PIECES[rng.next(PIECES.len())]).collect();
let mut t = lines.join(eol);
if n > 0 && rng.next(2) == 0 {
t.push_str(eol);
}
t
}
fn outside(text: &str) -> (String, String) {
let r = locate(text).unwrap().expect("a block");
(text[..r.start].to_string(), text[r.end..].to_string())
}
#[test]
fn bytes_outside_the_markers_never_change_across_sync_resync_a_config_change_and_clean() {
let mut rng = Lcg(7);
let (a, b) = (
render(".fairlead/lessons", &lists(2, 1)),
render("lessons", &lists(30, 30)),
);
for case in 0..400 {
let eol = if case % 2 == 0 { "\n" } else { "\r\n" };
let original = text(&mut rng, eol);
let synced = splice(Some(&original), &a).unwrap();
let kept = outside(&synced);
assert!(synced.starts_with(&original), "{original:?}");
let resynced = splice(Some(&synced), &a).unwrap();
assert_eq!(resynced, synced, "sync twice is sync once");
let changed = splice(Some(&synced), &b).unwrap();
assert_eq!(outside(&changed), kept, "{original:?}");
if original.contains('\n') && eol == "\r\n" {
assert!(!changed.replace("\r\n", "").contains('\n'), "{changed:?}");
}
assert_eq!(clean(&changed).unwrap().as_deref(), Some(original.as_str()));
let (head, tail) = (text(&mut rng, eol), text(&mut rng, eol));
let placed = format!("{head}{BEGIN}{eol}old{eol}{END}{tail}");
let replaced = splice(Some(&placed), &b).unwrap();
assert_eq!(outside(&replaced), (head.clone(), tail.clone()));
assert_eq!(
splice(Some(&replaced), &a).map(|s| outside(&s)),
Ok((head, tail))
);
}
}
#[test]
fn a_new_file_is_the_block_and_a_newline_and_an_empty_one_keeps_no_final_newline() {
let block = render("l", &lists(0, 0));
let created = splice(None, &block).unwrap();
assert_eq!(created, block.join("\n") + "\n");
assert_eq!(clean(&created).unwrap().as_deref(), Some(""));
let from_empty = splice(Some(""), &block).unwrap();
assert_eq!(from_empty, block.join("\n"));
assert_eq!(clean(&from_empty).unwrap().as_deref(), Some(""));
let crlf = splice(Some("# Rules\r\n"), &block).unwrap();
assert!(
crlf.starts_with("# Rules\r\n\r\n<!-- fairlead:begin -->\r\n"),
"{crlf:?}"
);
assert!(crlf.ends_with("<!-- fairlead:end -->\r\n"));
let bare = splice(Some("# Rules"), &block).unwrap();
assert!(bare.ends_with(END), "no final newline stays none: {bare:?}");
}
#[test]
fn markers_that_dont_make_one_block_are_refused_by_name() {
let block = render("l", &lists(0, 0));
for (text, says) in [
(format!("a\n{BEGIN}\nb\n"), "no `<!-- fairlead:end -->`"),
(format!("{END}\n"), "no `<!-- fairlead:begin -->`"),
(format!("{BEGIN}\n{BEGIN}\n{END}\n"), "2 begin markers"),
(format!("{BEGIN}\n{END}\n{END}\n"), "2 end markers"),
(format!("{END}\n{BEGIN}\n"), "before its begin marker"),
] {
let err = splice(Some(&text), &block).unwrap_err();
assert!(err.contains(says), "{err}");
assert!(clean(&text).is_err());
}
}
#[test]
fn the_block_stays_within_forty_lines_and_says_how_many_more() {
let small = render("l", &lists(3, 4));
assert_eq!(small.len(), 10 + 2 + 3 + 2 + 4);
assert!(!small.iter().any(|l| l.contains("more:")));
let big = render("l", &lists(30, 25));
assert_eq!(big.len(), MAX_LINES);
let more: Vec<&String> = big.iter().filter(|l| l.contains("more:")).collect();
assert_eq!(more.len(), 2, "{big:#?}");
let listed_l = big.iter().filter(|l| l.starts_with("- Lesson")).count();
let listed_s = big.iter().filter(|l| l.starts_with("- s")).count();
assert_eq!(listed_l + listed_s, MAX_LINES - 10 - 2 * 3);
assert_eq!(
*more[0],
format!("- … {} more: `fairlead lessons list`", 30 - listed_l)
);
assert_eq!(
*more[1],
format!("- … {} more: `fairlead brief --all`", 25 - listed_s)
);
let lopsided = render("l", &lists(1, 80));
assert_eq!(lopsided.len(), MAX_LINES);
assert!(lopsided.contains(&"- Lesson 0 (l-0)".to_string()));
assert_eq!(
render("l", &lists(30, 25)),
big,
"the same inputs, the same block"
);
}
#[test]
fn an_item_is_one_line_and_cant_close_the_block() {
assert_eq!(
item("a\n b <!-- fairlead:end -->"),
"- a b <!-- fairlead:end -->"
);
}
}