klasp-agents-codex 0.4.0

Codex agent surface for klasp — writes the AGENTS.md managed-block that documents the gate.
Documentation
//! `AGENTS.md` managed-block writer.
//!
//! Codex reads `AGENTS.md` from the repo root; klasp documents the gate
//! inside a delimited managed block that other tools must leave alone.
//! See [`docs/design.md` §3.1] for the surface-trait shape and
//! [`docs/roadmap.md` §v0.3] for why the markers are namespaced.
//!
//! ## Contract
//!
//! - **Idempotency.** `install(install(input))` == `install(input)`. The
//!   block contents are anchored by the [`MANAGED_START`] / [`MANAGED_END`]
//!   marker lines; re-running install replaces only what's between them.
//! - **Preservation.** Bytes outside the managed block are returned
//!   unchanged, with one tolerated normalisation noted below. This includes
//!   any prose another tool emitted (no shared markers) and pre-existing
//!   user notes.
//! - **Round-trip.** `uninstall(install(input))` is `input` after
//!   normalising the trailing-newline state to a single `\n` (or the empty
//!   string when `input` was empty or whitespace-only). Inputs that already
//!   end in exactly one `\n` round-trip byte-for-byte; an input without a
//!   trailing newline gains one. This canonicalises markdown to the POSIX
//!   "files end in `\n`" convention rather than preserving idiosyncratic
//!   trailing-whitespace state across an install/uninstall cycle.
//!
//! The markers are deliberately namespaced (`klasp:managed:start` /
//! `klasp:managed:end`) so they don't collide with other tools that emit
//! `<!-- generated by X -->` patterns.

use thiserror::Error;

/// Opening marker for klasp's managed block in `AGENTS.md`. Stable across
/// schema bumps; `install` greps for this exact substring to decide whether
/// the file already has a klasp block.
pub const MANAGED_START: &str = "<!-- klasp:managed:start -->";

/// Closing marker for klasp's managed block in `AGENTS.md`.
pub const MANAGED_END: &str = "<!-- klasp:managed:end -->";

/// Default placeholder body emitted between the markers on a fresh install.
/// W2 (#28) will replace this with the actual gate-invocation guidance once
/// the git hooks ship; until then the block exists only to reserve the
/// region and let other tools know not to write inside it.
pub const DEFAULT_BLOCK_BODY: &str = "## klasp gate\n\
    \n\
    This region is managed by `klasp install`. Do not edit by hand —\n\
    re-run `klasp install` instead. It will be populated with the\n\
    gate-invocation guidance once the v0.2 git hooks ship.\n";

#[derive(Debug, Error)]
pub enum AgentsMdError {
    /// The file contains an unmatched marker pair (start without end, end
    /// without start, or start-then-start). We refuse to coerce because the
    /// safe action — overwriting from the first marker to EOF — could nuke
    /// hand-written content the user intended to keep.
    #[error(
        "AGENTS.md: managed-block markers are malformed \
         (expected exactly one `{MANAGED_START}` followed by one `{MANAGED_END}`). \
         Fix the file by hand or remove both markers and re-run install."
    )]
    MalformedMarkers,
}

/// Render the full managed block (markers + body) for embedding in `AGENTS.md`.
///
/// Pure: no filesystem, no env. The output starts with [`MANAGED_START`]
/// on its own line, ends with [`MANAGED_END`] on its own line, and the
/// body is sandwiched with single newline separators.
pub fn render_managed_block(body: &str) -> String {
    // Body is always normalised to end in a single `\n` so the closing
    // marker sits on its own line without depending on caller hygiene.
    let trimmed = body.trim_end_matches('\n');
    format!("{MANAGED_START}\n{trimmed}\n{MANAGED_END}\n")
}

/// Insert (or update) klasp's managed block in `existing`, returning the
/// new file body.
///
/// Behaviour matrix:
///
/// | Input shape                     | Output shape                                      |
/// |---------------------------------|---------------------------------------------------|
/// | empty / all-whitespace          | the rendered block, no leading/trailing padding   |
/// | contains a managed block        | block contents replaced in-place                  |
/// | non-empty, no managed block     | original bytes + blank line + appended block      |
///
/// Idempotent: when the existing block already matches the rendered block
/// byte-for-byte, the input is returned unchanged.
pub fn install_block(existing: &str, body: &str) -> Result<String, AgentsMdError> {
    let block = render_managed_block(body);

    match find_block(existing)? {
        Some(span) => {
            // Replace in-place. Preserve everything outside [start, end).
            let mut out = String::with_capacity(existing.len() + block.len());
            out.push_str(&existing[..span.start]);
            out.push_str(&block);
            out.push_str(&existing[span.end..]);
            Ok(out)
        }
        None if existing.trim().is_empty() => {
            // Fresh-create / empty file: the block stands alone, no padding.
            Ok(block)
        }
        None => {
            // Append after existing content with a blank-line separator.
            // We normalise whatever trailing-newline state the file had,
            // then add exactly one blank line before the block. The
            // [`uninstall_block`] inverse undoes this padding.
            let mut out = String::with_capacity(existing.len() + block.len() + 2);
            out.push_str(existing.trim_end_matches('\n'));
            out.push_str("\n\n");
            out.push_str(&block);
            Ok(out)
        }
    }
}

