Skip to main content

testing_conventions/
agents.rs

1//! `install` (#232): write the testing contract into the repository's agent
2//! context file (`AGENTS.md`) as a marker-delimited, hash-versioned block —
3//! the beads (`bd init`) pattern. Idempotent: re-running refreshes the owned
4//! region and touches nothing outside it.
5
6use std::fs;
7use std::io::ErrorKind;
8use std::path::Path;
9
10use anyhow::{bail, Context};
11use sha2::{Digest, Sha256};
12
13const SCHEMA_VERSION: u32 = 1;
14const BEGIN_OPEN: &str = "<!-- testing-conventions:begin ";
15const END_MARKER: &str = "<!-- testing-conventions:end -->";
16
17/// The managed region's content — the few non-negotiables plus pointers to
18/// the canonical contract and the CLI. Thin on purpose: the consumer's file
19/// is theirs; the full contract lives on the docs site.
20const TEMPLATE: &str = "\
21## Testing conventions
22
23This repository enforces [testing-conventions](https://thekevinscott.github.io/testing-conventions/) in CI. The contract:
24
25- Start every change with the docs update and red integration/e2e tests; CI witnesses them fail before the implementation lands.
26- Colocate a unit test with every source file, and mock every collaborator in unit tests.
27- Clear the coverage floor and kill the mutants on every line you touch.
28- Ship each capability at parity across Python, TypeScript, and Rust.
29- An exemption carries a written reason showing the isolation techniques you tried; near-zero is the bar.
30
31Run the rules locally with the CLI: https://thekevinscott.github.io/testing-conventions/guide/cli
32Machine-readable contract: https://thekevinscott.github.io/testing-conventions/llms.txt
33";
34
35/// The begin marker carries the schema version and the first 12 hex chars of
36/// the SHA-256 of the region content, so staleness is visible at a glance.
37fn begin_marker() -> String {
38    let hex = Sha256::digest(TEMPLATE.as_bytes())
39        .iter()
40        .map(|b| format!("{b:02x}"))
41        .collect::<String>();
42    format!("{BEGIN_OPEN}v{SCHEMA_VERSION} hash={} -->", &hex[..12])
43}
44
45/// Upsert the managed block into the file at `path`: create the file when
46/// absent, append when the markers are missing, otherwise replace only the
47/// region between the markers. A current block is a byte-identical no-op.
48pub fn install(path: &Path) -> anyhow::Result<()> {
49    if path
50        .symlink_metadata()
51        .map(|meta| meta.file_type().is_symlink())
52        .unwrap_or(false)
53    {
54        bail!(
55            "{} is a symlink; refusing to write through it",
56            path.display()
57        );
58    }
59
60    let existing = match fs::read_to_string(path) {
61        Ok(text) => Some(text),
62        Err(err) if err.kind() == ErrorKind::NotFound => None,
63        Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())),
64    };
65
66    let region = format!("{}\n{TEMPLATE}{END_MARKER}", begin_marker());
67    let new = match &existing {
68        None => format!("{region}\n"),
69        Some(text) => {
70            let bounds = text.find(BEGIN_OPEN).and_then(|start| {
71                let end = text[start..].find(END_MARKER)?;
72                Some((start, start + end + END_MARKER.len()))
73            });
74            match bounds {
75                Some((start, end)) => format!("{}{region}{}", &text[..start], &text[end..]),
76                None => {
77                    let mut out = text.clone();
78                    if !out.is_empty() && !out.ends_with('\n') {
79                        out.push('\n');
80                    }
81                    if !out.is_empty() {
82                        out.push('\n');
83                    }
84                    format!("{out}{region}\n")
85                }
86            }
87        }
88    };
89
90    if existing.as_deref() == Some(new.as_str()) {
91        return Ok(());
92    }
93
94    // Atomic write: temp file in the target's directory, then rename, so a
95    // crash mid-write leaves the original intact.
96    let name = path
97        .file_name()
98        .with_context(|| format!("{} has no file name", path.display()))?;
99    let tmp = path
100        .parent()
101        .filter(|dir| !dir.as_os_str().is_empty())
102        .unwrap_or_else(|| Path::new("."))
103        .join(format!(
104            ".{}.tc-tmp-{}",
105            name.to_string_lossy(),
106            std::process::id()
107        ));
108    fs::write(&tmp, &new).with_context(|| format!("writing {}", tmp.display()))?;
109    fs::rename(&tmp, path)
110        .with_context(|| format!("renaming {} over {}", tmp.display(), path.display()))
111}