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    /// Where the docs scratch resolved to. Relative to the target, unless
109    /// the declaration itself names a path outside it.
110    pub docs_scratch: Utf8PathBuf,
111    /// Whether that directory is there.
112    pub docs_scratch_present: bool,
113}
114
115/// The directory name a target with no instance and no variable is checked
116/// for. This is a discovery candidate, never the rule: the rule is the
117/// declared value, and this exists because a target being classified has
118/// declared nothing yet. `paths::docs_root` discovers the same way.
119const DOCS_SCRATCH_CANDIDATE: &str = ".docs-scratch";
120
121/// Where the target keeps material that is not a statement yet.
122///
123/// `named` is what the variable carries, supplied by the caller. The
124/// variable wins, then the instance record, then the candidate above.
125fn 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
131/// Assess `target`, reading and never writing.
132///
133/// # Errors
134///
135/// [`AppError::Usage`] when the target exists and is not a directory,
136/// [`AppError::ManifestInvalid`] when an instance manifest exists but
137/// cannot be trusted — a broken instance must not silently classify — and
138/// [`AppError::Io`] for metadata failures and walk errors.
139pub 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
146/// Assess `target` with the docs-scratch variable's value supplied.
147///
148/// The environment is read at one boundary and passed in, so every case is
149/// reachable from a test. This crate forbids unsafe code, and setting a
150/// variable is unsafe from the 2024 edition on, so a test that could not
151/// inject would read the developer's own shell instead.
152///
153/// # Errors
154///
155/// See [`assess`].
156pub fn assess_with(
157    target: &Utf8Path,
158    named: Option<Utf8PathBuf>,
159    bundle: &dyn crate::release::ReleaseBundle,
160) -> Result<AssessReport, AppError> {
161    // A file target would walk as its own single entry and read as an
162    // empty repository; refuse it instead, on proven metadata only. An
163    // absent path falls through to the walk, whose I/O error names it,
164    // and a metadata failure is an I/O result, never a usage mistake.
165    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    // `is_dir` follows a link and reads false through a broken one, so a
177    // symlink is recognized on its own: a root the project points
178    // elsewhere is evidence whether or not the destination resolves.
179    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    // A populated documentation root is a corpus whatever format it uses:
195    // a tree of .adoc or .rst files under docs/ is exactly as settled as
196    // one of markdown, and a verdict that missed it would land seeds
197    // beside it. A root that is itself a symlink is evidence the same way,
198    // without being followed: the walk does not traverse links, so the
199    // link's presence is what there is to read.
200    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
232/// A path with `.` dropped and every resolvable `..` collapsed.
233///
234/// Lexical rather than `canonicalize`: a declared scratch that does not
235/// exist yet still has to compare equal to the walked entry once it does,
236/// and canonicalizing an absent path fails.
237fn 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
258/// What one walk over the target observed.
259struct Walked {
260    /// Every document file, relative to the target, sorted.
261    documents: Vec<Utf8PathBuf>,
262    /// The top-level directory names holding any entry at all.
263    populated_roots: Vec<String>,
264}
265
266/// Walk `target` once, with the pruned directories, the docs scratch, and
267/// the instance's own tree skipped. Symlinks are evidence and are not
268/// followed: a link named like a document still marks its directory as
269/// populated.
270///
271/// The docs scratch is skipped by path rather than by name, so a scratch
272/// that sits beside the checkout prunes nothing and a scratch inside it
273/// prunes only itself. Without that, staged rewrites would come back as
274/// documents to migrate on the next run.
275fn walk(target: &Utf8Path, scratch: &Utf8Path) -> Result<Walked, AppError> {
276    let mut documents = Vec::new();
277    let mut populated_roots = Vec::new();
278    // The comparison is lexical, so both sides are normalized first. A
279    // variable carries whatever the operator's shell holds, and `a/../a`
280    // names the same directory as `a` while comparing unequal. Reported
281    // present and then not pruned is the worst of both answers.
282    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
327/// Whether `path` is root-level project metadata rather than a corpus.
328fn 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    // Exact stems only: `README.architecture.md` is a document wearing a
340    // metadata prefix, and an allowlist that took every dotted suffix
341    // would classify it away.
342    ROOT_METADATA.iter().any(|metadata| stem == *metadata)
343}
344
345/// Whether an entry sits at `path`, broken symlinks included.
346///
347/// `symlink_metadata` rather than `exists`: a broken symlink named
348/// `mkdocs.yml` is still the project saying it documents itself there.
349/// Absence is the only failure that reads as absence; any other metadata
350/// error propagates, because evidence that cannot be read must never
351/// count as evidence that is not there.
352fn 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
360/// The methodology markers present: root markers, and the conventional
361/// zone directories under each detected documentation root.
362fn 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
380/// Per profile, the install destinations already present at the target.
381fn 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    /// A populated documentation root is a corpus whatever format it uses.
449    #[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    /// A symlink named like a document marks its root populated without
464    /// being followed.
465    #[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    /// A broken documentation-root symlink is still a root, and still
484    /// populated: the project pointed its docs somewhere, and where does
485    /// not matter to the verdict.
486    #[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    /// A broken marker symlink still marks: the project pointed its
503    /// configuration somewhere, and where does not matter to the verdict.
504    #[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    /// A broken symlink at a projected destination is a collision: the
520    /// path is occupied whatever it points at.
521    #[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    /// Evidence that cannot be read is an error, never absence: the
545    /// helper itself is exercised, because a whole-assess call would trip
546    /// over the walk before the marker probe runs.
547    #[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            // Mode 000 stays readable to a privileged runner; the case
561            // this test constructs does not exist there.
562            return;
563        }
564        match result {
565            Err(AppError::Io(_)) => {}
566            other => panic!("expected an I/O error, got {other:?}"),
567        }
568    }
569
570    /// Whether the suite runs privileged, where mode 000 stays readable.
571    fn nix_is_root() -> bool {
572        std::fs::read_dir("/root").is_ok()
573    }
574
575    /// A file target is a usage error, not an empty repository.
576    #[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    /// A documentation root that is itself a symlink is evidence without
591    /// being followed.
592    #[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    /// The allowlist takes exact stems only.
610    #[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    /// The walk prunes the scratch the project declared, wherever that is,
694    /// and the discovery candidate stops applying once one is declared.
695    #[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    /// A scratch beside the checkout prunes nothing inside it.
709    #[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}