use camino::{Utf8Path, Utf8PathBuf};
use crate::domain::manifest::{MANIFEST_PATH, validate_docs_scratch_path};
use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
use crate::domain::profile::{ProfileId, resolve_destination};
use crate::domain::version::CanonVersion;
use crate::error::AppError;
#[derive(Debug, Clone)]
pub struct InitOptions {
pub target: Utf8PathBuf,
pub profile: ProfileId,
pub apply: bool,
pub dry_run: bool,
pub docs_scratch: Option<Option<Utf8PathBuf>>,
pub reserve: Vec<String>,
pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
}
#[derive(Debug)]
pub struct InitOutcome {
pub lines: Vec<String>,
pub applied: bool,
pub removed: Vec<String>,
}
fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
if !target.is_absolute() {
return Err(AppError::Usage("target must be absolute".to_string()));
}
if !target.is_dir() {
return Err(AppError::Usage(format!("unresolved target: {target}")));
}
let canonical = std::fs::canonicalize(target)?;
let canonical = Utf8PathBuf::from_path_buf(canonical)
.map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
if canonical.as_str().chars().all(|c| c == '/') {
return Err(AppError::Usage("refusing root target".to_string()));
}
let mut ancestor = Some(canonical.as_path());
while let Some(dir) = ancestor {
if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
&& cargo.contains("name = \"spec-driven-docs\"")
{
return Err(AppError::Usage(
"target is inside the canon checkout".to_string(),
));
}
ancestor = dir.parent();
}
Ok(canonical)
}
fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
for entry in target.read_dir_utf8()? {
let entry = entry?;
if entry.file_name() != ".git" {
return Ok(true);
}
}
Ok(false)
}
pub(crate) fn recorded_field(target: &Utf8Path, key: &str) -> Option<serde_json::Value> {
std::fs::read_to_string(target.join(MANIFEST_PATH))
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|value| value.get(key).cloned())
.filter(|value| !value.is_null())
}
fn installed_at(target: &Utf8Path) -> String {
recorded_field(target, "installed_at")
.and_then(|value| value.as_str().map(String::from))
.unwrap_or_else(|| {
jiff::Timestamp::now()
.strftime("%Y-%m-%dT%H:%M:%SZ")
.to_string()
})
}
pub(crate) fn resolved_docs_scratch(
target: &Utf8Path,
flag: Option<&Option<Utf8PathBuf>>,
) -> Result<Option<Utf8PathBuf>, AppError> {
if let Some(declared) = flag {
return Ok(declared.clone());
}
let Some(recorded) = recorded_field(target, "docs_scratch") else {
return Ok(None);
};
let path = recorded
.as_str()
.filter(|path| !path.is_empty())
.map(Utf8PathBuf::from)
.ok_or_else(|| {
AppError::ManifestInvalid(format!(
"the recorded docs_scratch is not a path ({recorded}); \
re-declare it with --docs-scratch"
))
})?;
if let Err(error) = validate_docs_scratch_path(&path) {
return Err(AppError::ManifestInvalid(format!(
"the recorded docs_scratch is not usable ({error}); \
re-declare it with --docs-scratch"
)));
}
Ok(Some(path))
}
#[derive(Debug, Clone)]
pub struct TargetState {
pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
pub lines: Vec<String>,
}
pub fn compute_target_state(
target: &Utf8Path,
options: &InitOptions,
) -> Result<TargetState, AppError> {
let candidate = candidate_for(target, options)?;
let mut lines: Vec<String> = Vec::new();
for destination in &candidate.destinations {
lines.push(destination.path.to_string());
}
lines.extend(candidate.notes.iter().cloned());
lines.push(MANIFEST_PATH.to_string());
Ok(TargetState {
files: candidate.files(),
lines,
})
}
pub fn candidate_for(
target: &Utf8Path,
options: &InitOptions,
) -> Result<crate::candidate::Candidate, AppError> {
crate::candidate::project(&gather(target, options)?)
}
pub fn resolved_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
canonical_target(target)
}
fn gather(target: &Utf8Path, options: &InitOptions) -> Result<crate::candidate::Input, AppError> {
let docs_root = crate::candidate::docs_root_of(options.profile)?;
let recorded_adopted: Vec<String> = recorded_field(target, "adopted_files")
.and_then(|value| {
value.as_array().map(|entries| {
entries
.iter()
.filter_map(|entry| entry.get("destination")?.as_str().map(String::from))
.collect()
})
})
.unwrap_or_default();
let mut existing = std::collections::BTreeMap::new();
for projection in &crate::domain::profile::DECLARATION.adopted {
let destination = resolve_destination(&projection.destination, docs_root);
let path = target.join(&destination);
if path.is_file() {
existing.insert(destination, std::fs::read(&path)?);
}
}
let hooks_path = target.join(HOOKS_CONFIG_PATH);
let hooks_host = if hooks_path.is_file() {
std::fs::read_to_string(&hooks_path)?
} else {
String::new()
};
let agents_path = target.join(AGENTS_DIGEST_PATH);
if agents_path.is_symlink() {
return Err(AppError::Refused(
"AGENTS.md is a symlink; refusing to write the documentation block through it"
.to_string(),
));
}
let agents_host = if agents_path.is_file() {
std::fs::read_to_string(&agents_path)?
} else {
String::new()
};
Ok(crate::candidate::Input {
profile: options.profile,
version: CanonVersion::current(),
installed_at: installed_at(target),
docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
reserve: options.reserve.clone(),
writing_style: options.writing_style.clone(),
evidence: crate::candidate::Evidence {
existing,
recorded_adopted,
hooks_host,
agents_host,
},
})
}
pub fn init(
options: &InitOptions,
intent: crate::landing::classify::Intent,
) -> Result<InitOutcome, AppError> {
init_holding(None, options, intent)
}
pub fn init_holding(
held: Option<crate::transaction::lock::Lock>,
options: &InitOptions,
intent: crate::landing::classify::Intent,
) -> Result<InitOutcome, AppError> {
let target = canonical_target(&options.target)?;
crate::commands::front::serves(intent, &target)?;
let forced_dry = !options.apply
&& !options.dry_run
&& target_has_content(&target)?
&& !target.join(MANIFEST_PATH).is_file();
let dry = options.dry_run || forced_dry;
let held = match (dry, held) {
(true, _) => None,
(false, Some(held)) => Some(held),
(false, None) => Some(crate::landing::lock::hold(&target)?),
};
if let Some(recorded) = recorded_field(&target, "profile").and_then(|value| {
ProfileId::every().find(|profile| Some(profile.as_str()) == value.as_str())
}) && recorded != options.profile
{
return Err(AppError::Refused(format!(
"{target} records the {recorded} profile and this run asks for {}; \
moving a profile moves the documentation root, which is a migration to ask for deliberately",
options.profile
)));
}
let candidate = candidate_for(&target, options)?;
let mut lines: Vec<String> = candidate
.destinations
.iter()
.map(|destination| destination.path.to_string())
.collect();
lines.extend(candidate.notes.iter().cloned());
lines.push(MANIFEST_PATH.to_string());
if dry {
if forced_dry {
lines.push(
"DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
.to_string(),
);
}
lines.push("DRY RUN: no files written".to_string());
return Ok(InitOutcome {
lines,
applied: false,
removed: Vec::new(),
});
}
let outcome = crate::landing::apply::land(&target, &candidate, &recorded(&target))?;
drop(held);
Ok(InitOutcome {
lines,
applied: true,
removed: outcome.removed,
})
}
pub(crate) fn recorded(target: &Utf8Path) -> crate::landing::apply::Recorded {
crate::landing::apply::Recorded {
managed: digests(target, "managed_files", "destination", "sha256"),
integration: digests(target, "integration_blocks", "path", "marker_hash"),
}
}
fn digests(
target: &Utf8Path,
key: &str,
name: &str,
digest: &str,
) -> Vec<(String, crate::domain::ownership::Sha256)> {
let Some(value) = recorded_field(target, key) else {
return Vec::new();
};
let Some(entries) = value.as_array() else {
return Vec::new();
};
entries
.iter()
.filter_map(|entry| {
let destination = entry.get(name)?.as_str()?.to_string();
let held = entry.get(digest)?.as_str()?;
let held = held.parse::<crate::domain::ownership::Sha256>().ok()?;
Some((destination, held))
})
.collect()
}