fairlead 0.14.0

Guardrails and a guided path for coding agents: rules enforced as the agent works, tests scoped to what a change can reach, capped project memory, and KPIs.
//! The marked block `fairlead agents sync` keeps in AGENTS.md and CLAUDE.md:
//! what it says, and how it goes in and comes out of a file without moving
//! a byte outside its markers.

use std::ops::Range;

pub const BEGIN: &str = "<!-- fairlead:begin -->";
pub const END: &str = "<!-- fairlead:end -->";
/// The block's lines, markers included; past it the lists are cut.
pub const MAX_LINES: usize = 40;

/// The always-on lessons and the routed skills, one line each, already
/// written as the block lists them.
pub struct Lists {
    pub lessons: Vec<String>,
    pub skills: Vec<String>,
}

/// One list item, kept to one line and unable to close the block early.
pub fn item(text: &str) -> String {
    let one_line = text.split_whitespace().collect::<Vec<_>>().join(" ");
    format!("- {}", one_line.replace("<!--", "&lt;!--"))
}

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(),
    ]
}

/// The lines a list takes when `shown` of its items are listed.
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));
    }
}

/// The block, markers included, in at most `MAX_LINES` lines: the longer
/// list gives up a line first, so both keep what fits.
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
}

/// The file's own line ending, by its first line: CRLF stays CRLF.
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",
    }
}

/// Where the block is, markers included; none when the file has no
/// markers, and a reason when its markers don't make one block.
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())),
    }
}

/// The file with `lines` as its block: between the markers when it has
/// them, else after a blank line at its end, or the whole of a new file.
/// Everything outside the markers keeps its bytes, line endings and final
/// newline (or lack of one).
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'))
}

/// The file without its block: the markers, what's between them, the
/// block's line ending and the one blank line before it go, so a file
/// `splice` appended to comes back byte for byte. None when it has no block.
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(),
        }
    }

    /// A small fixed-seed generator, so the cases are many and the same each run.
    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 &lt;!-- fairlead:end -->"
        );
    }
}