use std::collections::BTreeMap;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::domain::profile::{ProfileId, resolve_destination};
use crate::error::AppError;
use crate::gates::PRUNED_DIRS;
use crate::services::status::{StatusReport, status};
const DOC_ROOTS: &[&str] = &["docs", "_docs", "doc", "documentation"];
const ROOT_MARKERS: &[&str] = &[
"specs",
"decisions",
"adr",
"adrs",
"mkdocs.yml",
"docusaurus.config.js",
"docusaurus.config.ts",
"conf.py",
];
const DOC_EXTENSIONS: &[&str] = &["md", "markdown", "adoc", "rst", "org"];
const ROOT_METADATA: &[&str] = &[
"readme",
"license",
"licence",
"contributing",
"changelog",
"agents",
"claude",
"code_of_conduct",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Classification {
Greenfield,
Brownfield,
NeedsDecision,
}
impl Classification {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Greenfield => "greenfield",
Self::Brownfield => "brownfield",
Self::NeedsDecision => "needs-decision",
}
}
}
#[derive(Debug, Serialize)]
pub struct Documents {
pub count: usize,
pub paths: Vec<Utf8PathBuf>,
}
#[derive(Debug, Serialize)]
pub struct AssessReport {
pub schema: &'static str,
pub target: Utf8PathBuf,
pub classification: Classification,
pub instance: StatusReport,
pub doc_roots: Vec<String>,
pub populated_doc_roots: Vec<String>,
pub documents: Documents,
pub methodology_markers: Vec<String>,
pub collisions: BTreeMap<String, Vec<String>>,
pub docs_scratch: Utf8PathBuf,
pub docs_scratch_present: bool,
}
const DOCS_SCRATCH_CANDIDATE: &str = ".docs-scratch";
fn docs_scratch(target: &Utf8Path, named: Option<Utf8PathBuf>) -> Utf8PathBuf {
let ctx = crate::gates::GateCtx::new(target);
crate::gates::paths::docs_scratch_with(&ctx, named)
.unwrap_or_else(|| Utf8PathBuf::from(DOCS_SCRATCH_CANDIDATE))
}
pub fn assess(target: &Utf8Path) -> Result<AssessReport, AppError> {
assess_with(target, crate::gates::paths::docs_scratch_variable())
}
pub fn assess_with(
target: &Utf8Path,
named: Option<Utf8PathBuf>,
) -> Result<AssessReport, AppError> {
match std::fs::metadata(target) {
Ok(metadata) if !metadata.is_dir() => {
return Err(AppError::Usage(format!(
"target is not a directory: {target}"
)));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(AppError::Io(error)),
}
let instance = status(target)?;
let doc_roots: Vec<String> = DOC_ROOTS
.iter()
.filter(|root| {
let root = target.join(root);
root.is_dir() || root.is_symlink()
})
.map(|root| (*root).to_string())
.collect();
let scratch = docs_scratch(target, named);
let walked = walk(target, &scratch)?;
let paths = walked.documents;
let methodology_markers = markers(target, &doc_roots)?;
let collisions = collisions(target)?;
let docs_scratch_present = target.join(&scratch).is_dir();
let populated_doc_roots: Vec<String> = doc_roots
.iter()
.filter(|root| walked.populated_roots.contains(*root) || target.join(root).is_symlink())
.cloned()
.collect();
let beyond_metadata = paths.iter().any(|path| !is_root_metadata(path));
let classification = if !populated_doc_roots.is_empty() || !methodology_markers.is_empty() {
Classification::Brownfield
} else if beyond_metadata {
Classification::NeedsDecision
} else {
Classification::Greenfield
};
Ok(AssessReport {
schema: "sdd.assess/2",
target: target.to_owned(),
classification,
instance,
doc_roots,
populated_doc_roots,
documents: Documents {
count: paths.len(),
paths,
},
methodology_markers,
collisions,
docs_scratch: scratch,
docs_scratch_present,
})
}
fn normalized(path: &Utf8Path) -> Utf8PathBuf {
let mut out = Utf8PathBuf::new();
for component in path.components() {
match component {
camino::Utf8Component::CurDir => {}
camino::Utf8Component::ParentDir => {
if matches!(
out.components().next_back(),
Some(camino::Utf8Component::Normal(_))
) {
out.pop();
} else {
out.push("..");
}
}
other => out.push(other.as_str()),
}
}
out
}
struct Walked {
documents: Vec<Utf8PathBuf>,
populated_roots: Vec<String>,
}
fn walk(target: &Utf8Path, scratch: &Utf8Path) -> Result<Walked, AppError> {
let mut documents = Vec::new();
let mut populated_roots = Vec::new();
let scratch_path = normalized(&target.join(scratch));
let walker = walkdir::WalkDir::new(target).into_iter().filter_entry(|e| {
let name = e.file_name().to_string_lossy();
!(e.depth() > 0
&& e.file_type().is_dir()
&& (PRUNED_DIRS.contains(&name.as_ref())
|| name == ".spec-driven-docs"
|| e.path()
.to_str()
.is_some_and(|path| normalized(Utf8Path::new(path)) == scratch_path)))
});
for entry in walker {
let entry = entry.map_err(|source| AppError::Io(std::io::Error::from(source)))?;
if entry.file_type().is_dir() {
continue;
}
let Some(path) = entry.path().to_str() else {
continue;
};
let relative = Utf8Path::new(path)
.strip_prefix(target)
.unwrap_or_else(|_| Utf8Path::new(path));
if let Some(root) = relative.components().next() {
let root = root.as_str().to_string();
if relative.components().nth(1).is_some() && !populated_roots.contains(&root) {
populated_roots.push(root);
}
}
if entry.file_type().is_file()
&& relative.extension().is_some_and(|extension| {
DOC_EXTENSIONS
.iter()
.any(|known| extension.eq_ignore_ascii_case(known))
})
{
documents.push(relative.to_owned());
}
}
documents.sort();
Ok(Walked {
documents,
populated_roots,
})
}
fn is_root_metadata(path: &Utf8Path) -> bool {
if path
.parent()
.is_some_and(|parent| !parent.as_str().is_empty())
{
return false;
}
let Some(stem) = path.file_stem() else {
return false;
};
let stem = stem.to_ascii_lowercase();
ROOT_METADATA.iter().any(|metadata| stem == *metadata)
}
fn entry_present(path: &Utf8Path) -> Result<bool, AppError> {
match path.symlink_metadata() {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(AppError::Io(error)),
}
}
fn markers(target: &Utf8Path, doc_roots: &[String]) -> Result<Vec<String>, AppError> {
let mut found = Vec::new();
for marker in ROOT_MARKERS {
if entry_present(&target.join(marker))? {
found.push((*marker).to_string());
}
}
for root in doc_roots {
for zone in ["specs", "decisions", "adr", "adrs", "conf.py"] {
let candidate = format!("{root}/{zone}");
if entry_present(&target.join(&candidate))? {
found.push(candidate);
}
}
}
Ok(found)
}
fn collisions(target: &Utf8Path) -> Result<BTreeMap<String, Vec<String>>, AppError> {
let mut collisions = BTreeMap::new();
for id in [ProfileId::Codebase, ProfileId::KnowledgeBase] {
let profile = id.profile();
let mut existing = Vec::new();
for projection in profile.managed.iter().chain(profile.adopted) {
let destination = resolve_destination(projection.destination, profile.docs_root);
if entry_present(&target.join(&destination))? {
existing.push(destination.to_string());
}
}
collisions.insert(id.as_str().to_string(), existing);
}
Ok(collisions)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from(dir.path().to_str().unwrap())
}
fn write(root: &Utf8Path, relative: &str) {
let path = root.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "content\n").unwrap();
}
#[test]
fn root_metadata_is_recognized_case_insensitively_and_only_at_root() {
assert!(is_root_metadata(Utf8Path::new("README.md")));
assert!(is_root_metadata(Utf8Path::new("readme.md")));
assert!(is_root_metadata(Utf8Path::new("code_of_conduct.md")));
assert!(is_root_metadata(Utf8Path::new("CONTRIBUTING.md")));
assert!(is_root_metadata(Utf8Path::new("AGENTS.md")));
assert!(!is_root_metadata(Utf8Path::new("notes.md")));
assert!(!is_root_metadata(Utf8Path::new("sub/README.md")));
}
#[test]
fn an_empty_target_classifies_greenfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "README.md");
write(&root, "CHANGELOG.md");
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::Greenfield);
assert_eq!(report.documents.count, 2);
}
#[test]
fn a_non_markdown_corpus_under_a_doc_root_classifies_brownfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "docs/guide.adoc");
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::Brownfield);
}
#[test]
fn a_symlinked_document_under_a_doc_root_classifies_brownfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "elsewhere.md");
std::fs::create_dir_all(root.join("docs")).unwrap();
std::os::unix::fs::symlink(root.join("elsewhere.md"), root.join("docs/architecture.md"))
.unwrap();
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::Brownfield);
assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
}
#[test]
fn a_broken_doc_root_symlink_classifies_brownfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
std::os::unix::fs::symlink(root.join("no-such-corpus"), root.join("docs")).unwrap();
let report = assess_with(&root, None).unwrap();
assert_eq!(report.doc_roots, vec!["docs".to_string()]);
assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
assert_eq!(report.classification, Classification::Brownfield);
}
#[test]
fn a_broken_marker_symlink_still_classifies_brownfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
std::os::unix::fs::symlink(root.join("no-such-config"), root.join("mkdocs.yml")).unwrap();
let report = assess_with(&root, None).unwrap();
assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
assert_eq!(report.classification, Classification::Brownfield);
}
#[test]
fn a_broken_destination_symlink_reads_as_a_collision() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
std::fs::create_dir_all(root.join("docs/specs")).unwrap();
std::os::unix::fs::symlink(
root.join("gone.md"),
root.join("docs/specs/SPEC-docs-format.md"),
)
.unwrap();
let report = assess_with(&root, None).unwrap();
assert!(
report.collisions["codebase"]
.iter()
.any(|path| path == "docs/specs/SPEC-docs-format.md")
);
}
#[test]
fn an_unreadable_entry_propagates_as_io_rather_than_absence() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
std::fs::create_dir_all(root.join("locked")).unwrap();
std::fs::write(root.join("locked/mkdocs.yml"), "site_name: x\n").unwrap();
std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o000))
.unwrap();
let result = entry_present(&root.join("locked/mkdocs.yml"));
std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o755))
.unwrap();
if nix_is_root() {
return;
}
match result {
Err(AppError::Io(_)) => {}
other => panic!("expected an I/O error, got {other:?}"),
}
}
fn nix_is_root() -> bool {
std::fs::read_dir("/root").is_ok()
}
#[test]
fn a_file_target_refuses_instead_of_classifying() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "just-a-file.md");
let error = assess_with(&root.join("just-a-file.md"), None).unwrap_err();
assert!(matches!(error, AppError::Usage(_)), "{error}");
}
#[test]
fn a_symlinked_doc_root_classifies_brownfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
std::fs::create_dir_all(root.join("external-corpus")).unwrap();
std::fs::write(root.join("external-corpus/guide.txt"), "prose\n").unwrap();
std::os::unix::fs::symlink(root.join("external-corpus"), root.join("docs")).unwrap();
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::Brownfield);
assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
}
#[test]
fn a_dotted_metadata_prefix_is_not_metadata() {
assert!(!is_root_metadata(Utf8Path::new("README.architecture.md")));
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "README.architecture.md");
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::NeedsDecision);
}
#[test]
fn a_corpus_under_a_doc_root_classifies_brownfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "docs/architecture.md");
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::Brownfield);
assert_eq!(report.doc_roots, vec!["docs".to_string()]);
assert_eq!(
report.documents.paths,
vec![Utf8PathBuf::from("docs/architecture.md")]
);
}
#[test]
fn a_methodology_marker_alone_classifies_brownfield() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "README.md");
write(&root, "mkdocs.yml");
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::Brownfield);
assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
}
#[test]
fn scattered_markdown_classifies_needs_decision() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "notes/design.md");
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::NeedsDecision);
}
#[test]
fn the_docs_scratch_and_pruned_directories_stay_out_of_the_inventory() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, ".docs-scratch/notes.md");
write(&root, "target/build.md");
write(&root, "node_modules/pkg/README.md");
let report = assess_with(&root, None).unwrap();
assert_eq!(report.classification, Classification::Greenfield);
assert_eq!(report.documents.count, 0);
assert!(report.docs_scratch_present);
assert_eq!(report.docs_scratch, DOCS_SCRATCH_CANDIDATE);
}
#[test]
fn the_walk_prunes_the_declared_scratch_and_nothing_else() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "staging/rewrite.md");
write(&root, ".docs-scratch/notes.md");
let walked = walk(&root, Utf8Path::new("staging")).unwrap();
assert_eq!(
walked.documents,
vec![Utf8PathBuf::from(".docs-scratch/notes.md")]
);
}
#[test]
fn a_docs_scratch_outside_the_target_prunes_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = utf8(&dir);
write(&root, "notes/design.md");
write(&root, ".docs-scratch/kept.md");
let walked = walk(&root, Utf8Path::new("../beside")).unwrap();
assert_eq!(walked.documents.len(), 2);
}
}