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