Skip to main content

spec_driven_docs/services/
assess.rs

1//! Classify a target repository before anything lands.
2//!
3//! The assessment is read-only evidence plus one classification computed
4//! from it by an explicit rule: `greenfield` when the project has written
5//! no durable documentation beyond root metadata, `brownfield` when a
6//! documentation root or a methodology marker shows a settled corpus, and
7//! `needs-decision` when documents sit outside any recognized home. The
8//! rule lives here so a routing skill reads a verdict it can cite instead
9//! of judging "little docs" by feel.
10
11use 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
21/// The directory names a documentation corpus conventionally lives under.
22const DOC_ROOTS: &[&str] = &["docs", "_docs", "doc", "documentation"];
23
24/// Root-level files and directories that mark an existing documentation
25/// methodology, whatever it is.
26const 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
37/// Extensions a durable document conventionally carries.
38const DOC_EXTENSIONS: &[&str] = &["md", "markdown", "adoc", "rst", "org"];
39
40/// Root-level filename stems that are metadata, not a documentation corpus.
41const ROOT_METADATA: &[&str] = &[
42    "readme",
43    "license",
44    "licence",
45    "contributing",
46    "changelog",
47    "agents",
48    "claude",
49    "code_of_conduct",
50];
51
52/// What the target is, for routing.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Classification {
56    /// No durable documentation beyond root metadata: land an instance.
57    Greenfield,
58    /// A settled corpus or a methodology marker: migrate, not just land.
59    Brownfield,
60    /// Documents outside any recognized home: the operator decides.
61    NeedsDecision,
62}
63
64impl Classification {
65    /// The kebab-case verdict word, as the JSON serializes it.
66    #[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/// The document inventory the classification is computed from.
77#[derive(Debug, Serialize)]
78pub struct Documents {
79    /// How many document files the walk found.
80    pub count: usize,
81    /// Every document path, relative to the target, sorted.
82    pub paths: Vec<Utf8PathBuf>,
83}
84
85/// The whole assessment: evidence first, one verdict from it.
86#[derive(Debug, Serialize)]
87pub struct AssessReport {
88    /// The shape version of this document.
89    pub schema: &'static str,
90    /// The assessed repository.
91    pub target: Utf8PathBuf,
92    /// The verdict the evidence below produces.
93    pub classification: Classification,
94    /// The instance report, verbatim from `sdd status`.
95    pub instance: StatusReport,
96    /// Documentation roots found at the target's top level.
97    pub doc_roots: Vec<String>,
98    /// The documentation roots holding any entry at all — the evidence the
99    /// brownfield verdict reads, whatever format or link shape the entries
100    /// have.
101    pub populated_doc_roots: Vec<String>,
102    /// The document inventory.
103    pub documents: Documents,
104    /// Methodology markers found, as target-relative paths.
105    pub methodology_markers: Vec<String>,
106    /// Per profile, the install destinations that already exist.
107    pub collisions: BTreeMap<String, Vec<String>>,
108    /// Whether the target carries a `.draft/` workshop.
109    pub draft_present: bool,
110}
111
112/// Assess `target`, reading and never writing.
113///
114/// # Errors
115///
116/// [`AppError::Usage`] when the target exists and is not a directory,
117/// [`AppError::ManifestInvalid`] when an instance manifest exists but
118/// cannot be trusted — a broken instance must not silently classify — and
119/// [`AppError::Io`] for metadata failures and walk errors.
120pub fn assess(target: &Utf8Path) -> Result<AssessReport, AppError> {
121    // A file target would walk as its own single entry and read as an
122    // empty repository; refuse it instead, on proven metadata only. An
123    // absent path falls through to the walk, whose I/O error names it,
124    // and a metadata failure is an I/O result, never a usage mistake.
125    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    // `is_dir` follows a link and reads false through a broken one, so a
137    // symlink is recognized on its own: a root the project points
138    // elsewhere is evidence whether or not the destination resolves.
139    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    // A populated documentation root is a corpus whatever format it uses:
154    // a tree of .adoc or .rst files under docs/ is exactly as settled as
155    // one of markdown, and a verdict that missed it would land seeds
156    // beside it. A root that is itself a symlink is evidence the same way,
157    // without being followed: the walk does not traverse links, so the
158    // link's presence is what there is to read.
159    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
190/// What one walk over the target observed.
191struct Walked {
192    /// Every document file, relative to the target, sorted.
193    documents: Vec<Utf8PathBuf>,
194    /// The top-level directory names holding any entry at all.
195    populated_roots: Vec<String>,
196}
197
198/// Walk `target` once, with the pruned directories, the workshop, and the
199/// instance's own tree skipped. Symlinks are evidence and are not
200/// followed: a link named like a document still marks its directory as
201/// populated.
202fn 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
247/// Whether `path` is root-level project metadata rather than a corpus.
248fn 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    // Exact stems only: `README.architecture.md` is a document wearing a
260    // metadata prefix, and an allowlist that took every dotted suffix
261    // would classify it away.
262    ROOT_METADATA.iter().any(|metadata| stem == *metadata)
263}
264
265/// Whether an entry sits at `path`, broken symlinks included.
266///
267/// `symlink_metadata` rather than `exists`: a broken symlink named
268/// `mkdocs.yml` is still the project saying it documents itself there.
269/// Absence is the only failure that reads as absence; any other metadata
270/// error propagates, because evidence that cannot be read must never
271/// count as evidence that is not there.
272fn 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
280/// The methodology markers present: root markers, and the conventional
281/// zone directories under each detected documentation root.
282fn 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
300/// Per profile, the install destinations already present at the target.
301fn 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    /// A populated documentation root is a corpus whatever format it uses.
356    #[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    /// A symlink named like a document marks its root populated without
366    /// being followed.
367    #[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    /// A broken documentation-root symlink is still a root, and still
381    /// populated: the project pointed its docs somewhere, and where does
382    /// not matter to the verdict.
383    #[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    /// A broken marker symlink still marks: the project pointed its
395    /// configuration somewhere, and where does not matter to the verdict.
396    #[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    /// A broken symlink at a projected destination is a collision: the
407    /// path is occupied whatever it points at.
408    #[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    /// Evidence that cannot be read is an error, never absence: the
427    /// helper itself is exercised, because a whole-assess call would trip
428    /// over the walk before the marker probe runs.
429    #[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            // Mode 000 stays readable to a privileged runner; the case
443            // this test constructs does not exist there.
444            return;
445        }
446        match result {
447            Err(AppError::Io(_)) => {}
448            other => panic!("expected an I/O error, got {other:?}"),
449        }
450    }
451
452    /// Whether the suite runs privileged, where mode 000 stays readable.
453    fn nix_is_root() -> bool {
454        std::fs::read_dir("/root").is_ok()
455    }
456
457    /// A file target is a usage error, not an empty repository.
458    #[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    /// A documentation root that is itself a symlink is evidence without
468    /// being followed.
469    #[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    /// The allowlist takes exact stems only.
482    #[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}