use camino::Utf8Path;
use crate::adapters::fs::write_within;
use crate::cli::hooks::HooksArgs;
use crate::context::AppContext;
use crate::domain::instance_config::InstanceConfig;
use crate::domain::manifest::{MANIFEST_PATH, Manifest};
use crate::domain::marker;
use crate::domain::ownership::Sha256;
use crate::error::AppError;
use crate::output;
use crate::services::hooks_render::{RenderOptions, render_block};
pub const CONFIG: &str = ".pre-commit-config.yaml";
pub const AGENTS: &str = "AGENTS.md";
fn record_block_hash(target: &Utf8Path, path: &str, hash: Option<Sha256>) -> Result<(), AppError> {
let manifest_relative = Utf8Path::new(MANIFEST_PATH);
let text = match std::fs::read_to_string(target.join(manifest_relative)) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
let hash = hash.ok_or_else(|| {
AppError::ManifestInvalid(format!("the rewritten {path} carries no managed block"))
})?;
let mut document: serde_json::Value = serde_json::from_str(&text)
.map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
let Some(blocks) = document
.get_mut("integration_blocks")
.and_then(serde_json::Value::as_array_mut)
else {
return Err(AppError::ManifestInvalid(
"integration_blocks is not an array".to_string(),
));
};
let mut matched = 0usize;
for block in blocks.iter_mut() {
if block.get("path").and_then(serde_json::Value::as_str) == Some(path) {
block["marker_hash"] = serde_json::Value::String(hash.to_string());
matched += 1;
}
}
if matched != 1 {
return Err(AppError::ManifestInvalid(format!(
"the manifest records {matched} integration blocks for {path}; expected one"
)));
}
let rendered = serde_json::to_string_pretty(&document)
.map_err(|error| AppError::ManifestInvalid(error.to_string()))?;
write_within(
target,
manifest_relative,
format!("{rendered}\n").as_bytes(),
)?;
Ok(())
}
fn restored(target: &Utf8Path, relative: &str, previous: &[u8], cause: &AppError) -> AppError {
match write_within(target, Utf8Path::new(relative), previous) {
Ok(()) => AppError::Refused(format!(
"{relative} was rewritten and its record could not be updated, so it was put back: {cause}"
)),
Err(error) => AppError::Refused(format!(
"{relative} was rewritten, its record could not be updated ({cause}), and restoring it failed ({error}); verify {relative} by hand"
)),
}
}
#[derive(Debug)]
enum Agents {
Current,
Stale(String),
Missing(&'static str),
Unmanaged,
}
fn instance_record(target: &Utf8Path) -> Result<Option<Manifest>, AppError> {
match crate::services::verifier::read_manifest(target) {
Ok(manifest) => Ok(Some(manifest)),
Err(AppError::ManifestMissing(_)) => Ok(None),
Err(error) => Err(error),
}
}
fn agents_block_recorded(manifest: Option<&Manifest>) -> bool {
manifest.is_some_and(|manifest| {
manifest
.integration_blocks
.iter()
.any(|block| block.path.as_str() == AGENTS)
})
}
fn agents_state(
target: &Utf8Path,
recorded: bool,
docs_root: &str,
declaration: &InstanceConfig,
) -> Result<Agents, AppError> {
let path = target.join(AGENTS);
if path.is_symlink() {
return Err(AppError::Refused(
"AGENTS.md is a symlink; refusing to write the documentation block through it"
.to_string(),
));
}
if !recorded {
return Ok(Agents::Unmanaged);
}
let host = match std::fs::read_to_string(&path) {
Ok(host) => host,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Agents::Missing("the file is absent"));
}
Err(error) => return Err(error.into()),
};
if marker::block_region_with(&host, marker::AGENTS_BEGIN, marker::AGENTS_END).is_none() {
return Ok(Agents::Missing("its managed block is gone"));
}
let block = crate::services::agents_render::render_block(docs_root, &declaration.writing_style);
let placed = marker::place_agents_block(&host, &block)?;
Ok(if placed == host {
Agents::Current
} else {
Agents::Stale(placed)
})
}
fn check(
path: &Utf8Path,
config_current: bool,
agents_path: &Utf8Path,
agents: &Agents,
) -> Result<(), AppError> {
let mut count = 0;
if !config_current {
output::line(format!(
"FAIL {path} does not match the declaration; run 'sdd hooks --apply'"
));
count += 1;
}
match agents {
Agents::Stale(..) => {
output::line(format!(
"FAIL the documentation block in {agents_path} does not match the declaration; run 'sdd hooks --apply'"
));
count += 1;
}
Agents::Missing(why) => {
output::line(format!(
"FAIL the install recorded a documentation block in {agents_path} and {why}; run 'sdd init --apply' to restore it"
));
count += 1;
}
Agents::Current | Agents::Unmanaged => {}
}
if count == 0 {
return Ok(());
}
Err(AppError::Violations { count })
}
pub fn run(_ctx: &AppContext, args: HooksArgs) -> Result<(), AppError> {
let target = Utf8Path::new(&args.target);
let declaration =
InstanceConfig::read(target).map_err(|error| AppError::Usage(error.to_string()))?;
let manifest = instance_record(target)?;
let docs_root = args.docs_root.unwrap_or_else(|| {
manifest
.as_ref()
.map_or_else(|| "_docs".to_string(), |m| m.docs_root.to_string())
});
if !args.apply && !args.check {
output::line(
render_block(&RenderOptions {
docs_root,
entry: args.entry,
indent: args.indent,
declaration,
})
.trim_end_matches('\n'),
);
return Ok(());
}
let path = target.join(CONFIG);
let host = std::fs::read_to_string(&path)?;
let (base, _) = marker::split_block(&host)?;
let rendered = render_block(&RenderOptions {
docs_root: docs_root.clone(),
entry: args.entry,
indent: marker::splice_indent(&base)?,
declaration: declaration.clone(),
});
let spliced = marker::splice(&base, &rendered)?;
let agents = agents_state(
target,
agents_block_recorded(manifest.as_ref()),
&docs_root,
&declaration,
)?;
let agents_path = target.join(AGENTS);
if args.check {
return check(&path, spliced == host, &agents_path, &agents);
}
if let Agents::Missing(why) = &agents {
return Err(AppError::Refused(format!(
"the install recorded a documentation block in {agents_path} and {why}; run 'sdd init --apply' to restore it"
)));
}
let agents_placed = match agents {
Agents::Stale(placed) => Some(placed),
Agents::Current | Agents::Unmanaged | Agents::Missing(_) => None,
};
if spliced == host && agents_placed.is_none() {
output::line(format!("OK {path} already matches the declaration"));
return Ok(());
}
if spliced != host {
write_within(target, Utf8Path::new(CONFIG), spliced.as_bytes())?;
if let Err(error) = record_block_hash(target, CONFIG, marker::block_hash(&spliced)) {
return Err(restored(target, CONFIG, host.as_bytes(), &error));
}
output::line(format!("OK rewrote the managed region in {path}"));
}
if let Some(placed) = agents_placed {
let previous = std::fs::read(&agents_path)?;
write_within(target, Utf8Path::new(AGENTS), placed.as_bytes())?;
if let Err(error) = record_block_hash(
target,
AGENTS,
marker::block_hash_with(&placed, marker::AGENTS_BEGIN, marker::AGENTS_END),
) {
return Err(restored(target, AGENTS, &previous, &error));
}
output::line(format!(
"OK rewrote the documentation block in {agents_path}"
));
}
Ok(())
}