Skip to main content

testing_conventions/
agents.rs

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