/// Inverse of [`install_block`]: remove klasp's managed block and the
/// blank-line separator install inserted when it appended the block.
///
/// Idempotent: a file with no managed block is returned unchanged. A file
/// that contained *only* the block (no other content) collapses to the
/// empty string. When install *appended* the block, uninstall normalises
/// the result to end with a single trailing newline — matching the
/// canonical markdown shape and round-tripping the common case where the
/// pre-install file already ended in `\n`.
pub fn uninstall_block(existing: &str) -> Result<String, AgentsMdError> {
    let Some(span) = find_block(existing)? else {
        return Ok(existing.to_string());
    };

    let before = &existing[..span.start];
    let after = &existing[span.end..];

    // If install appended the block to a non-empty file, it inserted a
    // `\n\n` separator (last-line terminator + one blank line). Reverse
    // that by stripping all trailing newlines from `before` and adding
    // back exactly one. This restores the canonical `<content>\n` shape
    // and round-trips the common case where the pre-install file ended
    // in a single `\n` byte-for-byte.
    //
    // When the block was at the head of the file (fresh-create path),
    // `before` is empty and there's nothing to strip; `after` is also
    // empty in that case so we emit `""`, which is the inverse of
    // installing into an empty file.
    let mut out = String::with_capacity(before.len() + after.len() + 1);
    if before.is_empty() {
        out.push_str(after);
    } else if after.is_empty() {
        out.push_str(before.trim_end_matches('\n'));
        out.push('\n');
    } else {
        out.push_str(before);
        out.push_str(after);
    }
    Ok(out)
}

/// `true` when `existing` already contains a (well-formed) klasp managed
/// block — used by callers to decide whether install is a no-op.
pub fn contains_block(existing: &str) -> Result<bool, AgentsMdError> {
    Ok(find_block(existing)?.is_some())
}

/// Byte span of the managed block within `existing`, including both
/// markers and the trailing `\n` after the end marker. `None` when no
/// block is present; `Err` when markers are malformed.
struct Span {
    start: usize,
    end: usize,
}

