spec_driven_docs/commands/
hooks.rs1use 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 const CONFIG: &str = ".pre-commit-config.yaml";
31
32pub const AGENTS: &str = "AGENTS.md";
34
35fn 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
83fn 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#[derive(Debug)]
98enum Agents {
99 Current,
101 Stale(String),
103 Missing(&'static str),
106 Unmanaged,
109}
110
111fn 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
125fn 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
135fn 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 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
177fn 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
213pub 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 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 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 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 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}