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, &crate::domain::profile::DECLARATION)?;
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 == crate::domain::paths::INSTANCE_DIR
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(
378 target: &Utf8Path,
379 released: &crate::domain::projection::Declaration,
380) -> Result<BTreeMap<String, Vec<String>>, AppError> {
381 let mut collisions = BTreeMap::new();
382 for id in ProfileId::every() {
383 let Some(profile) = released.profile(id) else {
384 continue;
385 };
386 let mut existing = Vec::new();
387 for projection in profile.managed.iter().chain(profile.adopted) {
388 let destination = resolve_destination(&projection.destination, profile.docs_root);
389 if entry_present(&target.join(&destination))? {
390 existing.push(destination.to_string());
391 }
392 }
393 collisions.insert(id.as_str().to_string(), existing);
394 }
395 Ok(collisions)
396}
397
398#[cfg(test)]
399mod tests {
400 #![allow(
401 clippy::unwrap_used,
402 reason = "a test panics as its failure signal, not as control flow"
403 )]
404
405 use super::*;
406
407 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
408 Utf8PathBuf::from(dir.path().to_str().unwrap())
409 }
410
411 fn write(root: &Utf8Path, relative: &str) {
412 let path = root.join(relative);
413 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
414 std::fs::write(path, "content\n").unwrap();
415 }
416
417 #[test]
418 fn root_metadata_is_recognized_case_insensitively_and_only_at_root() {
419 assert!(is_root_metadata(Utf8Path::new("README.md")));
420 assert!(is_root_metadata(Utf8Path::new("readme.md")));
421 assert!(is_root_metadata(Utf8Path::new("code_of_conduct.md")));
422 assert!(is_root_metadata(Utf8Path::new("CONTRIBUTING.md")));
423 assert!(is_root_metadata(Utf8Path::new("AGENTS.md")));
424 assert!(!is_root_metadata(Utf8Path::new("notes.md")));
425 assert!(!is_root_metadata(Utf8Path::new("sub/README.md")));
426 }
427
428 #[test]
429 fn an_empty_target_classifies_greenfield() {
430 let dir = tempfile::tempdir().unwrap();
431 let root = utf8(&dir);
432 write(&root, "README.md");
433 write(&root, "CHANGELOG.md");
434 let report = assess_with(&root, None).unwrap();
435 assert_eq!(report.classification, Classification::Greenfield);
436 assert_eq!(report.documents.count, 2);
437 }
438
439 #[test]
441 fn a_non_markdown_corpus_under_a_doc_root_classifies_brownfield() {
442 let dir = tempfile::tempdir().unwrap();
443 let root = utf8(&dir);
444 write(&root, "docs/guide.adoc");
445 let report = assess_with(&root, None).unwrap();
446 assert_eq!(report.classification, Classification::Brownfield);
447 }
448
449 #[test]
452 fn a_symlinked_document_under_a_doc_root_classifies_brownfield() {
453 let dir = tempfile::tempdir().unwrap();
454 let root = utf8(&dir);
455 write(&root, "elsewhere.md");
456 std::fs::create_dir_all(root.join("docs")).unwrap();
457 std::os::unix::fs::symlink(root.join("elsewhere.md"), root.join("docs/architecture.md"))
458 .unwrap();
459 let report = assess_with(&root, None).unwrap();
460 assert_eq!(report.classification, Classification::Brownfield);
461 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
462 }
463
464 #[test]
468 fn a_broken_doc_root_symlink_classifies_brownfield() {
469 let dir = tempfile::tempdir().unwrap();
470 let root = utf8(&dir);
471 std::os::unix::fs::symlink(root.join("no-such-corpus"), root.join("docs")).unwrap();
472 let report = assess_with(&root, None).unwrap();
473 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
474 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
475 assert_eq!(report.classification, Classification::Brownfield);
476 }
477
478 #[test]
481 fn a_broken_marker_symlink_still_classifies_brownfield() {
482 let dir = tempfile::tempdir().unwrap();
483 let root = utf8(&dir);
484 std::os::unix::fs::symlink(root.join("no-such-config"), root.join("mkdocs.yml")).unwrap();
485 let report = assess_with(&root, None).unwrap();
486 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
487 assert_eq!(report.classification, Classification::Brownfield);
488 }
489
490 #[test]
493 fn a_broken_destination_symlink_reads_as_a_collision() {
494 let dir = tempfile::tempdir().unwrap();
495 let root = utf8(&dir);
496 std::fs::create_dir_all(root.join("docs/specs")).unwrap();
497 std::os::unix::fs::symlink(
498 root.join("gone.md"),
499 root.join("docs/specs/SPEC-docs-format.md"),
500 )
501 .unwrap();
502 let report = assess_with(&root, None).unwrap();
503 assert!(
504 report.collisions["codebase"]
505 .iter()
506 .any(|path| path == "docs/specs/SPEC-docs-format.md")
507 );
508 }
509
510 #[test]
514 fn an_unreadable_entry_propagates_as_io_rather_than_absence() {
515 use std::os::unix::fs::PermissionsExt;
516 let dir = tempfile::tempdir().unwrap();
517 let root = utf8(&dir);
518 std::fs::create_dir_all(root.join("locked")).unwrap();
519 std::fs::write(root.join("locked/mkdocs.yml"), "site_name: x\n").unwrap();
520 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o000))
521 .unwrap();
522 let result = entry_present(&root.join("locked/mkdocs.yml"));
523 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o755))
524 .unwrap();
525 if nix_is_root() {
526 return;
529 }
530 match result {
531 Err(AppError::Io(_)) => {}
532 other => panic!("expected an I/O error, got {other:?}"),
533 }
534 }
535
536 fn nix_is_root() -> bool {
538 std::fs::read_dir("/root").is_ok()
539 }
540
541 #[test]
543 fn a_file_target_refuses_instead_of_classifying() {
544 let dir = tempfile::tempdir().unwrap();
545 let root = utf8(&dir);
546 write(&root, "just-a-file.md");
547 let error = assess_with(&root.join("just-a-file.md"), None).unwrap_err();
548 assert!(matches!(error, AppError::Usage(_)), "{error}");
549 }
550
551 #[test]
554 fn a_symlinked_doc_root_classifies_brownfield() {
555 let dir = tempfile::tempdir().unwrap();
556 let root = utf8(&dir);
557 std::fs::create_dir_all(root.join("external-corpus")).unwrap();
558 std::fs::write(root.join("external-corpus/guide.txt"), "prose\n").unwrap();
559 std::os::unix::fs::symlink(root.join("external-corpus"), root.join("docs")).unwrap();
560 let report = assess_with(&root, None).unwrap();
561 assert_eq!(report.classification, Classification::Brownfield);
562 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
563 }
564
565 #[test]
567 fn a_dotted_metadata_prefix_is_not_metadata() {
568 assert!(!is_root_metadata(Utf8Path::new("README.architecture.md")));
569 let dir = tempfile::tempdir().unwrap();
570 let root = utf8(&dir);
571 write(&root, "README.architecture.md");
572 let report = assess_with(&root, None).unwrap();
573 assert_eq!(report.classification, Classification::NeedsDecision);
574 }
575
576 #[test]
577 fn a_corpus_under_a_doc_root_classifies_brownfield() {
578 let dir = tempfile::tempdir().unwrap();
579 let root = utf8(&dir);
580 write(&root, "docs/architecture.md");
581 let report = assess_with(&root, None).unwrap();
582 assert_eq!(report.classification, Classification::Brownfield);
583 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
584 assert_eq!(
585 report.documents.paths,
586 vec![Utf8PathBuf::from("docs/architecture.md")]
587 );
588 }
589
590 #[test]
591 fn a_methodology_marker_alone_classifies_brownfield() {
592 let dir = tempfile::tempdir().unwrap();
593 let root = utf8(&dir);
594 write(&root, "README.md");
595 write(&root, "mkdocs.yml");
596 let report = assess_with(&root, None).unwrap();
597 assert_eq!(report.classification, Classification::Brownfield);
598 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
599 }
600
601 #[test]
602 fn scattered_markdown_classifies_needs_decision() {
603 let dir = tempfile::tempdir().unwrap();
604 let root = utf8(&dir);
605 write(&root, "notes/design.md");
606 let report = assess_with(&root, None).unwrap();
607 assert_eq!(report.classification, Classification::NeedsDecision);
608 }
609
610 #[test]
611 fn the_docs_scratch_and_pruned_directories_stay_out_of_the_inventory() {
612 let dir = tempfile::tempdir().unwrap();
613 let root = utf8(&dir);
614 write(&root, ".docs-scratch/notes.md");
615 write(&root, "target/build.md");
616 write(&root, "node_modules/pkg/README.md");
617 let report = assess_with(&root, None).unwrap();
618 assert_eq!(report.classification, Classification::Greenfield);
619 assert_eq!(report.documents.count, 0);
620 assert!(report.docs_scratch_present);
621 assert_eq!(report.docs_scratch, DOCS_SCRATCH_CANDIDATE);
622 }
623
624 #[test]
627 fn the_walk_prunes_the_declared_scratch_and_nothing_else() {
628 let dir = tempfile::tempdir().unwrap();
629 let root = utf8(&dir);
630 write(&root, "staging/rewrite.md");
631 write(&root, ".docs-scratch/notes.md");
632 let walked = walk(&root, Utf8Path::new("staging")).unwrap();
633 assert_eq!(
634 walked.documents,
635 vec![Utf8PathBuf::from(".docs-scratch/notes.md")]
636 );
637 }
638
639 #[test]
641 fn a_docs_scratch_outside_the_target_prunes_nothing() {
642 let dir = tempfile::tempdir().unwrap();
643 let root = utf8(&dir);
644 write(&root, "notes/design.md");
645 write(&root, ".docs-scratch/kept.md");
646 let walked = walk(&root, Utf8Path::new("../beside")).unwrap();
647 assert_eq!(walked.documents.len(), 2);
648 }
649}