Skip to main content

spec_driven_docs/commands/
hooks.rs

1//! `hooks` subcommand: runtime-shape.
2//!
3//! Renders the managed block from the registry and the project's
4//! declaration, and writes it into the target's configuration on request.
5//! What the render contains is the renderer's business; this handler
6//! projects the flags, resolves the declaration, and owns the write.
7//!
8//! `--apply` exists because nothing else reaches the block after an ordinary
9//! declaration edit. `sdd upgrade` returns early at the same version, and a
10//! render to stdout changes no file, so without this a project could edit
11//! its declaration and see nothing happen. The declaration also selects the
12//! writing-style route the documentation block in `AGENTS.md` carries, so
13//! `--apply` and `--check` reach that block too: one verb brings every
14//! managed region back into agreement with the declaration.
15
16use camino::Utf8Path;
17
18use crate::adapters::fs::write_within;
19use crate::cli::hooks::HooksArgs;
20use crate::context::AppContext;
21use crate::domain::instance_config::InstanceConfig;
22use crate::domain::manifest::{MANIFEST_PATH, Manifest};
23use crate::domain::marker;
24use crate::domain::ownership::Sha256;
25use crate::error::AppError;
26use crate::output;
27use crate::services::hooks_render::{RenderOptions, render_block};
28
29/// Where a pre-commit configuration lives in a target.
30pub const CONFIG: &str = ".pre-commit-config.yaml";
31
32/// Where the documentation block lives in a target.
33pub const AGENTS: &str = "AGENTS.md";
34
35/// Update the manifest's record of one managed region.
36///
37/// A target with no manifest is not an instance, and nothing records the
38/// region there. A manifest that exists must be readable and must carry
39/// exactly one record for the region, or the rewrite cannot be brought into
40/// agreement with its record and the caller puts the region back.
41fn record_block_hash(target: &Utf8Path, path: &str, hash: Option<Sha256>) -> Result<(), AppError> {
42    let manifest_relative = Utf8Path::new(MANIFEST_PATH);
43    let text = match std::fs::read_to_string(target.join(manifest_relative)) {
44        Ok(text) => text,
45        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
46        Err(error) => return Err(error.into()),
47    };
48    let hash = hash.ok_or_else(|| {
49        AppError::ManifestInvalid(format!("the rewritten {path} carries no managed block"))
50    })?;
51    let mut document: serde_json::Value = serde_json::from_str(&text)
52        .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
53    let Some(blocks) = document
54        .get_mut("integration_blocks")
55        .and_then(serde_json::Value::as_array_mut)
56    else {
57        return Err(AppError::ManifestInvalid(
58            "integration_blocks is not an array".to_string(),
59        ));
60    };
61    let mut matched = 0usize;
62    for block in blocks.iter_mut() {
63        if block.get("path").and_then(serde_json::Value::as_str) == Some(path) {
64            block["marker_hash"] = serde_json::Value::String(hash.to_string());
65            matched += 1;
66        }
67    }
68    if matched != 1 {
69        return Err(AppError::ManifestInvalid(format!(
70            "the manifest records {matched} integration blocks for {path}; expected one"
71        )));
72    }
73    let rendered = serde_json::to_string_pretty(&document)
74        .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
75    write_within(
76        target,
77        manifest_relative,
78        format!("{rendered}\n").as_bytes(),
79    )?;
80    Ok(())
81}
82
83/// Put one region back after its record could not be written, and say so
84/// where even that fails.
85fn restored(target: &Utf8Path, relative: &str, previous: &[u8], cause: &AppError) -> AppError {
86    match write_within(target, Utf8Path::new(relative), previous) {
87        Ok(()) => AppError::Refused(format!(
88            "{relative} was rewritten and its record could not be updated, so it was put back: {cause}"
89        )),
90        Err(error) => AppError::Refused(format!(
91            "{relative} was rewritten, its record could not be updated ({cause}), and restoring it failed ({error}); verify {relative} by hand"
92        )),
93    }
94}
95
96/// What the root `AGENTS.md` holds against what the declaration renders.
97#[derive(Debug)]
98enum Agents {
99    /// The block is present and agrees with the declaration.
100    Current,
101    /// The block is present and disagrees; the host as it would be written.
102    Stale(String),
103    /// The install recorded a block and the host no longer carries one, or
104    /// the host is gone.
105    Missing(&'static str),
106    /// The install recorded no block here, so none is owed: this
107    /// repository's own root digest is release-kit-owned and carries none.
108    Unmanaged,
109}
110
111/// The instance record, where the target is an instance.
112///
113/// An absent record means the target is not an instance, and the verb
114/// still renders and rewrites. A record that exists and cannot be read is
115/// an error: no ownership check judges anything before it has read the
116/// record, and a read failure is not evidence that nothing is recorded.
117fn instance_record(target: &Utf8Path) -> Result<Option<Manifest>, AppError> {
118    match crate::services::verifier::read_manifest(target) {
119        Ok(manifest) => Ok(Some(manifest)),
120        Err(AppError::ManifestMissing(_)) => Ok(None),
121        Err(error) => Err(error),
122    }
123}
124
125/// Whether the record names a documentation block in `AGENTS.md`.
126fn agents_block_recorded(manifest: Option<&Manifest>) -> bool {
127    manifest.is_some_and(|manifest| {
128        manifest
129            .integration_blocks
130            .iter()
131            .any(|block| block.path.as_str() == AGENTS)
132    })
133}
134
135/// Read the documentation block's state.
136///
137/// A host that cannot be read for a reason other than absence is an error,
138/// never a silent "current".
139fn agents_state(
140    target: &Utf8Path,
141    recorded: bool,
142    docs_root: &str,
143    declaration: &InstanceConfig,
144) -> Result<Agents, AppError> {
145    let path = target.join(AGENTS);
146    if path.is_symlink() {
147        return Err(AppError::Refused(
148            "AGENTS.md is a symlink; refusing to write the documentation block through it"
149                .to_string(),
150        ));
151    }
152    // The record is what says whether a block is owed here. A block the
153    // install never recorded is the project's own text, whatever it looks
154    // like, and this verb has no record to bring it into agreement with.
155    if !recorded {
156        return Ok(Agents::Unmanaged);
157    }
158    let host = match std::fs::read_to_string(&path) {
159        Ok(host) => host,
160        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
161            return Ok(Agents::Missing("the file is absent"));
162        }
163        Err(error) => return Err(error.into()),
164    };
165    if marker::block_region_with(&host, marker::AGENTS_BEGIN, marker::AGENTS_END).is_none() {
166        return Ok(Agents::Missing("its managed block is gone"));
167    }
168    let block = crate::services::agents_render::render_block(docs_root, &declaration.writing_style);
169    let placed = marker::place_agents_block(&host, &block)?;
170    Ok(if placed == host {
171        Agents::Current
172    } else {
173        Agents::Stale(placed)
174    })
175}
176
177/// Report every region that disagrees with the declaration, and fail on
178/// any.
179fn check(
180    path: &Utf8Path,
181    config_current: bool,
182    agents_path: &Utf8Path,
183    agents: &Agents,
184) -> Result<(), AppError> {
185    let mut count = 0;
186    if !config_current {
187        output::line(format!(
188            "FAIL {path} does not match the declaration; run 'sdd hooks --apply'"
189        ));
190        count += 1;
191    }
192    match agents {
193        Agents::Stale(..) => {
194            output::line(format!(
195                "FAIL the documentation block in {agents_path} does not match the declaration; run 'sdd hooks --apply'"
196            ));
197            count += 1;
198        }
199        Agents::Missing(why) => {
200            output::line(format!(
201                "FAIL the install recorded a documentation block in {agents_path} and {why}; run 'sdd init --apply' to restore it"
202            ));
203            count += 1;
204        }
205        Agents::Current | Agents::Unmanaged => {}
206    }
207    if count == 0 {
208        return Ok(());
209    }
210    Err(AppError::Violations { count })
211}
212
213/// Render the delivered gate set, and optionally write it.
214///
215/// # Errors
216///
217/// [`AppError::Usage`] when the declaration does not parse,
218/// [`AppError::Marker`] when the target's managed region is malformed, and
219/// [`AppError::Violations`] when `--check` finds the region stale.
220pub fn run(_ctx: &AppContext, args: HooksArgs) -> Result<(), AppError> {
221    let target = Utf8Path::new(&args.target);
222    let declaration =
223        InstanceConfig::read(target).map_err(|error| AppError::Usage(error.to_string()))?;
224    // The recorded root, where the target is an instance. Rendering against
225    // another root would write a block the installer never would.
226    let manifest = instance_record(target)?;
227    let docs_root = args.docs_root.unwrap_or_else(|| {
228        manifest
229            .as_ref()
230            .map_or_else(|| "_docs".to_string(), |m| m.docs_root.to_string())
231    });
232
233    if !args.apply && !args.check {
234        output::line(
235            render_block(&RenderOptions {
236                docs_root,
237                entry: args.entry,
238                indent: args.indent,
239                declaration,
240            })
241            .trim_end_matches('\n'),
242        );
243        return Ok(());
244    }
245
246    let path = target.join(CONFIG);
247    let host = std::fs::read_to_string(&path)?;
248    // A malformed marker pair is refused rather than repaired: a region this
249    // command cannot read is a region it must not overwrite.
250    //
251    // Strip the existing region before splicing, or the splice appends a
252    // second one. The indentation is measured from the stripped base, which
253    // is where the installer measures it.
254    let (base, _) = marker::split_block(&host)?;
255    let rendered = render_block(&RenderOptions {
256        docs_root: docs_root.clone(),
257        entry: args.entry,
258        indent: marker::splice_indent(&base)?,
259        declaration: declaration.clone(),
260    });
261    let spliced = marker::splice(&base, &rendered)?;
262    let agents = agents_state(
263        target,
264        agents_block_recorded(manifest.as_ref()),
265        &docs_root,
266        &declaration,
267    )?;
268    let agents_path = target.join(AGENTS);
269
270    if args.check {
271        return check(&path, spliced == host, &agents_path, &agents);
272    }
273
274    // A recorded block that is gone is not this verb's to rewrite: the
275    // install owns placing it, and a rewrite here would recreate the block
276    // without knowing what else the operator removed.
277    if let Agents::Missing(why) = &agents {
278        return Err(AppError::Refused(format!(
279            "the install recorded a documentation block in {agents_path} and {why}; run 'sdd init --apply' to restore it"
280        )));
281    }
282    let agents_placed = match agents {
283        Agents::Stale(placed) => Some(placed),
284        Agents::Current | Agents::Unmanaged | Agents::Missing(_) => None,
285    };
286    if spliced == host && agents_placed.is_none() {
287        output::line(format!("OK {path} already matches the declaration"));
288        return Ok(());
289    }
290    // Every write is bounded to the target and atomic, and the manifest
291    // record moves with each region: the block-tamper check reads it, so
292    // a rewrite that left the record behind would report the instance as
293    // tampered the moment it was made correct. A record that cannot be
294    // written puts the region back.
295    if spliced != host {
296        write_within(target, Utf8Path::new(CONFIG), spliced.as_bytes())?;
297        if let Err(error) = record_block_hash(target, CONFIG, marker::block_hash(&spliced)) {
298            return Err(restored(target, CONFIG, host.as_bytes(), &error));
299        }
300        output::line(format!("OK rewrote the managed region in {path}"));
301    }
302    if let Some(placed) = agents_placed {
303        let previous = std::fs::read(&agents_path)?;
304        write_within(target, Utf8Path::new(AGENTS), placed.as_bytes())?;
305        if let Err(error) = record_block_hash(
306            target,
307            AGENTS,
308            marker::block_hash_with(&placed, marker::AGENTS_BEGIN, marker::AGENTS_END),
309        ) {
310            return Err(restored(target, AGENTS, &previous, &error));
311        }
312        output::line(format!(
313            "OK rewrote the documentation block in {agents_path}"
314        ));
315    }
316    Ok(())
317}