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 use crate::domain::paths::{AGENTS_DIGEST_PATH as AGENTS, HOOKS_CONFIG_PATH as CONFIG};
30
31fn 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
79fn 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#[derive(Debug)]
94enum Agents {
95 Current,
97 Stale(String),
99 Missing(&'static str),
102 Unmanaged,
105}
106
107fn 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
121fn 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
131fn 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 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
173fn 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
209pub 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 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 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 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 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}