spec_driven_docs/services/
assess.rs1use 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 draft_present: bool,
110}
111
112pub fn assess(target: &Utf8Path) -> Result<AssessReport, AppError> {
121 match std::fs::metadata(target) {
126 Ok(metadata) if !metadata.is_dir() => {
127 return Err(AppError::Usage(format!(
128 "target is not a directory: {target}"
129 )));
130 }
131 Ok(_) => {}
132 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
133 Err(error) => return Err(AppError::Io(error)),
134 }
135 let instance = status(target)?;
136 let doc_roots: Vec<String> = DOC_ROOTS
140 .iter()
141 .filter(|root| {
142 let root = target.join(root);
143 root.is_dir() || root.is_symlink()
144 })
145 .map(|root| (*root).to_string())
146 .collect();
147 let walked = walk(target)?;
148 let paths = walked.documents;
149 let methodology_markers = markers(target, &doc_roots)?;
150 let collisions = collisions(target)?;
151 let draft_present = target.join(".draft").is_dir();
152
153 let populated_doc_roots: Vec<String> = doc_roots
160 .iter()
161 .filter(|root| walked.populated_roots.contains(*root) || target.join(root).is_symlink())
162 .cloned()
163 .collect();
164 let beyond_metadata = paths.iter().any(|path| !is_root_metadata(path));
165 let classification = if !populated_doc_roots.is_empty() || !methodology_markers.is_empty() {
166 Classification::Brownfield
167 } else if beyond_metadata {
168 Classification::NeedsDecision
169 } else {
170 Classification::Greenfield
171 };
172
173 Ok(AssessReport {
174 schema: "sdd.assess/1",
175 target: target.to_owned(),
176 classification,
177 instance,
178 doc_roots,
179 populated_doc_roots,
180 documents: Documents {
181 count: paths.len(),
182 paths,
183 },
184 methodology_markers,
185 collisions,
186 draft_present,
187 })
188}
189
190struct Walked {
192 documents: Vec<Utf8PathBuf>,
194 populated_roots: Vec<String>,
196}
197
198fn walk(target: &Utf8Path) -> Result<Walked, AppError> {
203 let mut documents = Vec::new();
204 let mut populated_roots = Vec::new();
205 let walker = walkdir::WalkDir::new(target).into_iter().filter_entry(|e| {
206 let name = e.file_name().to_string_lossy();
207 !(e.depth() > 0
208 && e.file_type().is_dir()
209 && (PRUNED_DIRS.contains(&name.as_ref())
210 || name == ".draft"
211 || name == ".spec-driven-docs"))
212 });
213 for entry in walker {
214 let entry = entry.map_err(|source| AppError::Io(std::io::Error::from(source)))?;
215 if entry.file_type().is_dir() {
216 continue;
217 }
218 let Some(path) = entry.path().to_str() else {
219 continue;
220 };
221 let relative = Utf8Path::new(path)
222 .strip_prefix(target)
223 .unwrap_or_else(|_| Utf8Path::new(path));
224 if let Some(root) = relative.components().next() {
225 let root = root.as_str().to_string();
226 if relative.components().nth(1).is_some() && !populated_roots.contains(&root) {
227 populated_roots.push(root);
228 }
229 }
230 if entry.file_type().is_file()
231 && relative.extension().is_some_and(|extension| {
232 DOC_EXTENSIONS
233 .iter()
234 .any(|known| extension.eq_ignore_ascii_case(known))
235 })
236 {
237 documents.push(relative.to_owned());
238 }
239 }
240 documents.sort();
241 Ok(Walked {
242 documents,
243 populated_roots,
244 })
245}
246
247fn is_root_metadata(path: &Utf8Path) -> bool {
249 if path
250 .parent()
251 .is_some_and(|parent| !parent.as_str().is_empty())
252 {
253 return false;
254 }
255 let Some(stem) = path.file_stem() else {
256 return false;
257 };
258 let stem = stem.to_ascii_lowercase();
259 ROOT_METADATA.iter().any(|metadata| stem == *metadata)
263}
264
265fn entry_present(path: &Utf8Path) -> Result<bool, AppError> {
273 match path.symlink_metadata() {
274 Ok(_) => Ok(true),
275 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
276 Err(error) => Err(AppError::Io(error)),
277 }
278}
279
280fn markers(target: &Utf8Path, doc_roots: &[String]) -> Result<Vec<String>, AppError> {
283 let mut found = Vec::new();
284 for marker in ROOT_MARKERS {
285 if entry_present(&target.join(marker))? {
286 found.push((*marker).to_string());
287 }
288 }
289 for root in doc_roots {
290 for zone in ["specs", "decisions", "adr", "adrs", "conf.py"] {
291 let candidate = format!("{root}/{zone}");
292 if entry_present(&target.join(&candidate))? {
293 found.push(candidate);
294 }
295 }
296 }
297 Ok(found)
298}
299
300fn collisions(target: &Utf8Path) -> Result<BTreeMap<String, Vec<String>>, AppError> {
302 let mut collisions = BTreeMap::new();
303 for id in [ProfileId::Codebase, ProfileId::KnowledgeBase] {
304 let profile = id.profile();
305 let mut existing = Vec::new();
306 for projection in profile.managed.iter().chain(profile.adopted) {
307 let destination = resolve_destination(projection.destination, profile.docs_root);
308 if entry_present(&target.join(&destination))? {
309 existing.push(destination.to_string());
310 }
311 }
312 collisions.insert(id.as_str().to_string(), existing);
313 }
314 Ok(collisions)
315}
316
317#[cfg(test)]
318mod tests {
319 #![allow(clippy::unwrap_used)]
320
321 use super::*;
322
323 fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
324 Utf8PathBuf::from(dir.path().to_str().unwrap())
325 }
326
327 fn write(root: &Utf8Path, relative: &str) {
328 let path = root.join(relative);
329 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
330 std::fs::write(path, "content\n").unwrap();
331 }
332
333 #[test]
334 fn root_metadata_is_recognized_case_insensitively_and_only_at_root() {
335 assert!(is_root_metadata(Utf8Path::new("README.md")));
336 assert!(is_root_metadata(Utf8Path::new("readme.md")));
337 assert!(is_root_metadata(Utf8Path::new("code_of_conduct.md")));
338 assert!(is_root_metadata(Utf8Path::new("CONTRIBUTING.md")));
339 assert!(is_root_metadata(Utf8Path::new("AGENTS.md")));
340 assert!(!is_root_metadata(Utf8Path::new("notes.md")));
341 assert!(!is_root_metadata(Utf8Path::new("sub/README.md")));
342 }
343
344 #[test]
345 fn an_empty_target_classifies_greenfield() {
346 let dir = tempfile::tempdir().unwrap();
347 let root = utf8(&dir);
348 write(&root, "README.md");
349 write(&root, "CHANGELOG.md");
350 let report = assess(&root).unwrap();
351 assert_eq!(report.classification, Classification::Greenfield);
352 assert_eq!(report.documents.count, 2);
353 }
354
355 #[test]
357 fn a_non_markdown_corpus_under_a_doc_root_classifies_brownfield() {
358 let dir = tempfile::tempdir().unwrap();
359 let root = utf8(&dir);
360 write(&root, "docs/guide.adoc");
361 let report = assess(&root).unwrap();
362 assert_eq!(report.classification, Classification::Brownfield);
363 }
364
365 #[test]
368 fn a_symlinked_document_under_a_doc_root_classifies_brownfield() {
369 let dir = tempfile::tempdir().unwrap();
370 let root = utf8(&dir);
371 write(&root, "elsewhere.md");
372 std::fs::create_dir_all(root.join("docs")).unwrap();
373 std::os::unix::fs::symlink(root.join("elsewhere.md"), root.join("docs/architecture.md"))
374 .unwrap();
375 let report = assess(&root).unwrap();
376 assert_eq!(report.classification, Classification::Brownfield);
377 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
378 }
379
380 #[test]
384 fn a_broken_doc_root_symlink_classifies_brownfield() {
385 let dir = tempfile::tempdir().unwrap();
386 let root = utf8(&dir);
387 std::os::unix::fs::symlink(root.join("no-such-corpus"), root.join("docs")).unwrap();
388 let report = assess(&root).unwrap();
389 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
390 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
391 assert_eq!(report.classification, Classification::Brownfield);
392 }
393
394 #[test]
397 fn a_broken_marker_symlink_still_classifies_brownfield() {
398 let dir = tempfile::tempdir().unwrap();
399 let root = utf8(&dir);
400 std::os::unix::fs::symlink(root.join("no-such-config"), root.join("mkdocs.yml")).unwrap();
401 let report = assess(&root).unwrap();
402 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
403 assert_eq!(report.classification, Classification::Brownfield);
404 }
405
406 #[test]
409 fn a_broken_destination_symlink_reads_as_a_collision() {
410 let dir = tempfile::tempdir().unwrap();
411 let root = utf8(&dir);
412 std::fs::create_dir_all(root.join("docs/specs")).unwrap();
413 std::os::unix::fs::symlink(
414 root.join("gone.md"),
415 root.join("docs/specs/SPEC-docs-format.md"),
416 )
417 .unwrap();
418 let report = assess(&root).unwrap();
419 assert!(
420 report.collisions["codebase"]
421 .iter()
422 .any(|path| path == "docs/specs/SPEC-docs-format.md")
423 );
424 }
425
426 #[test]
430 fn an_unreadable_entry_propagates_as_io_rather_than_absence() {
431 use std::os::unix::fs::PermissionsExt;
432 let dir = tempfile::tempdir().unwrap();
433 let root = utf8(&dir);
434 std::fs::create_dir_all(root.join("locked")).unwrap();
435 std::fs::write(root.join("locked/mkdocs.yml"), "site_name: x\n").unwrap();
436 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o000))
437 .unwrap();
438 let result = entry_present(&root.join("locked/mkdocs.yml"));
439 std::fs::set_permissions(root.join("locked"), std::fs::Permissions::from_mode(0o755))
440 .unwrap();
441 if nix_is_root() {
442 return;
445 }
446 match result {
447 Err(AppError::Io(_)) => {}
448 other => panic!("expected an I/O error, got {other:?}"),
449 }
450 }
451
452 fn nix_is_root() -> bool {
454 std::fs::read_dir("/root").is_ok()
455 }
456
457 #[test]
459 fn a_file_target_refuses_instead_of_classifying() {
460 let dir = tempfile::tempdir().unwrap();
461 let root = utf8(&dir);
462 write(&root, "just-a-file.md");
463 let error = assess(&root.join("just-a-file.md")).unwrap_err();
464 assert!(matches!(error, AppError::Usage(_)), "{error}");
465 }
466
467 #[test]
470 fn a_symlinked_doc_root_classifies_brownfield() {
471 let dir = tempfile::tempdir().unwrap();
472 let root = utf8(&dir);
473 std::fs::create_dir_all(root.join("external-corpus")).unwrap();
474 std::fs::write(root.join("external-corpus/guide.txt"), "prose\n").unwrap();
475 std::os::unix::fs::symlink(root.join("external-corpus"), root.join("docs")).unwrap();
476 let report = assess(&root).unwrap();
477 assert_eq!(report.classification, Classification::Brownfield);
478 assert_eq!(report.populated_doc_roots, vec!["docs".to_string()]);
479 }
480
481 #[test]
483 fn a_dotted_metadata_prefix_is_not_metadata() {
484 assert!(!is_root_metadata(Utf8Path::new("README.architecture.md")));
485 let dir = tempfile::tempdir().unwrap();
486 let root = utf8(&dir);
487 write(&root, "README.architecture.md");
488 let report = assess(&root).unwrap();
489 assert_eq!(report.classification, Classification::NeedsDecision);
490 }
491
492 #[test]
493 fn a_corpus_under_a_doc_root_classifies_brownfield() {
494 let dir = tempfile::tempdir().unwrap();
495 let root = utf8(&dir);
496 write(&root, "docs/architecture.md");
497 let report = assess(&root).unwrap();
498 assert_eq!(report.classification, Classification::Brownfield);
499 assert_eq!(report.doc_roots, vec!["docs".to_string()]);
500 assert_eq!(
501 report.documents.paths,
502 vec![Utf8PathBuf::from("docs/architecture.md")]
503 );
504 }
505
506 #[test]
507 fn a_methodology_marker_alone_classifies_brownfield() {
508 let dir = tempfile::tempdir().unwrap();
509 let root = utf8(&dir);
510 write(&root, "README.md");
511 write(&root, "mkdocs.yml");
512 let report = assess(&root).unwrap();
513 assert_eq!(report.classification, Classification::Brownfield);
514 assert_eq!(report.methodology_markers, vec!["mkdocs.yml".to_string()]);
515 }
516
517 #[test]
518 fn scattered_markdown_classifies_needs_decision() {
519 let dir = tempfile::tempdir().unwrap();
520 let root = utf8(&dir);
521 write(&root, "notes/design.md");
522 let report = assess(&root).unwrap();
523 assert_eq!(report.classification, Classification::NeedsDecision);
524 }
525
526 #[test]
527 fn the_workshop_and_pruned_directories_stay_out_of_the_inventory() {
528 let dir = tempfile::tempdir().unwrap();
529 let root = utf8(&dir);
530 write(&root, ".draft/scratch.md");
531 write(&root, "target/build.md");
532 write(&root, "node_modules/pkg/README.md");
533 let report = assess(&root).unwrap();
534 assert_eq!(report.classification, Classification::Greenfield);
535 assert_eq!(report.documents.count, 0);
536 assert!(report.draft_present);
537 }
538}