1use std::collections::BTreeMap;
12
13use camino::{Utf8Path, Utf8PathBuf};
14use serde::Serialize;
15
16use crate::domain::profile::{ProfileId, resolve_destination};
17use crate::error::AppError;
18use crate::gates::PRUNED_DIRS;
19use crate::services::status::{StatusReport, status};
20
21const DOC_ROOTS: &[&str] = &["docs", "_docs", "doc", "documentation"];
23
24const ROOT_MARKERS: &[&str] = &[
27 "specs",
28 "decisions",
29 "adr",
30 "adrs",
31 "mkdocs.yml",
32 "docusaurus.config.js",
33 "docusaurus.config.ts",
34 "conf.py",
35];
36
37const DOC_EXTENSIONS: &[&str] = &["md", "markdown", "adoc", "rst", "org"];
39
40const ROOT_METADATA: &[&str] = &[
42 "readme",
43 "license",
44 "licence",
45 "contributing",
46 "changelog",
47 "agents",
48 "claude",
49 "code_of_conduct",
50];
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Classification {
56 Greenfield,
58 Brownfield,
60 NeedsDecision,
62}
63
64impl Classification {
65 #[must_use]
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 Self::Greenfield => "greenfield",
70 Self::Brownfield => "brownfield",
71 Self::NeedsDecision => "needs-decision",
72 }
73 }
74}
75
76#[derive(Debug, Serialize)]
78pub struct Documents {
79 pub count: usize,
81 pub paths: Vec<Utf8PathBuf>,
83}
84
85#[derive(Debug, Serialize)]
87pub struct AssessReport {
88 pub schema: &'static str,
90 pub target: Utf8PathBuf,
92 pub classification: Classification,
94 pub instance: StatusReport,
96 pub doc_roots: Vec<String>,
98 pub populated_doc_roots: Vec<String>,
102 pub documents: Documents,
104 pub methodology_markers: Vec<String>,
106 pub collisions: BTreeMap<String, Vec<String>>,
108 pub docs_scratch: Utf8PathBuf,
111 pub docs_scratch_present: bool,
113}
114
115const DOCS_SCRATCH_CANDIDATE: &str = ".docs-scratch";
120
121fn docs_scratch(target: &Utf8Path, named: Option<Utf8PathBuf>) -> Utf8PathBuf {
126 let ctx = crate::gates::GateCtx::new(target);
127 crate::gates::paths::docs_scratch_with(&ctx, named)
128 .unwrap_or_else(|| Utf8PathBuf::from(DOCS_SCRATCH_CANDIDATE))
129}
130
131pub fn assess(target: &Utf8Path) -> Result<AssessReport, AppError> {
140 assess_with(target, crate::gates::paths::docs_scratch_variable())
141}
142
143pub fn assess_with(
154 target: &Utf8Path,
155 named: Option<Utf8PathBuf>,
156) -> Result<AssessReport, AppError> {
157 match std::fs::metadata(target) {
162 Ok(metadata) if !metadata.is_dir() => {
163 return Err(AppError::Usage(format!(
164 "target is not a directory: {target}"
165 )));
166 }
167 Ok(_) => {}
168 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
169 Err(error) => return Err(AppError::Io(error)),
170 }
171 let instance = status(target)?;
172 let doc_roots: Vec<String> = DOC_ROOTS
176 .iter()
177 .filter(|root| {
178 let root = target.join(root);
179 root.is_dir() || root.is_symlink()
180 })
181 .map(|root| (*root).to_string())
182 .collect();
183 let scratch = docs_scratch(target, named);
184 let walked = walk(target, &scratch)?;
185 let paths = walked.documents;
186 let methodology_markers = markers(target, &doc_roots)?;
187 let collisions = collisions(target)?;
188 let docs_scratch_present = target.join(&scratch).is_dir();
189
190 let populated_doc_roots: Vec<String> = doc_roots
197 .iter()
198 .filter(|root| walked.populated_roots.contains(*root) || target.join(root).is_symlink())
199 .cloned()
200 .collect();
201 let beyond_metadata = paths.iter().any(|path| !is_root_metadata(path));
202 let classification = if !populated_doc_roots.is_empty() || !methodology_markers.is_empty() {
203 Classification::Brownfield
204 } else if beyond_metadata {
205 Classification::NeedsDecision
206 } else {
207 Classification::Greenfield
208 };
209
210 Ok(AssessReport {
211 schema: "sdd.assess/2",
212 target: target.to_owned(),
213 classification,
214 instance,
215 doc_roots,
216 populated_doc_roots,
217 documents: Documents {
218 count: paths.len(),
219 paths,
220 },
221 methodology_markers,
222 collisions,
223 docs_scratch: scratch,
224 docs_scratch_present,
225 })
226}
227
228fn normalized(path: &Utf8Path) -> Utf8PathBuf {
234 let mut out = Utf8PathBuf::new();
235 for component in path.components() {
236 match component {
237 camino::Utf8Component::CurDir => {}
238 camino::Utf8Component::ParentDir => {
239 if matches!(
240 out.components().next_back(),
241 Some(camino::Utf8Component::Normal(_))
242 ) {
243 out.pop();
244 } else {
245 out.push("..");
246 }
247 }
248 other => out.push(other.as_str()),
249 }
250 }
251 out
252}
253
254struct Walked {
256 documents: Vec<Utf8PathBuf>,
258 populated_roots: Vec<String>,
260}
261
262fn walk(target: &Utf8Path, scratch: &Utf8Path) -> Result<Walked, AppError> {
272 let mut documents = Vec::new();
273 let mut populated_roots = Vec::new();
274 let scratch_path = normalized(&target.join(scratch));
279 let walker = walkdir::WalkDir::new(target).into_iter().filter_entry(|e| {
280 let name = e.file_name().to_string_lossy();
281 !(e.depth() > 0
282 && e.file_type().is_dir()
283 && (PRUNED_DIRS.contains(&name.as_ref())
284 || name == ".spec-driven-docs"
285 || e.path()
286 .to_str()
287 .is_some_and(|path| normalized(Utf8Path::new(path)) == scratch_path)))
288 });
289 for entry in walker {
290 let entry = entry.map_err(|source| AppError::Io(std::io::Error::from(source)))?;
291 if entry.file_type().is_dir() {
292 continue;
293 }
294 let Some(path) = entry.path().to_str() else {
295 continue;
296 };
297 let relative = Utf8Path::new(path)
298 .strip_prefix(target)
299 .unwrap_or_else(|_| Utf8Path::new(path));
300 if let Some(root) = relative.components().next() {
301 let root = root.as_str().to_string();
302 if relative.components().nth(1).is_some() && !populated_roots.contains(&root) {
303 populated_roots.push(root);
304 }
305 }
306 if entry.file_type().is_file()
307 && relative.extension().is_some_and(|extension| {
308 DOC_EXTENSIONS
309 .iter()
310 .any(|known| extension.eq_ignore_ascii_case(known))
311 })
312 {
313 documents.push(relative.to_owned());
314 }
315 }
316 documents.sort();
317 Ok(Walked {
318 documents,
319 populated_roots,
320 })
321}
322
323fn is_root_metadata(path: &Utf8Path) -> bool {
325 if path
326 .parent()
327 .is_some_and(|parent| !parent.as_str().is_empty())
328 {
329 return false;
330 }
331 let Some(stem) = path.file_stem() else {
332 return false;
333 };
334 let stem = stem.to_ascii_lowercase();
335 ROOT_METADATA.iter().any(|metadata| stem == *metadata)
339}
340
341fn entry_present(path: &Utf8Path) -> Result<bool, AppError> {
349 match path.symlink_metadata() {
350 Ok(_) => Ok(true),
351 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
352 Err(error) => Err(AppError::Io(error)),
353 }
354}
355
356fn markers(target: &Utf8Path, doc_roots: &[String]) -> Result<Vec<String>, AppError> {
359 let mut found = Vec::new();
360 for marker in ROOT_MARKERS {
361 if entry_present(&target.join(marker))? {
362 found.push((*marker).to_string());
363 }
364 }
365 for root in doc_roots {
366 for zone in ["specs", "decisions", "adr", "adrs", "conf.py"] {
367 let candidate = format!("{root}/{zone}");
368 if entry_present(&target.join(&candidate))? {
369 found.push(candidate);
370 }
371 }
372 }
373 Ok(found)
374}
375
376fn collisions(target: &Utf8Path) -> Result<BTreeMap<String, Vec<String>>, AppError> {
378 let mut collisions = BTreeMap::new();
379 for id in [ProfileId::Codebase, ProfileId::KnowledgeBase] {
380 let profile = id.profile();
381 let mut existing = Vec::new();
382 for projection in profile.managed.iter().chain(profile.adopted) {
383 let destination = resolve_destination(projection.destination, profile.docs_root);
384 if entry_present(&target.join(&destination))? {
385 existing.push(destination.to_string());
386 }
387 }
388 collisions.insert(id.as_str().to_string(), existing);
389 }
390 Ok(collisions)
391}
392
393#[cfg(test)]
394mod tests {
395 #![allow(clippy::unwrap_used)]
397
398 use super::*;
399
400 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
401 Utf8PathBuf::from(dir.path().to_str().unwrap())
402 }
403
404 fn write(root: &Utf8Path, relative: &str) {
405 let path = root.join(relative);
406 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
407 std::fs::write(path, "content\n").unwrap();
408 }
409
410 #[test]
411 fn root_metadata_is_recognized_case_insensitively_and_only_at_root() {
412 assert!(is_root_metadata(Utf8Path::new("README.md")));
413 assert!(is_root_metadata(Utf8Path::new("readme.md")));
414 assert!(is_root_metadata(Utf8Path::new("code_of_conduct.md")));
415 assert!(is_root_metadata(Utf8Path::new("CONTRIBUTING.md")));
416 assert!(is_root_metadata(Utf8Path::new("AGENTS.md")));
417 assert!(!is_root_metadata(Utf8Path::new("notes.md")));
418 assert!(!is_root_metadata(Utf8Path::new("sub/README.md")));
419 }
420
421 #[test]
422 fn an_empty_target_classifies_greenfield() {
423 let dir = tempfile::tempdir().unwrap();
424 let root = utf8(&dir);
425 write(&root, "README.md");
426 write(&root, "CHANGELOG.md");
427 let report = assess_with(&root, None).unwrap();
428 assert_eq!(report.classification, Classification::Greenfield);
429 assert_eq!(report.documents.count, 2);
430 }
431
432 #[test]
434 fn a_non_markdown_corpus_under_a_doc_root_classifies_brownfield() {
435 let dir = tempfile::tempdir().unwrap();
436 let root = utf8(&dir);
437 write(&root, "docs/guide.adoc");
438 let report = assess_with(&root, None).unwrap();
439 assert_eq!(report.classification, Classification::Brownfield);
440 }
441
442 #[test]
445 fn a_symlinked_document_under_a_doc_root_classifies_brownfield() {
446 let dir = tempfile::tempdir().unwrap();
447 let root = utf8(&dir);
448 write(&root, "elsewhere.md");
449 std::fs::create_dir_all(root.join("docs")).unwrap();
450 std::os::unix::fs::symlink(root.join("elsewhere.md"), root.join("docs/architecture.md"))
451 .unwrap();
452 let report = assess_with(&root, None).unwrap();
453 assert_eq!(report.classification, Classification::Brownfield);
454 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
455 }
456
457 #[test]
461 fn a_broken_doc_root_symlink_classifies_brownfield() {
462 let dir = tempfile::tempdir().unwrap();
463 let root = utf8(&dir);
464 std::os::unix::fs::symlink(root.join("no-such-corpus"), root.join("docs")).unwrap();
465 let report = assess_with(&root, None).unwrap();
466 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
467 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
468 assert_eq!(report.classification, Classification::Brownfield);
469 }
470
471 #[test]
474 fn a_broken_marker_symlink_still_classifies_brownfield() {
475 let dir = tempfile::tempdir().unwrap();
476 let root = utf8(&dir);
477 std::os::unix::fs::symlink(root.join("no-such-config"), root.join("mkdocs.yml")).unwrap();
478 let report = assess_with(&root, None).unwrap();
479 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
480 assert_eq!(report.classification, Classification::Brownfield);
481 }
482
483 #[test]
486 fn a_broken_destination_symlink_reads_as_a_collision() {
487 let dir = tempfile::tempdir().unwrap();
488 let root = utf8(&dir);
489 std::fs::create_dir_all(root.join("docs/specs")).unwrap();
490 std::os::unix::fs::symlink(
491 root.join("gone.md"),
492 root.join("docs/specs/SPEC-docs-format.md"),
493 )
494 .unwrap();
495 let report = assess_with(&root, None).unwrap();
496 assert!(
497 report.collisions["codebase"]
498 .iter()
499 .any(|path| path == "docs/specs/SPEC-docs-format.md")
500 );
501 }
502
503 #[test]
507 fn an_unreadable_entry_propagates_as_io_rather_than_absence() {
508 use std::os::unix::fs::PermissionsExt;
509 let dir = tempfile::tempdir().unwrap();
510 let root = utf8(&dir);
511 std::fs::create_dir_all(root.join("locked")).unwrap();
512 std::fs::write(root.join("locked/mkdocs.yml"), "site_name: x\n").unwrap();
513 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o000))
514 .unwrap();
515 let result = entry_present(&root.join("locked/mkdocs.yml"));
516 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o755))
517 .unwrap();
518 if nix_is_root() {
519 return;
522 }
523 match result {
524 Err(AppError::Io(_)) => {}
525 other => panic!("expected an I/O error, got {other:?}"),
526 }
527 }
528
529 fn nix_is_root() -> bool {
531 std::fs::read_dir("/root").is_ok()
532 }
533
534 #[test]
536 fn a_file_target_refuses_instead_of_classifying() {
537 let dir = tempfile::tempdir().unwrap();
538 let root = utf8(&dir);
539 write(&root, "just-a-file.md");
540 let error = assess_with(&root.join("just-a-file.md"), None).unwrap_err();
541 assert!(matches!(error, AppError::Usage(_)), "{error}");
542 }
543
544 #[test]
547 fn a_symlinked_doc_root_classifies_brownfield() {
548 let dir = tempfile::tempdir().unwrap();
549 let root = utf8(&dir);
550 std::fs::create_dir_all(root.join("external-corpus")).unwrap();
551 std::fs::write(root.join("external-corpus/guide.txt"), "prose\n").unwrap();
552 std::os::unix::fs::symlink(root.join("external-corpus"), root.join("docs")).unwrap();
553 let report = assess_with(&root, None).unwrap();
554 assert_eq!(report.classification, Classification::Brownfield);
555 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
556 }
557
558 #[test]
560 fn a_dotted_metadata_prefix_is_not_metadata() {
561 assert!(!is_root_metadata(Utf8Path::new("README.architecture.md")));
562 let dir = tempfile::tempdir().unwrap();
563 let root = utf8(&dir);
564 write(&root, "README.architecture.md");
565 let report = assess_with(&root, None).unwrap();
566 assert_eq!(report.classification, Classification::NeedsDecision);
567 }
568
569 #[test]
570 fn a_corpus_under_a_doc_root_classifies_brownfield() {
571 let dir = tempfile::tempdir().unwrap();
572 let root = utf8(&dir);
573 write(&root, "docs/architecture.md");
574 let report = assess_with(&root, None).unwrap();
575 assert_eq!(report.classification, Classification::Brownfield);
576 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
577 assert_eq!(
578 report.documents.paths,
579 vec![Utf8PathBuf::from("docs/architecture.md")]
580 );
581 }
582
583 #[test]
584 fn a_methodology_marker_alone_classifies_brownfield() {
585 let dir = tempfile::tempdir().unwrap();
586 let root = utf8(&dir);
587 write(&root, "README.md");
588 write(&root, "mkdocs.yml");
589 let report = assess_with(&root, None).unwrap();
590 assert_eq!(report.classification, Classification::Brownfield);
591 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
592 }
593
594 #[test]
595 fn scattered_markdown_classifies_needs_decision() {
596 let dir = tempfile::tempdir().unwrap();
597 let root = utf8(&dir);
598 write(&root, "notes/design.md");
599 let report = assess_with(&root, None).unwrap();
600 assert_eq!(report.classification, Classification::NeedsDecision);
601 }
602
603 #[test]
604 fn the_docs_scratch_and_pruned_directories_stay_out_of_the_inventory() {
605 let dir = tempfile::tempdir().unwrap();
606 let root = utf8(&dir);
607 write(&root, ".docs-scratch/notes.md");
608 write(&root, "target/build.md");
609 write(&root, "node_modules/pkg/README.md");
610 let report = assess_with(&root, None).unwrap();
611 assert_eq!(report.classification, Classification::Greenfield);
612 assert_eq!(report.documents.count, 0);
613 assert!(report.docs_scratch_present);
614 assert_eq!(report.docs_scratch, DOCS_SCRATCH_CANDIDATE);
615 }
616
617 #[test]
620 fn the_walk_prunes_the_declared_scratch_and_nothing_else() {
621 let dir = tempfile::tempdir().unwrap();
622 let root = utf8(&dir);
623 write(&root, "staging/rewrite.md");
624 write(&root, ".docs-scratch/notes.md");
625 let walked = walk(&root, Utf8Path::new("staging")).unwrap();
626 assert_eq!(
627 walked.documents,
628 vec![Utf8PathBuf::from(".docs-scratch/notes.md")]
629 );
630 }
631
632 #[test]
634 fn a_docs_scratch_outside_the_target_prunes_nothing() {
635 let dir = tempfile::tempdir().unwrap();
636 let root = utf8(&dir);
637 write(&root, "notes/design.md");
638 write(&root, ".docs-scratch/kept.md");
639 let walked = walk(&root, Utf8Path::new("../beside")).unwrap();
640 assert_eq!(walked.documents.len(), 2);
641 }
642}