use camino::Utf8Path;
use crate::adapters::fs::sha256_file;
use crate::domain::manifest::{CANON_SOURCE, MANIFEST_PATH, Manifest, SCHEMA_VERSION};
use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry};
use crate::domain::profile::{DocsRoot, ProfileId};
use crate::domain::version::CanonVersion;
use crate::error::AppError;
pub(crate) fn is_canon_checkout(root: &Utf8Path) -> bool {
std::fs::read_to_string(root.join("Cargo.toml"))
.is_ok_and(|cargo| cargo.contains("name = \"spec-driven-docs\""))
}
fn sorted_files(root: &Utf8Path, dir: &str, matches: impl Fn(&str) -> bool) -> Vec<String> {
let mut names: Vec<String> = root
.join(dir)
.read_dir_utf8()
.map(|entries| {
entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string())
.filter(|name| matches(name))
.collect()
})
.unwrap_or_default();
names.sort();
names
.into_iter()
.map(|name| format!("{dir}/{name}"))
.collect()
}
fn skill_files(root: &Utf8Path) -> Vec<String> {
let mut names: Vec<String> = root
.join("skills")
.read_dir_utf8()
.map(|entries| {
entries
.filter_map(Result::ok)
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.file_name().to_string())
.collect()
})
.unwrap_or_default();
names.sort();
names
.into_iter()
.map(|name| format!("skills/{name}/SKILL.md"))
.collect()
}
pub fn regenerate(root: &Utf8Path) -> Result<String, AppError> {
if !is_canon_checkout(root) {
return Err(AppError::Refused("not the canon checkout".to_string()));
}
let mut managed = Vec::new();
#[allow(
clippy::case_sensitive_file_extension_comparisons,
reason = "the payload convention is lowercase, as the glob it replaces was"
)]
let jsonc = |name: &str| name.ends_with(".jsonc");
for path in sorted_files(root, ".markdownlint", jsonc) {
managed.push(ManagedEntry {
source: path.clone().into(),
destination: path.clone().into(),
sha256: sha256_file(&root.join(&path))?,
});
}
for path in skill_files(root) {
managed.push(ManagedEntry {
source: path.clone().into(),
destination: path.clone().into(),
sha256: sha256_file(&root.join(&path))?,
});
}
for (path, _) in crate::embedded::shared_artifacts() {
let path = format!("skill-shared/{path}");
managed.push(ManagedEntry {
source: path.clone().into(),
destination: path.clone().into(),
sha256: sha256_file(&root.join(&path))?,
});
}
#[allow(
clippy::case_sensitive_file_extension_comparisons,
reason = "the spec convention is lowercase, as the glob it replaces was"
)]
let spec = |name: &str| name.starts_with("SPEC-") && name.ends_with(".md");
let mut adopted_paths = sorted_files(root, "_docs/specs", spec);
for template in crate::domain::profile::CANON_TEMPLATES {
adopted_paths.push((*template).to_string());
}
let mut adopted = Vec::new();
for path in adopted_paths {
let digest = sha256_file(&root.join(&path))?;
adopted.push(AdoptedEntry {
source: path.clone().into(),
destination: path.into(),
sha256: digest.clone(),
baseline_sha256: digest,
});
}
let registry = "_docs/reference/tracking.yaml";
let registry_baseline = "templates/TEMPLATE-tracking.yaml";
if root.join(registry).is_file() {
adopted.push(AdoptedEntry {
source: registry_baseline.into(),
destination: registry.into(),
sha256: sha256_file(&root.join(registry))?,
baseline_sha256: sha256_file(&root.join(registry_baseline))?,
});
}
let config = std::fs::read_to_string(root.join(".pre-commit-config.yaml"))?;
let marker_hash = crate::domain::marker::block_hash(&config).ok_or_else(|| {
AppError::Refused("no managed block in .pre-commit-config.yaml".to_string())
})?;
let installed_at = crate::services::installer::recorded_field(root, "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()
});
let plan_zone = crate::services::installer::resolved_plan_zone(root, None)?;
let docs_scratch = crate::services::installer::resolved_docs_scratch(root, None)?;
let manifest = Manifest {
schema_version: SCHEMA_VERSION,
canon_version: CanonVersion::current(),
canon_source: CANON_SOURCE.to_string(),
profile: ProfileId::KnowledgeBase,
docs_root: DocsRoot::UnderscoreDocs,
installed_at,
plan_zone,
docs_scratch,
managed_files: managed,
adopted_files: adopted,
integration_blocks: vec![IntegrationBlock {
path: ".pre-commit-config.yaml".into(),
marker_hash,
}],
};
crate::adapters::fs::write_file(&root.join(MANIFEST_PATH), manifest.to_json().as_bytes())?;
Ok(format!("OK regenerated {MANIFEST_PATH}"))
}