use std::collections::BTreeSet;
use camino::Utf8Path;
use camino::Utf8PathBuf;
use crate::adapters::fs::write_atomic;
use crate::domain::debt::Debt;
use crate::domain::ownership::Sha256;
use crate::domain::policy::{SENTINELS, Sentinel};
use crate::domain::profile::{DocsRoot, resolve_destination};
use crate::domain::rule_id::RuleId;
use crate::error::AppError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reconciliation {
pub sentinel: &'static Sentinel,
}
impl Reconciliation {
#[must_use]
pub fn note(&self, docs_root: DocsRoot) -> String {
format!(
"note: {} and no local specification defines `{}`; {} owns it; run 'sdd policy reconcile'",
self.sentinel.declares,
self.sentinel.rule,
crate::domain::profile::resolve_destination(self.sentinel.destination, docs_root)
)
}
}
pub fn local_rule_ids(
target: &Utf8Path,
docs_root: DocsRoot,
) -> Result<BTreeSet<String>, AppError> {
let specs = target.join(docs_root.as_str()).join("specs");
let mut ids = BTreeSet::new();
let Ok(entries) = specs.read_dir_utf8() else {
return Ok(ids);
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
#[allow(
clippy::case_sensitive_file_extension_comparisons,
reason = "the corpus convention is lowercase"
)]
if !path.as_str().ends_with(".md") {
continue;
}
let text = std::fs::read_to_string(path)?;
ids.extend(crate::embedded::rule_ids_in(&text));
}
Ok(ids)
}
fn active(target: &Utf8Path, sentinel: &Sentinel) -> bool {
match sentinel.rule {
RuleId::RecordedDimensionOnlyShrinks => {
Debt::read(target).is_ok_and(|debt| !debt.is_empty())
}
RuleId::ProjectSelectsOneSource => {
crate::domain::instance_config::InstanceConfig::read(target).is_ok_and(|declaration| {
declaration.writing_style.source
!= crate::domain::instance_config::WritingSource::Builtin
})
}
_ => false,
}
}
pub fn needed(target: &Utf8Path, docs_root: DocsRoot) -> Result<Vec<Reconciliation>, AppError> {
let defined = local_rule_ids(target, docs_root)?;
Ok(SENTINELS
.iter()
.filter(|sentinel| active(target, sentinel))
.filter(|sentinel| !defined.contains(sentinel.rule.as_str()))
.map(|sentinel| Reconciliation { sentinel })
.collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Seed {
destination: Utf8PathBuf,
bytes: Vec<u8>,
},
Append {
destination: Utf8PathBuf,
block: String,
rewritten: String,
},
Checklist {
destination: Utf8PathBuf,
block: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
pub reconciliation: Reconciliation,
pub action: Action,
}
#[must_use]
pub fn rule_block(seed: &str, rule: RuleId) -> Option<String> {
let heading = format!("### `{rule}`");
let mut lines = seed.lines().skip_while(|line| !line.starts_with(&heading));
let first = lines.next()?;
let mut block = format!("{first}\n");
for line in lines {
if line.starts_with("### ") || line.starts_with("## ") {
break;
}
block.push_str(line);
block.push('\n');
}
Some(format!("{}\n", block.trim_end_matches('\n')))
}
#[must_use]
pub fn append_to_requirements(text: &str, block: &str) -> Option<String> {
let lines: Vec<&str> = text.lines().collect();
let start = lines.iter().position(|line| *line == "## Requirements")?;
let end = lines[start + 1..]
.iter()
.position(|line| line.starts_with("## "))
.map_or(lines.len(), |offset| start + 1 + offset);
let mut out = String::new();
for line in &lines[..end] {
out.push_str(line);
out.push('\n');
}
let trimmed = out.trim_end_matches('\n').to_string();
out = format!("{trimmed}\n\n{block}");
if end < lines.len() {
out.push('\n');
for line in &lines[end..] {
out.push_str(line);
out.push('\n');
}
}
Some(out)
}
pub fn plan(target: &Utf8Path, docs_root: DocsRoot) -> Result<Vec<Plan>, AppError> {
let mut plans = Vec::new();
for reconciliation in needed(target, docs_root)? {
let sentinel = reconciliation.sentinel;
let seed = crate::embedded::asset(sentinel.source)
.ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", sentinel.source))?;
let seed_text = std::str::from_utf8(seed).map_err(anyhow::Error::from)?;
let block = rule_block(seed_text, sentinel.rule).ok_or_else(|| {
anyhow::anyhow!("{} does not define {}", sentinel.source, sentinel.rule)
})?;
let destination = resolve_destination(sentinel.destination, docs_root);
let full = target.join(&destination);
let action = if full.is_file() {
let text = std::fs::read_to_string(&full)?;
match append_to_requirements(&text, &block) {
Some(rewritten)
if crate::embedded::rule_ids_in(&rewritten)
.any(|id| id == sentinel.rule.as_str()) =>
{
Action::Append {
destination,
block,
rewritten,
}
}
_ => Action::Checklist { destination, block },
}
} else {
Action::Seed {
destination,
bytes: seed.to_vec(),
}
};
plans.push(Plan {
reconciliation,
action,
});
}
Ok(plans)
}
fn with_adopted_record(
document: &mut serde_json::Value,
source: &str,
destination: &Utf8Path,
bytes: &[u8],
baseline: &[u8],
) -> Result<(), AppError> {
let digest = Sha256::of(bytes).to_string();
let Some(entries) = document
.get_mut("adopted_files")
.and_then(serde_json::Value::as_array_mut)
else {
return Err(AppError::ManifestInvalid(
"adopted_files is not an array".to_string(),
));
};
let recorded = entries.iter_mut().find(|entry| {
entry.get("destination").and_then(serde_json::Value::as_str) == Some(destination.as_str())
});
match recorded {
Some(entry) => entry["sha256"] = serde_json::Value::String(digest),
None => entries.push(serde_json::json!({
"source": source,
"destination": destination.as_str(),
"sha256": digest,
"baseline_sha256": Sha256::of(baseline).to_string(),
})),
}
Ok(())
}
type Write = (Utf8PathBuf, Vec<u8>, &'static Sentinel);
fn preflight(target: &Utf8Path, plans: &[Plan]) -> Result<Vec<Write>, AppError> {
let mut writes: Vec<Write> = Vec::new();
for plan in plans {
let sentinel = plan.reconciliation.sentinel;
match &plan.action {
Action::Seed { destination, bytes } => {
writes.push((destination.clone(), bytes.clone(), sentinel));
}
Action::Append {
destination,
rewritten,
..
} => writes.push((
destination.clone(),
rewritten.clone().into_bytes(),
sentinel,
)),
Action::Checklist { destination, .. } => {
return Err(AppError::Refused(format!(
"{destination} is not in a shape this command rewrites; add the rule by hand"
)));
}
}
}
for (destination, _, _) in &writes {
crate::adapters::fs::check_destination(target, destination)
.map_err(|refusal| AppError::Refused(format!("{destination}: {refusal}")))?;
}
let manifest_relative = Utf8Path::new(crate::domain::manifest::MANIFEST_PATH);
crate::adapters::fs::check_destination(target, manifest_relative)
.map_err(|refusal| AppError::Refused(format!("{manifest_relative}: {refusal}")))?;
Ok(writes)
}
fn restore(target: &Utf8Path, backups: &[(Utf8PathBuf, Option<Vec<u8>>)]) -> Vec<Utf8PathBuf> {
let mut unrestored = Vec::new();
for (destination, previous) in backups {
let full = target.join(destination);
let current = match std::fs::read(&full) {
Ok(bytes) => Some(Some(bytes)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(None),
Err(_) => None,
};
if current.as_ref() == Some(previous) {
continue;
}
let put_back = previous.as_ref().map_or_else(
|| match std::fs::remove_file(&full) {
Ok(()) => true,
Err(error) => error.kind() == std::io::ErrorKind::NotFound,
},
|bytes| write_atomic(&full, bytes).is_ok(),
);
if !put_back {
unrestored.push(destination.clone());
}
}
unrestored
}
pub fn apply_all(target: &Utf8Path, plans: &[Plan]) -> Result<Vec<Utf8PathBuf>, AppError> {
let manifest_relative = Utf8Path::new(crate::domain::manifest::MANIFEST_PATH);
let writes = preflight(target, plans)?;
let manifest_text = std::fs::read_to_string(target.join(manifest_relative))?;
let mut document: serde_json::Value = serde_json::from_str(&manifest_text)
.map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
let mut backups: Vec<(Utf8PathBuf, Option<Vec<u8>>)> = Vec::new();
let mut attempt = |backups: &mut Vec<(Utf8PathBuf, Option<Vec<u8>>)>| -> Result<(), AppError> {
for (destination, bytes, sentinel) in &writes {
let full = target.join(destination);
let previous = if full.is_file() {
Some(std::fs::read(&full)?)
} else {
None
};
backups.push((destination.clone(), previous));
write_atomic(&full, bytes)?;
let written = std::fs::read_to_string(&full)?;
if !crate::embedded::rule_ids_in(&written).any(|id| id == sentinel.rule.as_str()) {
return Err(AppError::Refused(format!(
"{destination} did not define `{}` after the rewrite",
sentinel.rule
)));
}
let seed = crate::embedded::asset(sentinel.source)
.ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", sentinel.source))?;
with_adopted_record(&mut document, sentinel.source, destination, bytes, seed)?;
}
backups.push((
manifest_relative.to_path_buf(),
Some(manifest_text.clone().into_bytes()),
));
let rendered = serde_json::to_string_pretty(&document)
.map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
write_atomic(
&target.join(manifest_relative),
format!("{rendered}\n").as_bytes(),
)?;
Ok(())
};
if let Err(error) = attempt(&mut backups) {
let unrestored = restore(target, &backups);
let cause = match error {
AppError::Refused(reason) => reason,
other => format!("reconciliation aborted: {other}"),
};
if unrestored.is_empty() {
return Err(AppError::Refused(format!(
"{cause}; every file is restored"
)));
}
let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
return Err(AppError::Refused(format!(
"{cause}; restoration is incomplete, verify by hand: {}",
paths.join(" ")
)));
}
Ok(writes
.into_iter()
.map(|(destination, _, _)| destination)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
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";
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";
#[test]
fn the_rule_block_runs_from_its_heading_to_the_next() {
let seed = format!("{SPEC}\n{BLOCK}");
let block = rule_block(&seed, RuleId::RecordedDimensionOnlyShrinks);
assert!(block.is_none(), "a rule the seed lacks is not found");
let debt =
std::str::from_utf8(crate::embedded::asset("_docs/specs/SPEC-budget-debt.md").unwrap())
.unwrap();
let block = rule_block(debt, RuleId::RecordedDimensionOnlyShrinks).unwrap();
assert!(block.starts_with("### `budget-debt:a-recorded-dimension-only-shrinks`"));
assert!(block.contains("Verify:"));
assert!(!block.contains("debt-is-created-by-an-explicit-act"));
assert!(block.ends_with('\n') && !block.ends_with("\n\n"));
}
#[test]
fn the_block_is_appended_before_the_next_section_and_everything_else_survives() {
let out = append_to_requirements(SPEC, BLOCK).unwrap();
let ids: Vec<String> = crate::embedded::rule_ids_in(&out).collect();
assert_eq!(
ids,
vec!["sample:first".to_string(), "sample:second".to_string()]
);
assert!(out.contains("Verify: `true`\n\n### `sample:second`"));
assert!(out.contains("Verify: `true`\n\n## Unenforced\n"));
assert!(out.ends_with("| --- | --- |\n"));
assert!(out.starts_with("# Sample\n\n## Purpose\n\nOurs.\n"));
}
#[test]
fn a_file_whose_requirements_close_it_takes_the_block_at_the_end() {
let spec = SPEC.split("## Unenforced").next().unwrap();
let out = append_to_requirements(spec, BLOCK).unwrap();
assert!(out.ends_with(&format!("\n\n{BLOCK}")));
}
#[test]
fn a_file_without_a_requirements_section_is_not_rewritten() {
assert!(append_to_requirements("# Ours\n\nProse only.\n", BLOCK).is_none());
}
}