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.
12
13use camino::Utf8Path;
14
15use crate::cli::hooks::HooksArgs;
16use crate::context::AppContext;
17use crate::domain::instance_config::InstanceConfig;
18use crate::domain::marker;
19use crate::error::AppError;
20use crate::output;
21use crate::services::hooks_render::{RenderOptions, render_block};
22
23/// Where a pre-commit configuration lives in a target.
24pub const CONFIG: &str = ".pre-commit-config.yaml";
25
26/// Update the manifest's record of the managed region.
27fn record_block_hash(target: &Utf8Path, spliced: &str) -> Result<(), AppError> {
28    let manifest_path = target.join(".spec-driven-docs/manifest.json");
29    let Ok(text) = std::fs::read_to_string(&manifest_path) else {
30        // No manifest: the target is not an instance, and nothing records
31        // the region. The rewrite still stands.
32        return Ok(());
33    };
34    let Some(hash) = marker::block_hash(spliced) else {
35        return Ok(());
36    };
37    let mut document: serde_json::Value = serde_json::from_str(&text)
38        .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
39    if let Some(blocks) = document
40        .get_mut("integration_blocks")
41        .and_then(serde_json::Value::as_array_mut)
42    {
43        for block in blocks.iter_mut() {
44            if block.get("path").and_then(serde_json::Value::as_str) == Some(CONFIG) {
45                block["marker_hash"] = serde_json::Value::String(hash.to_string());
46            }
47        }
48    }
49    let rendered = serde_json::to_string_pretty(&document)
50        .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
51    let scratch = manifest_path.with_extension("json.sdd-tmp");
52    std::fs::write(&scratch, format!("{rendered}\n"))?;
53    std::fs::rename(&scratch, &manifest_path)?;
54    Ok(())
55}
56
57/// Render the delivered gate set, and optionally write it.
58///
59/// # Errors
60///
61/// [`AppError::Usage`] when the declaration does not parse,
62/// [`AppError::Marker`] when the target's managed region is malformed, and
63/// [`AppError::Violations`] when `--check` finds the region stale.
64pub fn run(_ctx: &AppContext, args: HooksArgs) -> Result<(), AppError> {
65    let target = Utf8Path::new(&args.target);
66    let declaration =
67        InstanceConfig::read(target).map_err(|error| AppError::Usage(error.to_string()))?;
68    // The recorded root, where the target is an instance. Rendering against
69    // another root would write a block the installer never would.
70    let docs_root = args.docs_root.unwrap_or_else(|| {
71        crate::services::verifier::read_manifest(target)
72            .map_or_else(|_| "_docs".to_string(), |m| m.docs_root.to_string())
73    });
74
75    if !args.apply && !args.check {
76        output::line(
77            render_block(&RenderOptions {
78                docs_root,
79                entry: args.entry,
80                indent: args.indent,
81                declaration,
82            })
83            .trim_end_matches('\n'),
84        );
85        return Ok(());
86    }
87
88    let path = target.join(CONFIG);
89    let host = std::fs::read_to_string(&path)?;
90    // A malformed marker pair is refused rather than repaired: a region this
91    // command cannot read is a region it must not overwrite.
92    //
93    // Strip the existing region before splicing, or the splice appends a
94    // second one. The indentation is measured from the stripped base, which
95    // is where the installer measures it.
96    let (base, _) = marker::split_block(&host)?;
97    let rendered = render_block(&RenderOptions {
98        docs_root,
99        entry: args.entry,
100        indent: marker::splice_indent(&base)?,
101        declaration,
102    });
103    let spliced = marker::splice(&base, &rendered)?;
104
105    if args.check {
106        if spliced == host {
107            return Ok(());
108        }
109        output::line(format!(
110            "FAIL {path} does not match the declaration; run 'sdd hooks --apply'"
111        ));
112        return Err(AppError::Violations { count: 1 });
113    }
114
115    if spliced == host {
116        output::line(format!("OK {path} already matches the declaration"));
117        return Ok(());
118    }
119    // Write through a sibling temporary file and rename, so a failure leaves
120    // the configuration byte-identical rather than half-written.
121    let scratch = path.with_extension("yaml.sdd-tmp");
122    std::fs::write(&scratch, &spliced)?;
123    std::fs::rename(&scratch, &path)?;
124    // The manifest records this region's hash, and the block-tamper check
125    // reads it. A rewrite that left the record behind would report the
126    // instance as tampered the moment it was made correct.
127    record_block_hash(target, &spliced)?;
128    output::line(format!("OK rewrote the managed region in {path}"));
129    Ok(())
130}