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(target: &Utf8Path) -> Result<AssessReport, AppError> {
140    assess_with(target, crate::gates::paths::docs_scratch_variable())
141}
142
143/// Assess `target` with the docs-scratch variable's value supplied.
144///
145/// The environment is read at one boundary and passed in, so every case is
146/// reachable from a test. This crate forbids unsafe code, and setting a
147/// variable is unsafe from the 2024 edition on, so a test that could not
148/// inject would read the developer's own shell instead.
149///
150/// # Errors
151///
152/// See [`assess`].
153pub fn assess_with(
154    target: &Utf8Path,
155    named: Option<Utf8PathBuf>,
156) -> Result<AssessReport, AppError> {
157    // A file target would walk as its own single entry and read as an
158    // empty repository; refuse it instead, on proven metadata only. An
159    // absent path falls through to the walk, whose I/O error names it,
160    // and a metadata failure is an I/O result, never a usage mistake.
161    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    // `is_dir` follows a link and reads false through a broken one, so a
173    // symlink is recognized on its own: a root the project points
174    // elsewhere is evidence whether or not the destination resolves.
175    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    // A populated documentation root is a corpus whatever format it uses:
191    // a tree of .adoc or .rst files under docs/ is exactly as settled as
192    // one of markdown, and a verdict that missed it would land seeds
193    // beside it. A root that is itself a symlink is evidence the same way,
194    // without being followed: the walk does not traverse links, so the
195    // link's presence is what there is to read.
196    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
228/// A path with `.` dropped and every resolvable `..` collapsed.
229///
230/// Lexical rather than `canonicalize`: a declared scratch that does not
231/// exist yet still has to compare equal to the walked entry once it does,
232/// and canonicalizing an absent path fails.
233fn 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
254/// What one walk over the target observed.
255struct Walked {
256    /// Every document file, relative to the target, sorted.
257    documents: Vec<Utf8PathBuf>,
258    /// The top-level directory names holding any entry at all.
259    populated_roots: Vec<String>,
260}
261
262/// Walk `target` once, with the pruned directories, the docs scratch, and
263/// the instance's own tree skipped. Symlinks are evidence and are not
264/// followed: a link named like a document still marks its directory as
265/// populated.
266///
267/// The docs scratch is skipped by path rather than by name, so a scratch
268/// that sits beside the checkout prunes nothing and a scratch inside it
269/// prunes only itself. Without that, staged rewrites would come back as
270/// documents to migrate on the next run.
271fn walk(target: &Utf8Path, scratch: &Utf8Path) -> Result<Walked, AppError> {
272    let mut documents = Vec::new();
273    let mut populated_roots = Vec::new();
274    // The comparison is lexical, so both sides are normalized first. A
275    // variable carries whatever the operator's shell holds, and `a/../a`
276    // names the same directory as `a` while comparing unequal. Reported
277    // present and then not pruned is the worst of both answers.
278    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
323/// Whether `path` is root-level project metadata rather than a corpus.
324fn 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    // Exact stems only: `README.architecture.md` is a document wearing a
336    // metadata prefix, and an allowlist that took every dotted suffix
337    // would classify it away.
338    ROOT_METADATA.iter().any(|metadata| stem == *metadata)
339}
340
341/// Whether an entry sits at `path`, broken symlinks included.
342///
343/// `symlink_metadata` rather than `exists`: a broken symlink named
344/// `mkdocs.yml` is still the project saying it documents itself there.
345/// Absence is the only failure that reads as absence; any other metadata
346/// error propagates, because evidence that cannot be read must never
347/// count as evidence that is not there.
348fn 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
356/// The methodology markers present: root markers, and the conventional
357/// zone directories under each detected documentation root.
358fn 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
376/// Per profile, the install destinations already present at the target.
377fn 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    /// A populated documentation root is a corpus whatever format it uses.
435    #[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    /// A symlink named like a document marks its root populated without
445    /// being followed.
446    #[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    /// A broken documentation-root symlink is still a root, and still
460    /// populated: the project pointed its docs somewhere, and where does
461    /// not matter to the verdict.
462    #[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    /// A broken marker symlink still marks: the project pointed its
474    /// configuration somewhere, and where does not matter to the verdict.
475    #[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    /// A broken symlink at a projected destination is a collision: the
486    /// path is occupied whatever it points at.
487    #[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    /// Evidence that cannot be read is an error, never absence: the
506    /// helper itself is exercised, because a whole-assess call would trip
507    /// over the walk before the marker probe runs.
508    #[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            // Mode 000 stays readable to a privileged runner; the case
522            // this test constructs does not exist there.
523            return;
524        }
525        match result {
526            Err(AppError::Io(_)) => {}
527            other => panic!("expected an I/O error, got {other:?}"),
528        }
529    }
530
531    /// Whether the suite runs privileged, where mode 000 stays readable.
532    fn nix_is_root() -> bool {
533        std::fs::read_dir("/root").is_ok()
534    }
535
536    /// A file target is a usage error, not an empty repository.
537    #[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    /// A documentation root that is itself a symlink is evidence without
547    /// being followed.
548    #[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    /// The allowlist takes exact stems only.
561    #[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    /// The walk prunes the scratch the project declared, wherever that is,
620    /// and the discovery candidate stops applying once one is declared.
621    #[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    /// A scratch beside the checkout prunes nothing inside it.
635    #[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}