spec_driven_docs/services/
policy.rs1use std::collections::BTreeSet;
18
19use camino::Utf8Path;
20
21use camino::Utf8PathBuf;
22
23use crate::adapters::fs::write_atomic;
24use crate::domain::debt::Debt;
25use crate::domain::ownership::Sha256;
26use crate::domain::policy::{SENTINELS, Sentinel};
27use crate::domain::profile::{DocsRoot, resolve_destination};
28use crate::domain::rule_id::RuleId;
29use crate::error::AppError;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Reconciliation {
34 pub sentinel: &'static Sentinel,
36}
37
38impl Reconciliation {
39 #[must_use]
41 pub fn note(&self, docs_root: DocsRoot) -> String {
42 format!(
43 "note: {} and no local specification defines `{}`; {} owns it; run 'sdd policy reconcile'",
44 self.sentinel.declares,
45 self.sentinel.rule,
46 crate::domain::profile::resolve_destination(self.sentinel.destination, docs_root)
47 )
48 }
49}
50
51pub fn local_rule_ids(
57 target: &Utf8Path,
58 docs_root: DocsRoot,
59) -> Result<BTreeSet<String>, AppError> {
60 let specs = target.join(docs_root.as_str()).join("specs");
61 let mut ids = BTreeSet::new();
62 let Ok(entries) = specs.read_dir_utf8() else {
63 return Ok(ids);
64 };
65 for entry in entries.filter_map(Result::ok) {
66 let path = entry.path();
67 #[allow(
68 clippy::case_sensitive_file_extension_comparisons,
69 reason = "the corpus convention is lowercase"
70 )]
71 if !path.as_str().ends_with(".md") {
72 continue;
73 }
74 let text = std::fs::read_to_string(path)?;
75 ids.extend(crate::embedded::rule_ids_in(&text));
76 }
77 Ok(ids)
78}
79
80fn active(target: &Utf8Path, sentinel: &Sentinel) -> bool {
86 match sentinel.rule {
87 RuleId::RecordedDimensionOnlyShrinks => {
88 Debt::read(target).is_ok_and(|debt| !debt.is_empty())
89 }
90 RuleId::ProjectSelectsOneSource => {
91 crate::domain::instance_config::InstanceConfig::read(target).is_ok_and(|declaration| {
92 declaration.writing_style.source
93 != crate::domain::instance_config::WritingSource::Builtin
94 })
95 }
96 _ => false,
97 }
98}
99
100pub fn needed(target: &Utf8Path, docs_root: DocsRoot) -> Result<Vec<Reconciliation>, AppError> {
106 let defined = local_rule_ids(target, docs_root)?;
107 Ok(SENTINELS
108 .iter()
109 .filter(|sentinel| active(target, sentinel))
110 .filter(|sentinel| !defined.contains(sentinel.rule.as_str()))
111 .map(|sentinel| Reconciliation { sentinel })
112 .collect())
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Action {
118 Seed {
120 destination: Utf8PathBuf,
122 bytes: Vec<u8>,
124 },
125 Append {
128 destination: Utf8PathBuf,
130 block: String,
132 rewritten: String,
134 },
135 Checklist {
138 destination: Utf8PathBuf,
140 block: String,
142 },
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Plan {
148 pub reconciliation: Reconciliation,
150 pub action: Action,
152}
153
154#[must_use]
157pub fn rule_block(seed: &str, rule: RuleId) -> Option<String> {
158 let heading = format!("### `{rule}`");
159 let mut lines = seed.lines().skip_while(|line| !line.starts_with(&heading));
160 let first = lines.next()?;
161 let mut block = format!("{first}\n");
162 for line in lines {
163 if line.starts_with("### ") || line.starts_with("## ") {
164 break;
165 }
166 block.push_str(line);
167 block.push('\n');
168 }
169 Some(format!("{}\n", block.trim_end_matches('\n')))
170}
171
172#[must_use]
175pub fn append_to_requirements(text: &str, block: &str) -> Option<String> {
176 let lines: Vec<&str> = text.lines().collect();
177 let start = lines.iter().position(|line| *line == "## Requirements")?;
178 let end = lines[start + 1..]
179 .iter()
180 .position(|line| line.starts_with("## "))
181 .map_or(lines.len(), |offset| start + 1 + offset);
182 let mut out = String::new();
183 for line in &lines[..end] {
184 out.push_str(line);
185 out.push('\n');
186 }
187 let trimmed = out.trim_end_matches('\n').to_string();
188 out = format!("{trimmed}\n\n{block}");
189 if end < lines.len() {
190 out.push('\n');
191 for line in &lines[end..] {
192 out.push_str(line);
193 out.push('\n');
194 }
195 }
196 Some(out)
197}
198
199pub fn plan(target: &Utf8Path, docs_root: DocsRoot) -> Result<Vec<Plan>, AppError> {
205 let mut plans = Vec::new();
206 for reconciliation in needed(target, docs_root)? {
207 let sentinel = reconciliation.sentinel;
208 let seed = crate::embedded::asset(sentinel.source)
209 .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", sentinel.source))?;
210 let seed_text = std::str::from_utf8(seed).map_err(anyhow::Error::from)?;
211 let block = rule_block(seed_text, sentinel.rule).ok_or_else(|| {
212 anyhow::anyhow!("{} does not define {}", sentinel.source, sentinel.rule)
213 })?;
214 let destination = resolve_destination(sentinel.destination, docs_root);
215 let full = target.join(&destination);
216 let action = if full.is_file() {
217 let text = std::fs::read_to_string(&full)?;
218 match append_to_requirements(&text, &block) {
219 Some(rewritten)
220 if crate::embedded::rule_ids_in(&rewritten)
221 .any(|id| id == sentinel.rule.as_str()) =>
222 {
223 Action::Append {
224 destination,
225 block,
226 rewritten,
227 }
228 }
229 _ => Action::Checklist { destination, block },
230 }
231 } else {
232 Action::Seed {
233 destination,
234 bytes: seed.to_vec(),
235 }
236 };
237 plans.push(Plan {
238 reconciliation,
239 action,
240 });
241 }
242 Ok(plans)
243}
244
245fn with_adopted_record(
251 document: &mut serde_json::Value,
252 source: &str,
253 destination: &Utf8Path,
254 bytes: &[u8],
255 baseline: &[u8],
256) -> Result<(), AppError> {
257 let digest = Sha256::of(bytes).to_string();
258 let Some(entries) = document
259 .get_mut("adopted_files")
260 .and_then(serde_json::Value::as_array_mut)
261 else {
262 return Err(AppError::ManifestInvalid(
263 "adopted_files is not an array".to_string(),
264 ));
265 };
266 let recorded = entries.iter_mut().find(|entry| {
267 entry.get("destination").and_then(serde_json::Value::as_str) == Some(destination.as_str())
268 });
269 match recorded {
270 Some(entry) => entry["sha256"] = serde_json::Value::String(digest),
271 None => entries.push(serde_json::json!({
272 "source": source,
273 "destination": destination.as_str(),
274 "sha256": digest,
275 "baseline_sha256": Sha256::of(baseline).to_string(),
276 })),
277 }
278 Ok(())
279}
280
281type Write = (Utf8PathBuf, Vec<u8>, &'static Sentinel);
282
283fn preflight(target: &Utf8Path, plans: &[Plan]) -> Result<Vec<Write>, AppError> {
286 let mut writes: Vec<Write> = Vec::new();
287 for plan in plans {
288 let sentinel = plan.reconciliation.sentinel;
289 match &plan.action {
290 Action::Seed { destination, bytes } => {
291 writes.push((destination.clone(), bytes.clone(), sentinel));
292 }
293 Action::Append {
294 destination,
295 rewritten,
296 ..
297 } => writes.push((
298 destination.clone(),
299 rewritten.clone().into_bytes(),
300 sentinel,
301 )),
302 Action::Checklist { destination, .. } => {
303 return Err(AppError::Refused(format!(
304 "{destination} is not in a shape this command rewrites; add the rule by hand"
305 )));
306 }
307 }
308 }
309 for (destination, _, _) in &writes {
310 crate::adapters::fs::check_destination(target, destination)
311 .map_err(|refusal| AppError::Refused(format!("{destination}: {refusal}")))?;
312 }
313 let manifest_relative = Utf8Path::new(crate::domain::manifest::MANIFEST_PATH);
314 crate::adapters::fs::check_destination(target, manifest_relative)
315 .map_err(|refusal| AppError::Refused(format!("{manifest_relative}: {refusal}")))?;
316 Ok(writes)
317}
318
319fn restore(target: &Utf8Path, backups: &[(Utf8PathBuf, Option<Vec<u8>>)]) -> Vec<Utf8PathBuf> {
325 let mut unrestored = Vec::new();
326 for (destination, previous) in backups {
327 let full = target.join(destination);
328 let current = match std::fs::read(&full) {
332 Ok(bytes) => Some(Some(bytes)),
333 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(None),
334 Err(_) => None,
335 };
336 if current.as_ref() == Some(previous) {
337 continue;
338 }
339 let put_back = previous.as_ref().map_or_else(
342 || match std::fs::remove_file(&full) {
343 Ok(()) => true,
344 Err(error) => error.kind() == std::io::ErrorKind::NotFound,
345 },
346 |bytes| write_atomic(&full, bytes).is_ok(),
347 );
348 if !put_back {
349 unrestored.push(destination.clone());
350 }
351 }
352 unrestored
353}
354
355pub fn apply_all(target: &Utf8Path, plans: &[Plan]) -> Result<Vec<Utf8PathBuf>, AppError> {
372 let manifest_relative = Utf8Path::new(crate::domain::manifest::MANIFEST_PATH);
373 let writes = preflight(target, plans)?;
374 let manifest_text = std::fs::read_to_string(target.join(manifest_relative))?;
375 let mut document: serde_json::Value = serde_json::from_str(&manifest_text)
376 .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
377
378 let mut backups: Vec<(Utf8PathBuf, Option<Vec<u8>>)> = Vec::new();
379 let mut attempt = |backups: &mut Vec<(Utf8PathBuf, Option<Vec<u8>>)>| -> Result<(), AppError> {
380 for (destination, bytes, sentinel) in &writes {
381 let full = target.join(destination);
382 let previous = if full.is_file() {
383 Some(std::fs::read(&full)?)
384 } else {
385 None
386 };
387 backups.push((destination.clone(), previous));
388 write_atomic(&full, bytes)?;
389 let written = std::fs::read_to_string(&full)?;
390 if !crate::embedded::rule_ids_in(&written).any(|id| id == sentinel.rule.as_str()) {
391 return Err(AppError::Refused(format!(
392 "{destination} did not define `{}` after the rewrite",
393 sentinel.rule
394 )));
395 }
396 let seed = crate::embedded::asset(sentinel.source)
397 .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", sentinel.source))?;
398 with_adopted_record(&mut document, sentinel.source, destination, bytes, seed)?;
399 }
400 backups.push((
401 manifest_relative.to_path_buf(),
402 Some(manifest_text.clone().into_bytes()),
403 ));
404 let rendered = serde_json::to_string_pretty(&document)
405 .map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
406 write_atomic(
407 &target.join(manifest_relative),
408 format!("{rendered}\n").as_bytes(),
409 )?;
410 Ok(())
411 };
412 if let Err(error) = attempt(&mut backups) {
413 let unrestored = restore(target, &backups);
414 let cause = match error {
415 AppError::Refused(reason) => reason,
416 other => format!("reconciliation aborted: {other}"),
417 };
418 if unrestored.is_empty() {
419 return Err(AppError::Refused(format!(
420 "{cause}; every file is restored"
421 )));
422 }
423 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
424 return Err(AppError::Refused(format!(
425 "{cause}; restoration is incomplete, verify by hand: {}",
426 paths.join(" ")
427 )));
428 }
429 Ok(writes
430 .into_iter()
431 .map(|(destination, _, _)| destination)
432 .collect())
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 const SPEC: &str = "# Sample\n\n## Purpose\n\nOurs.\n\n## Requirements\n\n### `sample:first` — First\n\nThe author MUST keep it.\n\n#### Scenario: One\n\n- GIVEN x\n- WHEN y\n- THEN z\n\nVerify: `true`\n\n## Unenforced\n\n| Rule | Reviewer confirms |\n| --- | --- |\n";
440
441 const BLOCK: &str = "### `sample:second` — Second\n\nThe author MUST add it.\n\n#### Scenario: Two\n\n- GIVEN a\n- WHEN b\n- THEN c\n\nVerify: `true`\n";
442
443 #[test]
444 fn the_rule_block_runs_from_its_heading_to_the_next() {
445 let seed = format!("{SPEC}\n{BLOCK}");
446 let block = rule_block(&seed, RuleId::RecordedDimensionOnlyShrinks);
447 assert!(block.is_none(), "a rule the seed lacks is not found");
448 let debt =
449 std::str::from_utf8(crate::embedded::asset("_docs/specs/SPEC-budget-debt.md").unwrap())
450 .unwrap();
451 let block = rule_block(debt, RuleId::RecordedDimensionOnlyShrinks).unwrap();
452 assert!(block.starts_with("### `budget-debt:a-recorded-dimension-only-shrinks`"));
453 assert!(block.contains("Verify:"));
454 assert!(!block.contains("debt-is-created-by-an-explicit-act"));
455 assert!(block.ends_with('\n') && !block.ends_with("\n\n"));
456 }
457
458 #[test]
459 fn the_block_is_appended_before_the_next_section_and_everything_else_survives() {
460 let out = append_to_requirements(SPEC, BLOCK).unwrap();
461 let ids: Vec<String> = crate::embedded::rule_ids_in(&out).collect();
462 assert_eq!(
463 ids,
464 vec!["sample:first".to_string(), "sample:second".to_string()]
465 );
466 assert!(out.contains("Verify: `true`\n\n### `sample:second`"));
467 assert!(out.contains("Verify: `true`\n\n## Unenforced\n"));
468 assert!(out.ends_with("| --- | --- |\n"));
469 assert!(out.starts_with("# Sample\n\n## Purpose\n\nOurs.\n"));
470 }
471
472 #[test]
473 fn a_file_whose_requirements_close_it_takes_the_block_at_the_end() {
474 let spec = SPEC.split("## Unenforced").next().unwrap();
475 let out = append_to_requirements(spec, BLOCK).unwrap();
476 assert!(out.ends_with(&format!("\n\n{BLOCK}")));
477 }
478
479 #[test]
480 fn a_file_without_a_requirements_section_is_not_rewritten() {
481 assert!(append_to_requirements("# Ours\n\nProse only.\n", BLOCK).is_none());
482 }
483}