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(
140 target: &Utf8Path,
141 bundle: &dyn crate::release::ReleaseBundle,
142) -> Result<AssessReport, AppError> {
143 assess_with(target, crate::gates::paths::docs_scratch_variable(), bundle)
144}
145
146pub fn assess_with(
157 target: &Utf8Path,
158 named: Option<Utf8PathBuf>,
159 bundle: &dyn crate::release::ReleaseBundle,
160) -> Result<AssessReport, AppError> {
161 match std::fs::metadata(target) {
166 Ok(metadata) if !metadata.is_dir() => {
167 return Err(AppError::Usage(format!(
168 "target is not a directory: {target}"
169 )));
170 }
171 Ok(_) => {}
172 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
173 Err(error) => return Err(AppError::Io(error)),
174 }
175 let instance = status(target)?;
176 let doc_roots: Vec<String> = DOC_ROOTS
180 .iter()
181 .filter(|root| {
182 let root = target.join(root);
183 root.is_dir() || root.is_symlink()
184 })
185 .map(|root| (*root).to_string())
186 .collect();
187 let scratch = docs_scratch(target, named);
188 let walked = walk(target, &scratch)?;
189 let paths = walked.documents;
190 let methodology_markers = markers(target, &doc_roots)?;
191 let collisions = collisions(target, &bundle.declaration()?)?;
192 let docs_scratch_present = target.join(&scratch).is_dir();
193
194 let populated_doc_roots: Vec<String> = doc_roots
201 .iter()
202 .filter(|root| walked.populated_roots.contains(*root) || target.join(root).is_symlink())
203 .cloned()
204 .collect();
205 let beyond_metadata = paths.iter().any(|path| !is_root_metadata(path));
206 let classification = if !populated_doc_roots.is_empty() || !methodology_markers.is_empty() {
207 Classification::Brownfield
208 } else if beyond_metadata {
209 Classification::NeedsDecision
210 } else {
211 Classification::Greenfield
212 };
213
214 Ok(AssessReport {
215 schema: "sdd.assess/2",
216 target: target.to_owned(),
217 classification,
218 instance,
219 doc_roots,
220 populated_doc_roots,
221 documents: Documents {
222 count: paths.len(),
223 paths,
224 },
225 methodology_markers,
226 collisions,
227 docs_scratch: scratch,
228 docs_scratch_present,
229 })
230}
231
232fn normalized(path: &Utf8Path) -> Utf8PathBuf {
238 let mut out = Utf8PathBuf::new();
239 for component in path.components() {
240 match component {
241 camino::Utf8Component::CurDir => {}
242 camino::Utf8Component::ParentDir => {
243 if matches!(
244 out.components().next_back(),
245 Some(camino::Utf8Component::Normal(_))
246 ) {
247 out.pop();
248 } else {
249 out.push("..");
250 }
251 }
252 other => out.push(other.as_str()),
253 }
254 }
255 out
256}
257
258struct Walked {
260 documents: Vec<Utf8PathBuf>,
262 populated_roots: Vec<String>,
264}
265
266fn walk(target: &Utf8Path, scratch: &Utf8Path) -> Result<Walked, AppError> {
276 let mut documents = Vec::new();
277 let mut populated_roots = Vec::new();
278 let scratch_path = normalized(&target.join(scratch));
283 let walker = walkdir::WalkDir::new(target).into_iter().filter_entry(|e| {
284 let name = e.file_name().to_string_lossy();
285 !(e.depth() > 0
286 && e.file_type().is_dir()
287 && (PRUNED_DIRS.contains(&name.as_ref())
288 || name == crate::domain::paths::INSTANCE_DIR
289 || e.path()
290 .to_str()
291 .is_some_and(|path| normalized(Utf8Path::new(path)) == scratch_path)))
292 });
293 for entry in walker {
294 let entry = entry.map_err(|source| AppError::Io(std::io::Error::from(source)))?;
295 if entry.file_type().is_dir() {
296 continue;
297 }
298 let Some(path) = entry.path().to_str() else {
299 continue;
300 };
301 let relative = Utf8Path::new(path)
302 .strip_prefix(target)
303 .unwrap_or_else(|_| Utf8Path::new(path));
304 if let Some(root) = relative.components().next() {
305 let root = root.as_str().to_string();
306 if relative.components().nth(1).is_some() && !populated_roots.contains(&root) {
307 populated_roots.push(root);
308 }
309 }
310 if entry.file_type().is_file()
311 && relative.extension().is_some_and(|extension| {
312 DOC_EXTENSIONS
313 .iter()
314 .any(|known| extension.eq_ignore_ascii_case(known))
315 })
316 {
317 documents.push(relative.to_owned());
318 }
319 }
320 documents.sort();
321 Ok(Walked {
322 documents,
323 populated_roots,
324 })
325}
326
327fn is_root_metadata(path: &Utf8Path) -> bool {
329 if path
330 .parent()
331 .is_some_and(|parent| !parent.as_str().is_empty())
332 {
333 return false;
334 }
335 let Some(stem) = path.file_stem() else {
336 return false;
337 };
338 let stem = stem.to_ascii_lowercase();
339 ROOT_METADATA.iter().any(|metadata| stem == *metadata)
343}
344
345fn entry_present(path: &Utf8Path) -> Result<bool, AppError> {
353 match path.symlink_metadata() {
354 Ok(_) => Ok(true),
355 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
356 Err(error) => Err(AppError::Io(error)),
357 }
358}
359
360fn markers(target: &Utf8Path, doc_roots: &[String]) -> Result<Vec<String>, AppError> {
363 let mut found = Vec::new();
364 for marker in ROOT_MARKERS {
365 if entry_present(&target.join(marker))? {
366 found.push((*marker).to_string());
367 }
368 }
369 for root in doc_roots {
370 for zone in ["specs", "decisions", "adr", "adrs", "conf.py"] {
371 let candidate = format!("{root}/{zone}");
372 if entry_present(&target.join(&candidate))? {
373 found.push(candidate);
374 }
375 }
376 }
377 Ok(found)
378}
379
380fn collisions(
382 target: &Utf8Path,
383 released: &crate::domain::projection::Declaration,
384) -> Result<BTreeMap<String, Vec<String>>, AppError> {
385 let mut collisions = BTreeMap::new();
386 for id in ProfileId::every() {
387 let Some(profile) = released.profile(id) else {
388 continue;
389 };
390 let mut existing = Vec::new();
391 for projection in profile.managed.iter().chain(profile.adopted) {
392 let destination = resolve_destination(&projection.destination, profile.docs_root);
393 if entry_present(&target.join(&destination))? {
394 existing.push(destination.to_string());
395 }
396 }
397 collisions.insert(id.as_str().to_string(), existing);
398 }
399 Ok(collisions)
400}
401
402#[cfg(test)]
403mod tests {
404 #![allow(
405 clippy::unwrap_used,
406 reason = "a test panics as its failure signal, not as control flow"
407 )]
408
409 use super::*;
410
411 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
412 Utf8PathBuf::from(dir.path().to_str().unwrap())
413 }
414
415 fn write(root: &Utf8Path, relative: &str) {
416 let path = root.join(relative);
417 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
418 std::fs::write(path, "content\n").unwrap();
419 }
420
421 #[test]
422 fn root_metadata_is_recognized_case_insensitively_and_only_at_root() {
423 assert!(is_root_metadata(Utf8Path::new("README.md")));
424 assert!(is_root_metadata(Utf8Path::new("readme.md")));
425 assert!(is_root_metadata(Utf8Path::new("code_of_conduct.md")));
426 assert!(is_root_metadata(Utf8Path::new("CONTRIBUTING.md")));
427 assert!(is_root_metadata(Utf8Path::new("AGENTS.md")));
428 assert!(!is_root_metadata(Utf8Path::new("notes.md")));
429 assert!(!is_root_metadata(Utf8Path::new("sub/README.md")));
430 }
431
432 #[test]
433 fn an_empty_target_classifies_greenfield() {
434 let dir = tempfile::tempdir().unwrap();
435 let root = utf8(&dir);
436 write(&root, "README.md");
437 write(&root, "CHANGELOG.md");
438 let report = assess_with(
439 &root,
440 None,
441 &crate::release::embedded::EmbeddedReleaseBundle::new(),
442 )
443 .unwrap();
444 assert_eq!(report.classification, Classification::Greenfield);
445 assert_eq!(report.documents.count, 2);
446 }
447
448 #[test]
450 fn a_non_markdown_corpus_under_a_doc_root_classifies_brownfield() {
451 let dir = tempfile::tempdir().unwrap();
452 let root = utf8(&dir);
453 write(&root, "docs/guide.adoc");
454 let report = assess_with(
455 &root,
456 None,
457 &crate::release::embedded::EmbeddedReleaseBundle::new(),
458 )
459 .unwrap();
460 assert_eq!(report.classification, Classification::Brownfield);
461 }
462
463 #[test]
466 fn a_symlinked_document_under_a_doc_root_classifies_brownfield() {
467 let dir = tempfile::tempdir().unwrap();
468 let root = utf8(&dir);
469 write(&root, "elsewhere.md");
470 std::fs::create_dir_all(root.join("docs")).unwrap();
471 std::os::unix::fs::symlink(root.join("elsewhere.md"), root.join("docs/architecture.md"))
472 .unwrap();
473 let report = assess_with(
474 &root,
475 None,
476 &crate::release::embedded::EmbeddedReleaseBundle::new(),
477 )
478 .unwrap();
479 assert_eq!(report.classification, Classification::Brownfield);
480 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
481 }
482
483 #[test]
487 fn a_broken_doc_root_symlink_classifies_brownfield() {
488 let dir = tempfile::tempdir().unwrap();
489 let root = utf8(&dir);
490 std::os::unix::fs::symlink(root.join("no-such-corpus"), root.join("docs")).unwrap();
491 let report = assess_with(
492 &root,
493 None,
494 &crate::release::embedded::EmbeddedReleaseBundle::new(),
495 )
496 .unwrap();
497 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
498 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
499 assert_eq!(report.classification, Classification::Brownfield);
500 }
501
502 #[test]
505 fn a_broken_marker_symlink_still_classifies_brownfield() {
506 let dir = tempfile::tempdir().unwrap();
507 let root = utf8(&dir);
508 std::os::unix::fs::symlink(root.join("no-such-config"), root.join("mkdocs.yml")).unwrap();
509 let report = assess_with(
510 &root,
511 None,
512 &crate::release::embedded::EmbeddedReleaseBundle::new(),
513 )
514 .unwrap();
515 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
516 assert_eq!(report.classification, Classification::Brownfield);
517 }
518
519 #[test]
522 fn a_broken_destination_symlink_reads_as_a_collision() {
523 let dir = tempfile::tempdir().unwrap();
524 let root = utf8(&dir);
525 std::fs::create_dir_all(root.join("docs/specs")).unwrap();
526 std::os::unix::fs::symlink(
527 root.join("gone.md"),
528 root.join("docs/specs/SPEC-docs-format.md"),
529 )
530 .unwrap();
531 let report = assess_with(
532 &root,
533 None,
534 &crate::release::embedded::EmbeddedReleaseBundle::new(),
535 )
536 .unwrap();
537 assert!(
538 report.collisions["codebase"]
539 .iter()
540 .any(|path| path == "docs/specs/SPEC-docs-format.md")
541 );
542 }
543
544 #[test]
548 fn an_unreadable_entry_propagates_as_io_rather_than_absence() {
549 use std::os::unix::fs::PermissionsExt;
550 let dir = tempfile::tempdir().unwrap();
551 let root = utf8(&dir);
552 std::fs::create_dir_all(root.join("locked")).unwrap();
553 std::fs::write(root.join("locked/mkdocs.yml"), "site_name: x\n").unwrap();
554 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o000))
555 .unwrap();
556 let result = entry_present(&root.join("locked/mkdocs.yml"));
557 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o755))
558 .unwrap();
559 if nix_is_root() {
560 return;
563 }
564 match result {
565 Err(AppError::Io(_)) => {}
566 other => panic!("expected an I/O error, got {other:?}"),
567 }
568 }
569
570 fn nix_is_root() -> bool {
572 std::fs::read_dir("/root").is_ok()
573 }
574
575 #[test]
577 fn a_file_target_refuses_instead_of_classifying() {
578 let dir = tempfile::tempdir().unwrap();
579 let root = utf8(&dir);
580 write(&root, "just-a-file.md");
581 let error = assess_with(
582 &root.join("just-a-file.md"),
583 None,
584 &crate::release::embedded::EmbeddedReleaseBundle::new(),
585 )
586 .unwrap_err();
587 assert!(matches!(error, AppError::Usage(_)), "{error}");
588 }
589
590 #[test]
593 fn a_symlinked_doc_root_classifies_brownfield() {
594 let dir = tempfile::tempdir().unwrap();
595 let root = utf8(&dir);
596 std::fs::create_dir_all(root.join("external-corpus")).unwrap();
597 std::fs::write(root.join("external-corpus/guide.txt"), "prose\n").unwrap();
598 std::os::unix::fs::symlink(root.join("external-corpus"), root.join("docs")).unwrap();
599 let report = assess_with(
600 &root,
601 None,
602 &crate::release::embedded::EmbeddedReleaseBundle::new(),
603 )
604 .unwrap();
605 assert_eq!(report.classification, Classification::Brownfield);
606 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
607 }
608
609 #[test]
611 fn a_dotted_metadata_prefix_is_not_metadata() {
612 assert!(!is_root_metadata(Utf8Path::new("README.architecture.md")));
613 let dir = tempfile::tempdir().unwrap();
614 let root = utf8(&dir);
615 write(&root, "README.architecture.md");
616 let report = assess_with(
617 &root,
618 None,
619 &crate::release::embedded::EmbeddedReleaseBundle::new(),
620 )
621 .unwrap();
622 assert_eq!(report.classification, Classification::NeedsDecision);
623 }
624
625 #[test]
626 fn a_corpus_under_a_doc_root_classifies_brownfield() {
627 let dir = tempfile::tempdir().unwrap();
628 let root = utf8(&dir);
629 write(&root, "docs/architecture.md");
630 let report = assess_with(
631 &root,
632 None,
633 &crate::release::embedded::EmbeddedReleaseBundle::new(),
634 )
635 .unwrap();
636 assert_eq!(report.classification, Classification::Brownfield);
637 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
638 assert_eq!(
639 report.documents.paths,
640 vec![Utf8PathBuf::from("docs/architecture.md")]
641 );
642 }
643
644 #[test]
645 fn a_methodology_marker_alone_classifies_brownfield() {
646 let dir = tempfile::tempdir().unwrap();
647 let root = utf8(&dir);
648 write(&root, "README.md");
649 write(&root, "mkdocs.yml");
650 let report = assess_with(
651 &root,
652 None,
653 &crate::release::embedded::EmbeddedReleaseBundle::new(),
654 )
655 .unwrap();
656 assert_eq!(report.classification, Classification::Brownfield);
657 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
658 }
659
660 #[test]
661 fn scattered_markdown_classifies_needs_decision() {
662 let dir = tempfile::tempdir().unwrap();
663 let root = utf8(&dir);
664 write(&root, "notes/design.md");
665 let report = assess_with(
666 &root,
667 None,
668 &crate::release::embedded::EmbeddedReleaseBundle::new(),
669 )
670 .unwrap();
671 assert_eq!(report.classification, Classification::NeedsDecision);
672 }
673
674 #[test]
675 fn the_docs_scratch_and_pruned_directories_stay_out_of_the_inventory() {
676 let dir = tempfile::tempdir().unwrap();
677 let root = utf8(&dir);
678 write(&root, ".docs-scratch/notes.md");
679 write(&root, "target/build.md");
680 write(&root, "node_modules/pkg/README.md");
681 let report = assess_with(
682 &root,
683 None,
684 &crate::release::embedded::EmbeddedReleaseBundle::new(),
685 )
686 .unwrap();
687 assert_eq!(report.classification, Classification::Greenfield);
688 assert_eq!(report.documents.count, 0);
689 assert!(report.docs_scratch_present);
690 assert_eq!(report.docs_scratch, DOCS_SCRATCH_CANDIDATE);
691 }
692
693 #[test]
696 fn the_walk_prunes_the_declared_scratch_and_nothing_else() {
697 let dir = tempfile::tempdir().unwrap();
698 let root = utf8(&dir);
699 write(&root, "staging/rewrite.md");
700 write(&root, ".docs-scratch/notes.md");
701 let walked = walk(&root, Utf8Path::new("staging")).unwrap();
702 assert_eq!(
703 walked.documents,
704 vec![Utf8PathBuf::from(".docs-scratch/notes.md")]
705 );
706 }
707
708 #[test]
710 fn a_docs_scratch_outside_the_target_prunes_nothing() {
711 let dir = tempfile::tempdir().unwrap();
712 let root = utf8(&dir);
713 write(&root, "notes/design.md");
714 write(&root, ".docs-scratch/kept.md");
715 let walked = walk(&root, Utf8Path::new("../beside")).unwrap();
716 assert_eq!(walked.documents.len(), 2);
717 }
718}