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(
396 clippy::unwrap_used,
397 reason = "a test panics as its failure signal, not as control flow"
398 )]
399
400 use super::*;
401
402 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
403 Utf8PathBuf::from(dir.path().to_str().unwrap())
404 }
405
406 fn write(root: &Utf8Path, relative: &str) {
407 let path = root.join(relative);
408 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
409 std::fs::write(path, "content\n").unwrap();
410 }
411
412 #[test]
413 fn root_metadata_is_recognized_case_insensitively_and_only_at_root() {
414 assert!(is_root_metadata(Utf8Path::new("README.md")));
415 assert!(is_root_metadata(Utf8Path::new("readme.md")));
416 assert!(is_root_metadata(Utf8Path::new("code_of_conduct.md")));
417 assert!(is_root_metadata(Utf8Path::new("CONTRIBUTING.md")));
418 assert!(is_root_metadata(Utf8Path::new("AGENTS.md")));
419 assert!(!is_root_metadata(Utf8Path::new("notes.md")));
420 assert!(!is_root_metadata(Utf8Path::new("sub/README.md")));
421 }
422
423 #[test]
424 fn an_empty_target_classifies_greenfield() {
425 let dir = tempfile::tempdir().unwrap();
426 let root = utf8(&dir);
427 write(&root, "README.md");
428 write(&root, "CHANGELOG.md");
429 let report = assess_with(&root, None).unwrap();
430 assert_eq!(report.classification, Classification::Greenfield);
431 assert_eq!(report.documents.count, 2);
432 }
433
434 #[test]
436 fn a_non_markdown_corpus_under_a_doc_root_classifies_brownfield() {
437 let dir = tempfile::tempdir().unwrap();
438 let root = utf8(&dir);
439 write(&root, "docs/guide.adoc");
440 let report = assess_with(&root, None).unwrap();
441 assert_eq!(report.classification, Classification::Brownfield);
442 }
443
444 #[test]
447 fn a_symlinked_document_under_a_doc_root_classifies_brownfield() {
448 let dir = tempfile::tempdir().unwrap();
449 let root = utf8(&dir);
450 write(&root, "elsewhere.md");
451 std::fs::create_dir_all(root.join("docs")).unwrap();
452 std::os::unix::fs::symlink(root.join("elsewhere.md"), root.join("docs/architecture.md"))
453 .unwrap();
454 let report = assess_with(&root, None).unwrap();
455 assert_eq!(report.classification, Classification::Brownfield);
456 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
457 }
458
459 #[test]
463 fn a_broken_doc_root_symlink_classifies_brownfield() {
464 let dir = tempfile::tempdir().unwrap();
465 let root = utf8(&dir);
466 std::os::unix::fs::symlink(root.join("no-such-corpus"), root.join("docs")).unwrap();
467 let report = assess_with(&root, None).unwrap();
468 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
469 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
470 assert_eq!(report.classification, Classification::Brownfield);
471 }
472
473 #[test]
476 fn a_broken_marker_symlink_still_classifies_brownfield() {
477 let dir = tempfile::tempdir().unwrap();
478 let root = utf8(&dir);
479 std::os::unix::fs::symlink(root.join("no-such-config"), root.join("mkdocs.yml")).unwrap();
480 let report = assess_with(&root, None).unwrap();
481 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
482 assert_eq!(report.classification, Classification::Brownfield);
483 }
484
485 #[test]
488 fn a_broken_destination_symlink_reads_as_a_collision() {
489 let dir = tempfile::tempdir().unwrap();
490 let root = utf8(&dir);
491 std::fs::create_dir_all(root.join("docs/specs")).unwrap();
492 std::os::unix::fs::symlink(
493 root.join("gone.md"),
494 root.join("docs/specs/SPEC-docs-format.md"),
495 )
496 .unwrap();
497 let report = assess_with(&root, None).unwrap();
498 assert!(
499 report.collisions["codebase"]
500 .iter()
501 .any(|path| path == "docs/specs/SPEC-docs-format.md")
502 );
503 }
504
505 #[test]
509 fn an_unreadable_entry_propagates_as_io_rather_than_absence() {
510 use std::os::unix::fs::PermissionsExt;
511 let dir = tempfile::tempdir().unwrap();
512 let root = utf8(&dir);
513 std::fs::create_dir_all(root.join("locked")).unwrap();
514 std::fs::write(root.join("locked/mkdocs.yml"), "site_name: x\n").unwrap();
515 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o000))
516 .unwrap();
517 let result = entry_present(&root.join("locked/mkdocs.yml"));
518 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o755))
519 .unwrap();
520 if nix_is_root() {
521 return;
524 }
525 match result {
526 Err(AppError::Io(_)) => {}
527 other => panic!("expected an I/O error, got {other:?}"),
528 }
529 }
530
531 fn nix_is_root() -> bool {
533 std::fs::read_dir("/root").is_ok()
534 }
535
536 #[test]
538 fn a_file_target_refuses_instead_of_classifying() {
539 let dir = tempfile::tempdir().unwrap();
540 let root = utf8(&dir);
541 write(&root, "just-a-file.md");
542 let error = assess_with(&root.join("just-a-file.md"), None).unwrap_err();
543 assert!(matches!(error, AppError::Usage(_)), "{error}");
544 }
545
546 #[test]
549 fn a_symlinked_doc_root_classifies_brownfield() {
550 let dir = tempfile::tempdir().unwrap();
551 let root = utf8(&dir);
552 std::fs::create_dir_all(root.join("external-corpus")).unwrap();
553 std::fs::write(root.join("external-corpus/guide.txt"), "prose\n").unwrap();
554 std::os::unix::fs::symlink(root.join("external-corpus"), root.join("docs")).unwrap();
555 let report = assess_with(&root, None).unwrap();
556 assert_eq!(report.classification, Classification::Brownfield);
557 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
558 }
559
560 #[test]
562 fn a_dotted_metadata_prefix_is_not_metadata() {
563 assert!(!is_root_metadata(Utf8Path::new("README.architecture.md")));
564 let dir = tempfile::tempdir().unwrap();
565 let root = utf8(&dir);
566 write(&root, "README.architecture.md");
567 let report = assess_with(&root, None).unwrap();
568 assert_eq!(report.classification, Classification::NeedsDecision);
569 }
570
571 #[test]
572 fn a_corpus_under_a_doc_root_classifies_brownfield() {
573 let dir = tempfile::tempdir().unwrap();
574 let root = utf8(&dir);
575 write(&root, "docs/architecture.md");
576 let report = assess_with(&root, None).unwrap();
577 assert_eq!(report.classification, Classification::Brownfield);
578 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
579 assert_eq!(
580 report.documents.paths,
581 vec![Utf8PathBuf::from("docs/architecture.md")]
582 );
583 }
584
585 #[test]
586 fn a_methodology_marker_alone_classifies_brownfield() {
587 let dir = tempfile::tempdir().unwrap();
588 let root = utf8(&dir);
589 write(&root, "README.md");
590 write(&root, "mkdocs.yml");
591 let report = assess_with(&root, None).unwrap();
592 assert_eq!(report.classification, Classification::Brownfield);
593 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
594 }
595
596 #[test]
597 fn scattered_markdown_classifies_needs_decision() {
598 let dir = tempfile::tempdir().unwrap();
599 let root = utf8(&dir);
600 write(&root, "notes/design.md");
601 let report = assess_with(&root, None).unwrap();
602 assert_eq!(report.classification, Classification::NeedsDecision);
603 }
604
605 #[test]
606 fn the_docs_scratch_and_pruned_directories_stay_out_of_the_inventory() {
607 let dir = tempfile::tempdir().unwrap();
608 let root = utf8(&dir);
609 write(&root, ".docs-scratch/notes.md");
610 write(&root, "target/build.md");
611 write(&root, "node_modules/pkg/README.md");
612 let report = assess_with(&root, None).unwrap();
613 assert_eq!(report.classification, Classification::Greenfield);
614 assert_eq!(report.documents.count, 0);
615 assert!(report.docs_scratch_present);
616 assert_eq!(report.docs_scratch, DOCS_SCRATCH_CANDIDATE);
617 }
618
619 #[test]
622 fn the_walk_prunes_the_declared_scratch_and_nothing_else() {
623 let dir = tempfile::tempdir().unwrap();
624 let root = utf8(&dir);
625 write(&root, "staging/rewrite.md");
626 write(&root, ".docs-scratch/notes.md");
627 let walked = walk(&root, Utf8Path::new("staging")).unwrap();
628 assert_eq!(
629 walked.documents,
630 vec![Utf8PathBuf::from(".docs-scratch/notes.md")]
631 );
632 }
633
634 #[test]
636 fn a_docs_scratch_outside_the_target_prunes_nothing() {
637 let dir = tempfile::tempdir().unwrap();
638 let root = utf8(&dir);
639 write(&root, "notes/design.md");
640 write(&root, ".docs-scratch/kept.md");
641 let walked = walk(&root, Utf8Path::new("../beside")).unwrap();
642 assert_eq!(walked.documents.len(), 2);
643 }
644}