fn find_block(existing: &str) -> Result<Option<Span>, AgentsMdError> {
    let (Some(start), Some(end_marker_start)) =
        (existing.find(MANAGED_START), existing.find(MANAGED_END))
    else {
        // Either marker present without the other → malformed; both absent → no block.
        return if existing.contains(MANAGED_START) || existing.contains(MANAGED_END) {
            Err(AgentsMdError::MalformedMarkers)
        } else {
            Ok(None)
        };
    };

    // Reject duplicates and crossed pairs in one pass: a well-formed block
    // has `find == rfind` for both markers, with the start before the end.
    if existing.rfind(MANAGED_START) != Some(start)
        || existing.rfind(MANAGED_END) != Some(end_marker_start)
        || end_marker_start < start
    {
        return Err(AgentsMdError::MalformedMarkers);
    }

    // Span end = end of MANAGED_END line, including the trailing newline if
    // there is one. This makes the replace operation a clean cut.
    let after_marker = end_marker_start + MANAGED_END.len();
    let end = if existing.as_bytes().get(after_marker) == Some(&b'\n') {
        after_marker + 1
    } else {
        after_marker
    };
    Ok(Some(Span { start, end }))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn body() -> &'static str {
        DEFAULT_BLOCK_BODY
    }

    #[test]
    fn render_block_wraps_body_in_markers() {
        let s = render_managed_block("hello");
        assert!(s.starts_with(MANAGED_START));
        assert!(s.contains("hello"));
        assert!(s.trim_end().ends_with(MANAGED_END));
        assert!(s.ends_with('\n'));
    }

    #[test]
    fn render_block_normalises_trailing_newlines_in_body() {
        // Caller-supplied body with extra trailing newlines must not
        // produce a blank line before the closing marker.
        let s = render_managed_block("hello\n\n\n");
        assert_eq!(s, format!("{MANAGED_START}\nhello\n{MANAGED_END}\n"));
    }

    #[test]
    fn install_into_empty_emits_just_the_block() {
        let out = install_block("", body()).unwrap();
        assert!(out.starts_with(MANAGED_START));
        assert!(out.trim_end().ends_with(MANAGED_END));
        // No leading whitespace or padding.
        assert_eq!(out.chars().next(), Some('<'));
    }

    #[test]
    fn install_into_whitespace_only_emits_just_the_block() {
        let out = install_block("\n\n   \n", body()).unwrap();
        assert!(out.starts_with(MANAGED_START));
    }

    #[test]
    fn install_appends_with_blank_line_separator_when_no_block_exists() {
        let pre = "# Project\n\nSome notes from the user.\n";
        let out = install_block(pre, body()).unwrap();
        // Pre-existing content survives byte-for-byte at the head.
        assert!(out.starts_with(pre));
        // Exactly one blank line between the user content and the block.
        let after_pre = &out[pre.len()..];
        assert!(
            after_pre.starts_with("\n"),
            "expected separator newline, got: {after_pre:?}"
        );
        assert!(after_pre[1..].starts_with(MANAGED_START));
    }

    #[test]
    fn install_replaces_existing_block_in_place() {
        let stale_block = render_managed_block("OLD CONTENT");
        let pre = format!("# Top\n\n{stale_block}\nbottom prose\n");
        let out = install_block(&pre, "NEW CONTENT").unwrap();
        assert!(out.contains("NEW CONTENT"));
        assert!(!out.contains("OLD CONTENT"));
        // Surrounding bytes preserved.
        assert!(out.starts_with("# Top\n\n"));
        assert!(out.ends_with("bottom prose\n"));
    }

    #[test]
    fn install_is_idempotent() {
        let pre = "# Project\n\nNotes.\n";
        let once = install_block(pre, body()).unwrap();
        let twice = install_block(&once, body()).unwrap();
        assert_eq!(once, twice, "install is not idempotent");
    }

    #[test]
    fn install_uninstall_round_trip_restores_original() {
        let pre = "# Project\n\nSome notes from the user.\n";
        let installed = install_block(pre, body()).unwrap();
        let restored = uninstall_block(&installed).unwrap();
        assert_eq!(restored, pre, "round-trip changed the file");
    }

    #[test]
    fn install_uninstall_round_trip_on_empty_returns_empty() {
        let installed = install_block("", body()).unwrap();
        let restored = uninstall_block(&installed).unwrap();
        assert_eq!(restored, "");
    }

    #[test]
    fn uninstall_is_noop_when_no_block_present() {
        let pre = "# Project\n\nNo klasp here.\n";
        let out = uninstall_block(pre).unwrap();
        assert_eq!(out, pre);
    }

    #[test]
    fn uninstall_preserves_content_above_and_below_block() {
        let stale_block = render_managed_block("between");
        let pre = format!("ABOVE\n\n{stale_block}\nBELOW\n");
        let out = uninstall_block(&pre).unwrap();
        // ABOVE must survive; BELOW must survive; nothing from the block.
        assert!(out.contains("ABOVE"));
        assert!(out.contains("BELOW"));
        assert!(!out.contains(MANAGED_START));
        assert!(!out.contains(MANAGED_END));
        assert!(!out.contains("between"));
    }

    #[test]
    fn uninstall_is_idempotent() {
        let pre = "# Project\n\nNotes.\n";
        let installed = install_block(pre, body()).unwrap();
        let once = uninstall_block(&installed).unwrap();
        let twice = uninstall_block(&once).unwrap();
        assert_eq!(once, twice);
    }

    #[test]
    fn malformed_markers_are_rejected() {
        let pre = format!("{MANAGED_START}\nbody — no closing marker\n");
        let err = install_block(&pre, body()).expect_err("must fail");
        assert!(matches!(err, AgentsMdError::MalformedMarkers));
    }

    #[test]
    fn end_before_start_is_rejected() {
        let pre = format!("{MANAGED_END}\nbody\n{MANAGED_START}\n");
        let err = install_block(&pre, body()).expect_err("must fail");
        assert!(matches!(err, AgentsMdError::MalformedMarkers));
    }

    #[test]
    fn duplicate_start_marker_is_rejected() {
        let pre =
            format!("{MANAGED_START}\none\n{MANAGED_END}\n{MANAGED_START}\ntwo\n{MANAGED_END}\n");
        let err = install_block(&pre, body()).expect_err("must fail");
        assert!(matches!(err, AgentsMdError::MalformedMarkers));
    }

    #[test]
    fn contains_block_returns_true_after_install() {
        let installed = install_block("", body()).unwrap();
        assert!(contains_block(&installed).unwrap());
    }

    #[test]
    fn contains_block_returns_false_for_unrelated_html_comments() {
        // A foreign tool's marker comment must not look like ours.
        let pre = "<!-- generated by some-other-tool -->\n# Notes\n";
        assert!(!contains_block(pre).unwrap());
    }
}